rift-ds 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/components/Attachment/Attachment.css +1036 -0
- package/components/Attachment/AttachmentDropZone.d.ts +39 -0
- package/components/Attachment/AttachmentDropZone.js +76 -0
- package/components/Attachment/AttachmentGroup.d.ts +60 -0
- package/components/Attachment/AttachmentGroup.js +179 -0
- package/components/Attachment/AttachmentTile.d.ts +80 -0
- package/components/Attachment/AttachmentTile.js +143 -0
- package/components/Attachment/AttachmentViewer.d.ts +66 -0
- package/components/Attachment/AttachmentViewer.js +215 -0
- package/components/Attachment/fileTypes.d.ts +101 -0
- package/components/Attachment/fileTypes.js +182 -0
- package/components/Attachment/typeMark.d.ts +11 -0
- package/components/Attachment/typeMark.js +51 -0
- package/components/Attachment/useAttachments.d.ts +65 -0
- package/components/Attachment/useAttachments.js +201 -0
- package/components/Chart/BarChart.js +1 -1
- package/components/Chart/ComboChart.js +1 -1
- package/components/Chart/Treemap.js +83 -6
- package/components/ChatMessage/ChatMessage.css +24 -0
- package/components/ChatMessage/ChatMessage.d.ts +6 -0
- package/components/ChatMessage/ChatMessage.js +8 -5
- package/components/ColorPicker/ColorPicker.js +1 -1
- package/components/Composer/Composer.css +140 -5
- package/components/Composer/Composer.d.ts +71 -14
- package/components/Composer/Composer.js +204 -11
- package/components/DatePicker/DatePicker.css +1 -1
- package/components/DocumentChip/DocumentChip.d.ts +7 -5
- package/components/EventCalendar/EventCalendar.css +2 -2
- package/components/FileInput/FileInput.css +1 -1
- package/components/FileInput/FileInput.js +1 -7
- package/components/Globe/Globe.d.ts +5 -3
- package/components/Globe/Globe.js +18 -2
- package/components/Inspector/Inspector.css +108 -3
- package/components/Inspector/InspectorColorPicker.d.ts +43 -0
- package/components/Inspector/InspectorColorPicker.js +51 -0
- package/components/Instructions/Instructions.css +4 -4
- package/components/MessageCard/MessageCard.d.ts +1 -1
- package/components/Prose/Prose.css +27 -0
- package/components/Sparkline/Sparkline.css +2 -2
- package/components/Spinner/Spinner.css +1 -1
- package/components/Timeline/Timeline.css +1 -1
- package/components/WorldMap/land.d.ts +1 -1
- package/components/registry.json +48 -3
- package/components/registry.json.d.ts +48 -3
- package/components/registry.json.js +1 -1
- package/index.d.ts +5 -0
- package/index.js +21 -0
- package/package.json +9 -1
- package/tokens/motion.d.ts +2 -0
- package/tokens/motion.js +2 -0
- package/tokens/presets/contrast.css +2 -4
- package/tokens/presets/coral.css +2 -4
- package/tokens/presets/forest.css +2 -4
- package/tokens/presets/gold.css +4 -4
- package/tokens/presets/mono.css +0 -2
- package/tokens/presets/pink.css +4 -4
- package/tokens/presets/terminal.css +5 -4
- package/tokens/presets/violet.css +2 -4
- package/tokens/presets/warm.css +3 -4
- package/tokens/presets/zest.css +2 -4
- package/tokens/registry.json +5 -0
- package/tokens/registry.json.d.ts +5 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +14 -5
- package/tokens/tokens-light.css +16 -2
- package/tokens/tokens-motion.css +2 -0
- package/tokens/tokens-primitives.css +10 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import React, { useId, useRef, useState, useEffect } from "react";
|
|
4
|
+
import ReactDOM from "react-dom";
|
|
5
|
+
import { Button } from "../Button/Button.js";
|
|
6
|
+
import { CircularButton } from "../CircularButton/CircularButton.js";
|
|
7
|
+
import { formatBytes } from "./fileTypes.js";
|
|
8
|
+
import { renderTypeMark } from "./typeMark.js";
|
|
9
|
+
import { useMounted } from "../../behaviors/useMounted.js";
|
|
10
|
+
import { useLayer } from "../../behaviors/useLayer.js";
|
|
11
|
+
import { useFocusScope } from "../../behaviors/useFocusScope.js";
|
|
12
|
+
import { useScrollLock } from "../../behaviors/useScrollLock.js";
|
|
13
|
+
import "../../fonts/material-symbols.css";
|
|
14
|
+
import "./Attachment.css";
|
|
15
|
+
const AttachmentViewer = React.forwardRef(
|
|
16
|
+
({
|
|
17
|
+
open,
|
|
18
|
+
onOpenChange,
|
|
19
|
+
items,
|
|
20
|
+
activeId,
|
|
21
|
+
defaultActiveId,
|
|
22
|
+
onActiveChange,
|
|
23
|
+
renderPreview,
|
|
24
|
+
onDownload,
|
|
25
|
+
onRetry,
|
|
26
|
+
dismissible = true,
|
|
27
|
+
downloadLabel = "Download",
|
|
28
|
+
closeLabel = "Close",
|
|
29
|
+
previousLabel = "Previous file",
|
|
30
|
+
nextLabel = "Next file",
|
|
31
|
+
retryLabel = "Try again",
|
|
32
|
+
noPreviewLabel = "No preview for this file type",
|
|
33
|
+
failedLabel = "Couldn’t load this file",
|
|
34
|
+
failedDescription = "The link may have expired.",
|
|
35
|
+
formatPosition = (position, total) => `${position} of ${total}`,
|
|
36
|
+
className = "",
|
|
37
|
+
...rest
|
|
38
|
+
}, ref) => {
|
|
39
|
+
const baseClass = "ds-attachment-viewer";
|
|
40
|
+
const titleId = useId();
|
|
41
|
+
const panelRef = useRef(null);
|
|
42
|
+
const setPanelRef = (node) => {
|
|
43
|
+
panelRef.current = node;
|
|
44
|
+
if (typeof ref === "function") ref(node);
|
|
45
|
+
else if (ref) ref.current = node;
|
|
46
|
+
};
|
|
47
|
+
const isControlled = activeId !== void 0;
|
|
48
|
+
const [uncontrolledId, setUncontrolledId] = useState(defaultActiveId);
|
|
49
|
+
const currentId = isControlled ? activeId : uncontrolledId;
|
|
50
|
+
const foundIndex = items.findIndex((item2) => item2.id === currentId);
|
|
51
|
+
const index = foundIndex === -1 ? 0 : foundIndex;
|
|
52
|
+
const item = items[index];
|
|
53
|
+
const total = items.length;
|
|
54
|
+
const mounted = useMounted();
|
|
55
|
+
useLayer({
|
|
56
|
+
open,
|
|
57
|
+
dismissOnEscape: dismissible,
|
|
58
|
+
onDismiss: () => onOpenChange(false)
|
|
59
|
+
});
|
|
60
|
+
useFocusScope(panelRef, { active: open });
|
|
61
|
+
useScrollLock(open);
|
|
62
|
+
const step = (delta) => {
|
|
63
|
+
if (total < 2) return;
|
|
64
|
+
const next = items[(index + delta + total) % total];
|
|
65
|
+
if (!isControlled) setUncontrolledId(next.id);
|
|
66
|
+
onActiveChange?.(next.id);
|
|
67
|
+
};
|
|
68
|
+
const stepRef = useRef(step);
|
|
69
|
+
stepRef.current = step;
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!open || total < 2) return;
|
|
72
|
+
const onKey = (event) => {
|
|
73
|
+
if (event.key === "ArrowLeft") stepRef.current(-1);
|
|
74
|
+
if (event.key === "ArrowRight") stepRef.current(1);
|
|
75
|
+
};
|
|
76
|
+
document.addEventListener("keydown", onKey);
|
|
77
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
78
|
+
}, [open, total]);
|
|
79
|
+
if (!mounted) return null;
|
|
80
|
+
const containerClasses = [baseClass, open ? `${baseClass}--open` : "", className].filter(Boolean).join(" ");
|
|
81
|
+
const sizeText = item?.size === void 0 ? "" : formatBytes(item.size);
|
|
82
|
+
const positionText = total > 1 ? formatPosition(index + 1, total) : "";
|
|
83
|
+
const subtitle = [sizeText, positionText].filter(Boolean).join(" · ");
|
|
84
|
+
let stage = null;
|
|
85
|
+
if (item) {
|
|
86
|
+
const failed = item.status === "error" || item.status === "unavailable";
|
|
87
|
+
const custom = failed ? void 0 : renderPreview?.(item);
|
|
88
|
+
const imageSrc = item.src ?? item.previewSrc;
|
|
89
|
+
if (custom !== void 0 && custom !== null && custom !== false) {
|
|
90
|
+
stage = custom;
|
|
91
|
+
} else if (!failed && imageSrc) {
|
|
92
|
+
stage = /* @__PURE__ */ jsx(
|
|
93
|
+
"img",
|
|
94
|
+
{
|
|
95
|
+
className: `${baseClass}__image`,
|
|
96
|
+
src: imageSrc,
|
|
97
|
+
alt: item.previewAlt ?? item.name
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
} else if (!failed && item.excerpt) {
|
|
101
|
+
stage = /* @__PURE__ */ jsx("p", { className: `${baseClass}__text`, children: item.excerpt });
|
|
102
|
+
} else {
|
|
103
|
+
stage = /* @__PURE__ */ jsxs("div", { className: `${baseClass}__empty`, children: [
|
|
104
|
+
/* @__PURE__ */ jsx("span", { className: `${baseClass}__empty-mark`, children: renderTypeMark(item.name, item.kind) }),
|
|
105
|
+
/* @__PURE__ */ jsx("p", { className: `${baseClass}__empty-title`, children: failed ? failedLabel : noPreviewLabel }),
|
|
106
|
+
/* @__PURE__ */ jsx("p", { className: `${baseClass}__empty-detail`, children: failed ? item.error ?? failedDescription : [item.name, sizeText].filter(Boolean).join(" · ") }),
|
|
107
|
+
(failed && onRetry || onDownload) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__empty-actions`, children: [
|
|
108
|
+
failed && onRetry && /* @__PURE__ */ jsx(
|
|
109
|
+
Button,
|
|
110
|
+
{
|
|
111
|
+
variant: "primary",
|
|
112
|
+
size: "compact",
|
|
113
|
+
label: retryLabel,
|
|
114
|
+
onClick: () => onRetry(item)
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
onDownload && /* @__PURE__ */ jsx(
|
|
118
|
+
Button,
|
|
119
|
+
{
|
|
120
|
+
variant: failed && onRetry ? "secondary" : "primary",
|
|
121
|
+
size: "compact",
|
|
122
|
+
iconLeft: "download",
|
|
123
|
+
label: downloadLabel,
|
|
124
|
+
onClick: () => onDownload(item)
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
] })
|
|
128
|
+
] });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const viewer = /* @__PURE__ */ jsxs("div", { className: containerClasses, children: [
|
|
132
|
+
/* @__PURE__ */ jsx(
|
|
133
|
+
"div",
|
|
134
|
+
{
|
|
135
|
+
className: `${baseClass}__backdrop`,
|
|
136
|
+
onClick: dismissible ? () => onOpenChange(false) : void 0
|
|
137
|
+
}
|
|
138
|
+
),
|
|
139
|
+
/* @__PURE__ */ jsxs(
|
|
140
|
+
"div",
|
|
141
|
+
{
|
|
142
|
+
...rest,
|
|
143
|
+
ref: setPanelRef,
|
|
144
|
+
className: `${baseClass}__panel`,
|
|
145
|
+
role: "dialog",
|
|
146
|
+
"aria-modal": "true",
|
|
147
|
+
"aria-labelledby": titleId,
|
|
148
|
+
tabIndex: -1,
|
|
149
|
+
children: [
|
|
150
|
+
/* @__PURE__ */ jsxs("div", { className: `${baseClass}__header`, children: [
|
|
151
|
+
item && /* @__PURE__ */ jsx("span", { className: `${baseClass}__header-mark`, children: renderTypeMark(item.name, item.kind) }),
|
|
152
|
+
/* @__PURE__ */ jsxs("div", { className: `${baseClass}__heading`, children: [
|
|
153
|
+
/* @__PURE__ */ jsx("h2", { className: `${baseClass}__title`, id: titleId, title: item?.name, children: item?.name }),
|
|
154
|
+
subtitle !== "" && /* @__PURE__ */ jsx("p", { className: `${baseClass}__subtitle`, children: subtitle })
|
|
155
|
+
] }),
|
|
156
|
+
item && onDownload && /* @__PURE__ */ jsx(
|
|
157
|
+
Button,
|
|
158
|
+
{
|
|
159
|
+
className: `${baseClass}__download`,
|
|
160
|
+
variant: "secondary",
|
|
161
|
+
size: "compact",
|
|
162
|
+
iconLeft: "download",
|
|
163
|
+
label: downloadLabel,
|
|
164
|
+
onClick: () => onDownload(item)
|
|
165
|
+
}
|
|
166
|
+
),
|
|
167
|
+
dismissible && /* @__PURE__ */ jsx(
|
|
168
|
+
CircularButton,
|
|
169
|
+
{
|
|
170
|
+
icon: "close",
|
|
171
|
+
variant: "tertiary",
|
|
172
|
+
size: "compact",
|
|
173
|
+
ariaLabel: closeLabel,
|
|
174
|
+
tooltip: false,
|
|
175
|
+
onClick: () => onOpenChange(false)
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
] }),
|
|
179
|
+
/* @__PURE__ */ jsx("div", { className: `${baseClass}__stage`, children: stage }),
|
|
180
|
+
total > 1 && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__footer`, children: [
|
|
181
|
+
/* @__PURE__ */ jsx(
|
|
182
|
+
CircularButton,
|
|
183
|
+
{
|
|
184
|
+
icon: "chevron_left",
|
|
185
|
+
variant: "secondary",
|
|
186
|
+
size: "compact",
|
|
187
|
+
ariaLabel: previousLabel,
|
|
188
|
+
tooltip: false,
|
|
189
|
+
onClick: () => step(-1)
|
|
190
|
+
}
|
|
191
|
+
),
|
|
192
|
+
/* @__PURE__ */ jsx("span", { className: `${baseClass}__position`, "aria-live": "polite", children: positionText }),
|
|
193
|
+
/* @__PURE__ */ jsx(
|
|
194
|
+
CircularButton,
|
|
195
|
+
{
|
|
196
|
+
icon: "chevron_right",
|
|
197
|
+
variant: "secondary",
|
|
198
|
+
size: "compact",
|
|
199
|
+
ariaLabel: nextLabel,
|
|
200
|
+
tooltip: false,
|
|
201
|
+
onClick: () => step(1)
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
] })
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
] });
|
|
209
|
+
return ReactDOM.createPortal(viewer, document.body);
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
AttachmentViewer.displayName = "AttachmentViewer";
|
|
213
|
+
export {
|
|
214
|
+
AttachmentViewer
|
|
215
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file-type model behind the attachment family: what kind of file a
|
|
3
|
+
* name and mime type describe, the short label its type mark wears, and the
|
|
4
|
+
* small formatting helpers a tile needs. No React and no browser APIs, so a
|
|
5
|
+
* server component, a route handler or a test can import it directly.
|
|
6
|
+
*
|
|
7
|
+
* Four kinds are "well known" and carry a colour role of their own (the
|
|
8
|
+
* `--color-file-*` tokens): pdf, spreadsheet, document and presentation.
|
|
9
|
+
* Every other kind is neutral and wears its extension as a badge, so colour
|
|
10
|
+
* stays a signal for the formats a reader recognises at a glance.
|
|
11
|
+
*/
|
|
12
|
+
/** What kind of file an attachment is. */
|
|
13
|
+
export type FileKind = 'pdf' | 'document' | 'spreadsheet' | 'presentation' | 'image' | 'audio' | 'video' | 'archive' | 'code' | 'text' | 'pasted' | 'generic';
|
|
14
|
+
/** Where an attachment is in its life: usable, on its way, failed, or gone. */
|
|
15
|
+
export type AttachmentStatus = 'ready' | 'uploading' | 'error' | 'unavailable';
|
|
16
|
+
/** What a kind looks like on a tile. */
|
|
17
|
+
export interface FileKindMeta {
|
|
18
|
+
/** Reader-facing name of the kind, used in accessible names. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Short uppercase label on the type mark when the extension gives none. */
|
|
21
|
+
mark: string;
|
|
22
|
+
/** Whether the kind has a colour role of its own; the rest are neutral. */
|
|
23
|
+
colored: boolean;
|
|
24
|
+
/** Material Symbol standing for the kind in a plain list row. */
|
|
25
|
+
icon: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const FILE_KINDS: Record<FileKind, FileKindMeta>;
|
|
28
|
+
/** The lower-case extension of a file name, without the dot. Empty when the
|
|
29
|
+
name has none, or is a dotfile with nothing before the dot. */
|
|
30
|
+
export declare function getFileExtension(name: string): string;
|
|
31
|
+
/** The two facts about a file this module reads. A `File` satisfies it. */
|
|
32
|
+
export interface FileLike {
|
|
33
|
+
/** File name, extension included. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Mime type, when the source reported one. */
|
|
36
|
+
type?: string;
|
|
37
|
+
}
|
|
38
|
+
/** The kind a file is, read from its extension first and its mime type second. */
|
|
39
|
+
export declare function getFileKind(file: FileLike): FileKind;
|
|
40
|
+
/**
|
|
41
|
+
* The short uppercase label a tile's type mark wears. A well-known kind
|
|
42
|
+
* keeps its family mark (every word-processor file reads DOC), except where
|
|
43
|
+
* the extension is itself the familiar name (CSV). Any other file wears its
|
|
44
|
+
* own extension.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getFileTypeLabel(name: string, kind: FileKind): string;
|
|
47
|
+
/**
|
|
48
|
+
* Split a file name for middle truncation: the head may be ellipsized, the
|
|
49
|
+
* tail (the last few characters of the stem plus the extension) always
|
|
50
|
+
* shows, so a truncated name still says what the file is.
|
|
51
|
+
*/
|
|
52
|
+
export declare function splitFileName(name: string, tailChars?: number): {
|
|
53
|
+
head: string;
|
|
54
|
+
tail: string;
|
|
55
|
+
};
|
|
56
|
+
/** Render a byte count as a short human-readable string. */
|
|
57
|
+
export declare function formatBytes(bytes: number): string;
|
|
58
|
+
/**
|
|
59
|
+
* Whether a file satisfies a native `accept` string: comma-separated
|
|
60
|
+
* extensions (`.pdf`), exact mime types (`text/csv`) and wildcards
|
|
61
|
+
* (`image/*`). An empty string accepts everything.
|
|
62
|
+
*/
|
|
63
|
+
export declare function matchesAccept(file: FileLike, accept: string): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* One attachment as the family passes it around: what a tile draws, what a
|
|
66
|
+
* group lays out, and what `useAttachments` keeps. Plain data, so it
|
|
67
|
+
* serialises and crosses the server boundary; the `File` itself stays with
|
|
68
|
+
* whoever owns the upload.
|
|
69
|
+
*/
|
|
70
|
+
export interface AttachmentItem {
|
|
71
|
+
/** Stable identity, unique within its list. */
|
|
72
|
+
id: string;
|
|
73
|
+
/** File name, extension included. */
|
|
74
|
+
name: string;
|
|
75
|
+
/** What kind of file it is. */
|
|
76
|
+
kind: FileKind;
|
|
77
|
+
/** Size in bytes. */
|
|
78
|
+
size?: number;
|
|
79
|
+
/** Mime type, when the source reported one. */
|
|
80
|
+
mimeType?: string;
|
|
81
|
+
/** Where the file is in its life. */
|
|
82
|
+
status: AttachmentStatus;
|
|
83
|
+
/** Upload progress, 0 to 100, while `status` is `uploading`. */
|
|
84
|
+
progress?: number;
|
|
85
|
+
/** Why it failed, while `status` is `error`. */
|
|
86
|
+
error?: string;
|
|
87
|
+
/** Image that fills the tile: an object URL for a picture, or a
|
|
88
|
+
host-rendered first page for a document. */
|
|
89
|
+
previewSrc?: string;
|
|
90
|
+
/** Alt text for the preview. Defaults to the name. */
|
|
91
|
+
previewAlt?: string;
|
|
92
|
+
/** Full-size image for the viewer, when the preview is a thumbnail.
|
|
93
|
+
Defaults to `previewSrc`. */
|
|
94
|
+
src?: string;
|
|
95
|
+
/** Intrinsic width of the preview, to reserve its shape before it loads. */
|
|
96
|
+
width?: number;
|
|
97
|
+
/** Intrinsic height of the preview. */
|
|
98
|
+
height?: number;
|
|
99
|
+
/** First lines of a pasted block, drawn as the tile's excerpt. */
|
|
100
|
+
excerpt?: string;
|
|
101
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const FILE_KINDS = {
|
|
2
|
+
pdf: { label: "PDF", mark: "PDF", colored: true, icon: "picture_as_pdf" },
|
|
3
|
+
document: {
|
|
4
|
+
label: "Document",
|
|
5
|
+
mark: "DOC",
|
|
6
|
+
colored: true,
|
|
7
|
+
icon: "description"
|
|
8
|
+
},
|
|
9
|
+
spreadsheet: {
|
|
10
|
+
label: "Spreadsheet",
|
|
11
|
+
mark: "XLS",
|
|
12
|
+
colored: true,
|
|
13
|
+
icon: "table"
|
|
14
|
+
},
|
|
15
|
+
presentation: {
|
|
16
|
+
label: "Presentation",
|
|
17
|
+
mark: "PPT",
|
|
18
|
+
colored: true,
|
|
19
|
+
icon: "co_present"
|
|
20
|
+
},
|
|
21
|
+
image: { label: "Image", mark: "IMG", colored: false, icon: "image" },
|
|
22
|
+
audio: { label: "Audio", mark: "AUDIO", colored: false, icon: "graphic_eq" },
|
|
23
|
+
video: { label: "Video", mark: "VIDEO", colored: false, icon: "movie" },
|
|
24
|
+
archive: {
|
|
25
|
+
label: "Archive",
|
|
26
|
+
mark: "ZIP",
|
|
27
|
+
colored: false,
|
|
28
|
+
icon: "folder_zip"
|
|
29
|
+
},
|
|
30
|
+
code: { label: "Code", mark: "CODE", colored: false, icon: "code" },
|
|
31
|
+
text: { label: "Text", mark: "TXT", colored: false, icon: "notes" },
|
|
32
|
+
pasted: {
|
|
33
|
+
label: "Pasted text",
|
|
34
|
+
mark: "PASTED",
|
|
35
|
+
colored: false,
|
|
36
|
+
icon: "content_paste"
|
|
37
|
+
},
|
|
38
|
+
generic: { label: "File", mark: "FILE", colored: false, icon: "draft" }
|
|
39
|
+
};
|
|
40
|
+
const EXTENSION_KINDS = {
|
|
41
|
+
pdf: "pdf",
|
|
42
|
+
doc: "document",
|
|
43
|
+
docx: "document",
|
|
44
|
+
odt: "document",
|
|
45
|
+
rtf: "document",
|
|
46
|
+
pages: "document",
|
|
47
|
+
xls: "spreadsheet",
|
|
48
|
+
xlsx: "spreadsheet",
|
|
49
|
+
xlsm: "spreadsheet",
|
|
50
|
+
csv: "spreadsheet",
|
|
51
|
+
tsv: "spreadsheet",
|
|
52
|
+
ods: "spreadsheet",
|
|
53
|
+
numbers: "spreadsheet",
|
|
54
|
+
ppt: "presentation",
|
|
55
|
+
pptx: "presentation",
|
|
56
|
+
odp: "presentation",
|
|
57
|
+
png: "image",
|
|
58
|
+
jpg: "image",
|
|
59
|
+
jpeg: "image",
|
|
60
|
+
gif: "image",
|
|
61
|
+
webp: "image",
|
|
62
|
+
avif: "image",
|
|
63
|
+
svg: "image",
|
|
64
|
+
bmp: "image",
|
|
65
|
+
heic: "image",
|
|
66
|
+
tiff: "image",
|
|
67
|
+
mp3: "audio",
|
|
68
|
+
wav: "audio",
|
|
69
|
+
m4a: "audio",
|
|
70
|
+
ogg: "audio",
|
|
71
|
+
flac: "audio",
|
|
72
|
+
aac: "audio",
|
|
73
|
+
mp4: "video",
|
|
74
|
+
mov: "video",
|
|
75
|
+
webm: "video",
|
|
76
|
+
mkv: "video",
|
|
77
|
+
avi: "video",
|
|
78
|
+
zip: "archive",
|
|
79
|
+
tar: "archive",
|
|
80
|
+
gz: "archive",
|
|
81
|
+
tgz: "archive",
|
|
82
|
+
rar: "archive",
|
|
83
|
+
"7z": "archive",
|
|
84
|
+
js: "code",
|
|
85
|
+
jsx: "code",
|
|
86
|
+
ts: "code",
|
|
87
|
+
tsx: "code",
|
|
88
|
+
mjs: "code",
|
|
89
|
+
cjs: "code",
|
|
90
|
+
json: "code",
|
|
91
|
+
html: "code",
|
|
92
|
+
css: "code",
|
|
93
|
+
py: "code",
|
|
94
|
+
rb: "code",
|
|
95
|
+
go: "code",
|
|
96
|
+
rs: "code",
|
|
97
|
+
java: "code",
|
|
98
|
+
c: "code",
|
|
99
|
+
cpp: "code",
|
|
100
|
+
h: "code",
|
|
101
|
+
sh: "code",
|
|
102
|
+
sql: "code",
|
|
103
|
+
yml: "code",
|
|
104
|
+
yaml: "code",
|
|
105
|
+
xml: "code",
|
|
106
|
+
swift: "code",
|
|
107
|
+
kt: "code",
|
|
108
|
+
php: "code",
|
|
109
|
+
txt: "text",
|
|
110
|
+
md: "text",
|
|
111
|
+
log: "text"
|
|
112
|
+
};
|
|
113
|
+
const MIME_KINDS = [
|
|
114
|
+
[/^application\/pdf$/, "pdf"],
|
|
115
|
+
[/^image\//, "image"],
|
|
116
|
+
[/^audio\//, "audio"],
|
|
117
|
+
[/^video\//, "video"],
|
|
118
|
+
[/spreadsheet|ms-excel|^text\/csv$/, "spreadsheet"],
|
|
119
|
+
[/presentation|ms-powerpoint/, "presentation"],
|
|
120
|
+
[/wordprocessing|msword|opendocument\.text|^application\/rtf$/, "document"],
|
|
121
|
+
[/zip|x-tar|x-7z|x-rar|gzip/, "archive"],
|
|
122
|
+
[/json|javascript|typescript|xml|^text\/(html|css|x-)/, "code"],
|
|
123
|
+
[/^text\//, "text"]
|
|
124
|
+
];
|
|
125
|
+
function getFileExtension(name) {
|
|
126
|
+
const dot = name.lastIndexOf(".");
|
|
127
|
+
if (dot <= 0 || dot === name.length - 1) return "";
|
|
128
|
+
return name.slice(dot + 1).toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
function getFileKind(file) {
|
|
131
|
+
const byExtension = EXTENSION_KINDS[getFileExtension(file.name)];
|
|
132
|
+
if (byExtension) return byExtension;
|
|
133
|
+
const mime = file.type ?? "";
|
|
134
|
+
if (mime !== "") {
|
|
135
|
+
for (const [test, kind] of MIME_KINDS) if (test.test(mime)) return kind;
|
|
136
|
+
}
|
|
137
|
+
return "generic";
|
|
138
|
+
}
|
|
139
|
+
const MAX_MARK_CHARS = 5;
|
|
140
|
+
function getFileTypeLabel(name, kind) {
|
|
141
|
+
const extension = getFileExtension(name);
|
|
142
|
+
if (kind === "pasted") return FILE_KINDS.pasted.mark;
|
|
143
|
+
if (FILE_KINDS[kind].colored) {
|
|
144
|
+
return extension === "csv" || extension === "tsv" ? extension.toUpperCase() : FILE_KINDS[kind].mark;
|
|
145
|
+
}
|
|
146
|
+
if (extension !== "" && extension.length <= MAX_MARK_CHARS) return extension.toUpperCase();
|
|
147
|
+
return FILE_KINDS[kind].mark;
|
|
148
|
+
}
|
|
149
|
+
function splitFileName(name, tailChars = 4) {
|
|
150
|
+
const extension = getFileExtension(name);
|
|
151
|
+
const stemLength = extension === "" ? name.length : name.length - extension.length - 1;
|
|
152
|
+
if (stemLength <= tailChars) return { head: "", tail: name };
|
|
153
|
+
const cut = stemLength - tailChars;
|
|
154
|
+
return { head: name.slice(0, cut), tail: name.slice(cut) };
|
|
155
|
+
}
|
|
156
|
+
function formatBytes(bytes) {
|
|
157
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
158
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
159
|
+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
160
|
+
const value = bytes / Math.pow(1024, exponent);
|
|
161
|
+
return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`;
|
|
162
|
+
}
|
|
163
|
+
function matchesAccept(file, accept) {
|
|
164
|
+
const rules = accept.split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean);
|
|
165
|
+
if (rules.length === 0) return true;
|
|
166
|
+
const mime = (file.type ?? "").toLowerCase();
|
|
167
|
+
const name = file.name.toLowerCase();
|
|
168
|
+
return rules.some((rule) => {
|
|
169
|
+
if (rule.startsWith(".")) return name.endsWith(rule);
|
|
170
|
+
if (rule.endsWith("/*")) return mime.startsWith(rule.slice(0, -1));
|
|
171
|
+
return mime === rule;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
FILE_KINDS,
|
|
176
|
+
formatBytes,
|
|
177
|
+
getFileExtension,
|
|
178
|
+
getFileKind,
|
|
179
|
+
getFileTypeLabel,
|
|
180
|
+
matchesAccept,
|
|
181
|
+
splitFileName
|
|
182
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ReactElement } from 'react';
|
|
2
|
+
import { FileKind } from './fileTypes';
|
|
3
|
+
/**
|
|
4
|
+
* Render the type mark for a file. `typeLabel` overrides the derived label.
|
|
5
|
+
* The label is drawn, not announced: the mark is decoration beside a file
|
|
6
|
+
* name that already carries the extension.
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderTypeMark(name: string, kind: FileKind, options?: {
|
|
9
|
+
typeLabel?: string;
|
|
10
|
+
form?: 'icon' | 'badge';
|
|
11
|
+
}): ReactElement;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { getFileTypeLabel, FILE_KINDS } from "./fileTypes.js";
|
|
3
|
+
const BASE = "ds-attachment-mark";
|
|
4
|
+
const glyph = (label) => createElement(
|
|
5
|
+
"svg",
|
|
6
|
+
{ className: `${BASE}__glyph`, viewBox: "0 0 32 32", "aria-hidden": true, focusable: false },
|
|
7
|
+
createElement("path", {
|
|
8
|
+
fill: "currentColor",
|
|
9
|
+
d: "M8.5 1A2.5 2.5 0 0 0 6 3.5v14.5a2.5 2.5 0 0 0 2.5 2.5h15a2.5 2.5 0 0 0 2.5-2.5V8.6a1.5 1.5 0 0 0-.44-1.06l-6.1-6.1A1.5 1.5 0 0 0 18.4 1H8.5Z"
|
|
10
|
+
}),
|
|
11
|
+
createElement("path", {
|
|
12
|
+
className: `${BASE}__fold`,
|
|
13
|
+
d: "M18.5 1.6v4.65c0 .97.78 1.75 1.75 1.75h4.65l-6.4-6.4Z"
|
|
14
|
+
}),
|
|
15
|
+
createElement(
|
|
16
|
+
"text",
|
|
17
|
+
{
|
|
18
|
+
className: `${BASE}__letters`,
|
|
19
|
+
x: 16,
|
|
20
|
+
y: 31,
|
|
21
|
+
textAnchor: "middle",
|
|
22
|
+
// A long override is squeezed to the glyph's width rather than spilling out of it
|
|
23
|
+
...label.length > 3 ? { textLength: 30, lengthAdjust: "spacingAndGlyphs" } : {}
|
|
24
|
+
},
|
|
25
|
+
label
|
|
26
|
+
)
|
|
27
|
+
);
|
|
28
|
+
function renderTypeMark(name, kind, options = {}) {
|
|
29
|
+
const { typeLabel, form = "icon" } = options;
|
|
30
|
+
const label = typeLabel ?? getFileTypeLabel(name, kind);
|
|
31
|
+
const colored = FILE_KINDS[kind].colored;
|
|
32
|
+
const tone = colored ? `${BASE}--${kind}` : `${BASE}--neutral`;
|
|
33
|
+
if (form === "badge") {
|
|
34
|
+
return createElement(
|
|
35
|
+
"span",
|
|
36
|
+
{ className: `${BASE} ${BASE}--badge ${tone}`, "aria-hidden": true },
|
|
37
|
+
// A well-known format keeps its colour as a dot beside the label:
|
|
38
|
+
// the label itself stays in a text colour, so it reads on any theme
|
|
39
|
+
colored ? createElement("span", { className: `${BASE}__dot` }) : null,
|
|
40
|
+
label
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return createElement(
|
|
44
|
+
"span",
|
|
45
|
+
{ className: `${BASE} ${BASE}--icon ${tone}`, "aria-hidden": true },
|
|
46
|
+
colored ? glyph(label) : createElement("span", { className: `${BASE}__chip` }, label)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
renderTypeMark
|
|
51
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AttachmentItem } from './fileTypes';
|
|
2
|
+
/** Why a file was turned away. */
|
|
3
|
+
export type AttachmentRejectionReason = 'type' | 'size' | 'count' | 'duplicate';
|
|
4
|
+
export interface AttachmentRejection {
|
|
5
|
+
/** The file that did not join the list. */
|
|
6
|
+
file: File;
|
|
7
|
+
/** Which rule it broke. */
|
|
8
|
+
reason: AttachmentRejectionReason;
|
|
9
|
+
}
|
|
10
|
+
/** What an upload function is given besides the file. */
|
|
11
|
+
export interface AttachmentUploadContext {
|
|
12
|
+
/** Aborts when the item is removed or the list is cleared. */
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
/** Report progress, 0 to 100. */
|
|
15
|
+
onProgress: (percent: number) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface UseAttachmentsOptions {
|
|
18
|
+
/** Native `accept` string. Files outside it are rejected. */
|
|
19
|
+
accept?: string;
|
|
20
|
+
/** Largest file allowed, in bytes. */
|
|
21
|
+
maxSize?: number;
|
|
22
|
+
/** Most files the list may hold. */
|
|
23
|
+
maxCount?: number;
|
|
24
|
+
/** Let the same file join twice. Off by default; identity is name, size and modified time. */
|
|
25
|
+
allowDuplicates?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Upload one file. While it runs the item is `uploading`; resolving makes
|
|
28
|
+
* it `ready`, rejecting makes it `error` with the thrown message. Omit it
|
|
29
|
+
* and every file is `ready` as soon as it joins.
|
|
30
|
+
*/
|
|
31
|
+
upload?: (file: File, context: AttachmentUploadContext) => Promise<void>;
|
|
32
|
+
/** Fires with the files a call to `add` turned away. */
|
|
33
|
+
onReject?: (rejections: AttachmentRejection[]) => void;
|
|
34
|
+
/** Characters of a pasted block kept as its tile excerpt. */
|
|
35
|
+
excerptLength?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface UseAttachmentsResult {
|
|
38
|
+
/** The list, in the order files joined. */
|
|
39
|
+
items: AttachmentItem[];
|
|
40
|
+
/** Add files; returns the ones turned away. */
|
|
41
|
+
add: (files: File[]) => AttachmentRejection[];
|
|
42
|
+
/** Add a block of pasted text as an attachment of its own. */
|
|
43
|
+
addText: (text: string, name?: string) => void;
|
|
44
|
+
/** Remove one item, aborting its upload and revoking its preview. */
|
|
45
|
+
remove: (id: string) => void;
|
|
46
|
+
/** Run a failed item's upload again. */
|
|
47
|
+
retry: (id: string) => void;
|
|
48
|
+
/** Empty the list, aborting uploads and revoking every preview. */
|
|
49
|
+
clear: () => void;
|
|
50
|
+
/**
|
|
51
|
+
* Empty the list for a send and return what it held, previews still
|
|
52
|
+
* alive. The caller now owns them: see `revokeAttachmentUrls`.
|
|
53
|
+
*/
|
|
54
|
+
take: () => {
|
|
55
|
+
items: AttachmentItem[];
|
|
56
|
+
files: File[];
|
|
57
|
+
};
|
|
58
|
+
/** The `File` behind an item, or the text of a pasted one. */
|
|
59
|
+
getSource: (id: string) => File | string | undefined;
|
|
60
|
+
/** True while any item is uploading. */
|
|
61
|
+
isUploading: boolean;
|
|
62
|
+
}
|
|
63
|
+
/** Release the object URLs behind a list of items. Safe to call twice. */
|
|
64
|
+
export declare function revokeAttachmentUrls(items: AttachmentItem[]): void;
|
|
65
|
+
export declare function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsResult;
|