rift-ds 1.3.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.
Files changed (37) hide show
  1. package/README.md +2 -2
  2. package/components/Attachment/Attachment.css +1036 -0
  3. package/components/Attachment/AttachmentDropZone.d.ts +39 -0
  4. package/components/Attachment/AttachmentDropZone.js +76 -0
  5. package/components/Attachment/AttachmentGroup.d.ts +60 -0
  6. package/components/Attachment/AttachmentGroup.js +179 -0
  7. package/components/Attachment/AttachmentTile.d.ts +80 -0
  8. package/components/Attachment/AttachmentTile.js +143 -0
  9. package/components/Attachment/AttachmentViewer.d.ts +66 -0
  10. package/components/Attachment/AttachmentViewer.js +215 -0
  11. package/components/Attachment/fileTypes.d.ts +101 -0
  12. package/components/Attachment/fileTypes.js +182 -0
  13. package/components/Attachment/typeMark.d.ts +11 -0
  14. package/components/Attachment/typeMark.js +51 -0
  15. package/components/Attachment/useAttachments.d.ts +65 -0
  16. package/components/Attachment/useAttachments.js +201 -0
  17. package/components/ChatMessage/ChatMessage.css +24 -0
  18. package/components/ChatMessage/ChatMessage.d.ts +6 -0
  19. package/components/ChatMessage/ChatMessage.js +8 -5
  20. package/components/Composer/Composer.css +95 -2
  21. package/components/Composer/Composer.d.ts +57 -8
  22. package/components/Composer/Composer.js +179 -6
  23. package/components/DocumentChip/DocumentChip.d.ts +7 -5
  24. package/components/FileInput/FileInput.js +1 -7
  25. package/components/MessageCard/MessageCard.d.ts +1 -1
  26. package/components/registry.json +38 -2
  27. package/components/registry.json.d.ts +38 -2
  28. package/components/registry.json.js +1 -1
  29. package/index.d.ts +4 -0
  30. package/index.js +19 -0
  31. package/package.json +9 -1
  32. package/tokens/registry.json +4 -0
  33. package/tokens/registry.json.d.ts +4 -0
  34. package/tokens/registry.json.js +1 -1
  35. package/tokens/tokens-dark.css +6 -0
  36. package/tokens/tokens-light.css +12 -0
  37. package/tokens/tokens-primitives.css +10 -0
@@ -1,9 +1,21 @@
1
1
  "use client";
2
- import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useRef, useState, useLayoutEffect } from "react";
2
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
3
+ import React, { useRef, useState, useLayoutEffect, useEffect } from "react";
4
+ import { AttachmentTile } from "../Attachment/AttachmentTile.js";
5
+ import { formatBytes } from "../Attachment/fileTypes.js";
4
6
  import { CircularButton } from "../CircularButton/CircularButton.js";
5
7
  import "../../fonts/material-symbols.css";
6
8
  import "./Composer.css";
9
+ const defaultFileAnnouncement = ({
10
+ type,
11
+ names
12
+ }) => {
13
+ const list = names.join(", ");
14
+ if (type === "added") return `Attached ${list}`;
15
+ if (type === "removed") return `Removed ${list}`;
16
+ return `Could not attach ${list}`;
17
+ };
18
+ const dragCarriesFiles = (event) => Array.from(event.dataTransfer?.types ?? []).includes("Files");
7
19
  const Composer = React.forwardRef(
8
20
  ({
9
21
  value,
@@ -17,6 +29,19 @@ const Composer = React.forwardRef(
17
29
  context,
18
30
  contextIcon,
19
31
  contextPlacement = "inside",
32
+ files,
33
+ onFilesSelected,
34
+ onFileRemove,
35
+ onFileClick,
36
+ accept,
37
+ multiple = true,
38
+ attachButton = true,
39
+ pasteThreshold,
40
+ onPasteAsAttachment,
41
+ attachLabel = "Attach files",
42
+ dropLabel = "Drop files to attach",
43
+ filesLabel = "Attachments",
44
+ formatFileAnnouncement = defaultFileAnnouncement,
20
45
  attachments,
21
46
  actions,
22
47
  trailingActions,
@@ -25,6 +50,7 @@ const Composer = React.forwardRef(
25
50
  className = "",
26
51
  onChange,
27
52
  onKeyDown,
53
+ onPaste,
28
54
  ...rest
29
55
  }, ref) => {
30
56
  const baseClass = "ds-composer";
@@ -34,7 +60,17 @@ const Composer = React.forwardRef(
34
60
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
35
61
  const currentValue = isControlled ? value : uncontrolledValue;
36
62
  const disabled = Boolean(rest.disabled);
37
- const canSend = !disabled && currentValue.trim() !== "";
63
+ const queued = files ?? [];
64
+ const hasFiles = queued.length > 0;
65
+ const armed = Boolean(onFilesSelected);
66
+ const anyUploading = queued.some((file) => file.status === "uploading");
67
+ const anyReady = queued.some((file) => file.status === "ready");
68
+ const canSend = !disabled && !anyUploading && (currentValue.trim() !== "" || anyReady);
69
+ const fileInputRef = useRef(null);
70
+ const trayRef = useRef(null);
71
+ const [dragging, setDragging] = useState(false);
72
+ const dragDepth = useRef(0);
73
+ const [announcement, setAnnouncement] = useState("");
38
74
  const setTextareaRef = (node) => {
39
75
  textareaRef.current = node;
40
76
  if (typeof ref === "function") ref(node);
@@ -57,6 +93,92 @@ const Composer = React.forwardRef(
57
93
  observer.observe(node);
58
94
  return () => observer.disconnect();
59
95
  }, []);
96
+ const previousFiles = useRef(queued);
97
+ const announceRef = useRef(formatFileAnnouncement);
98
+ announceRef.current = formatFileAnnouncement;
99
+ useEffect(() => {
100
+ const before = previousFiles.current;
101
+ const now = files ?? [];
102
+ previousFiles.current = now;
103
+ if (before === now) return;
104
+ const beforeById = new Map(before.map((file) => [file.id, file]));
105
+ const nowIds = new Set(now.map((file) => file.id));
106
+ const added = now.filter((file) => !beforeById.has(file.id)).map((file) => file.name);
107
+ const removed = before.filter((file) => !nowIds.has(file.id)).map((file) => file.name);
108
+ const failed = now.filter((file) => file.status === "error" && beforeById.get(file.id)?.status !== "error").map((file) => file.name);
109
+ const parts = [
110
+ failed.length > 0 ? announceRef.current({ type: "failed", names: failed }) : "",
111
+ added.length > 0 ? announceRef.current({ type: "added", names: added }) : "",
112
+ removed.length > 0 ? announceRef.current({ type: "removed", names: removed }) : ""
113
+ ].filter(Boolean);
114
+ if (parts.length > 0) setAnnouncement(parts.join(". "));
115
+ }, [files]);
116
+ const focusAfterRemove = useRef(null);
117
+ useEffect(() => {
118
+ const index = focusAfterRemove.current;
119
+ if (index === null) return;
120
+ focusAfterRemove.current = null;
121
+ const buttons = trayRef.current?.querySelectorAll(
122
+ ".ds-attachment-tile__remove"
123
+ );
124
+ const next = buttons?.[Math.min(index, (buttons?.length ?? 0) - 1)];
125
+ if (next) next.focus();
126
+ else textareaRef.current?.focus();
127
+ }, [files]);
128
+ const handleFileRemove = (id, index) => {
129
+ focusAfterRemove.current = index;
130
+ onFileRemove?.(id);
131
+ };
132
+ const handlePick = (event) => {
133
+ const picked = Array.from(event.target.files ?? []);
134
+ event.target.value = "";
135
+ if (picked.length > 0) onFilesSelected?.(picked);
136
+ };
137
+ const handlePaste = (event) => {
138
+ onPaste?.(event);
139
+ if (event.defaultPrevented) return;
140
+ const pasted = Array.from(event.clipboardData?.files ?? []);
141
+ if (armed && pasted.length > 0) {
142
+ event.preventDefault();
143
+ onFilesSelected?.(pasted);
144
+ return;
145
+ }
146
+ if (pasteThreshold === void 0 || !onPasteAsAttachment) return;
147
+ const text = event.clipboardData?.getData("text/plain") ?? "";
148
+ if (text.length >= pasteThreshold) {
149
+ event.preventDefault();
150
+ onPasteAsAttachment(text);
151
+ }
152
+ };
153
+ const takesDrag = (event) => armed && !disabled && dragCarriesFiles(event);
154
+ const handleDragEnter = (event) => {
155
+ if (!takesDrag(event)) return;
156
+ event.preventDefault();
157
+ event.stopPropagation();
158
+ dragDepth.current += 1;
159
+ setDragging(true);
160
+ };
161
+ const handleDragOver = (event) => {
162
+ if (!takesDrag(event)) return;
163
+ event.preventDefault();
164
+ event.stopPropagation();
165
+ event.dataTransfer.dropEffect = "copy";
166
+ };
167
+ const handleDragLeave = (event) => {
168
+ if (!takesDrag(event)) return;
169
+ event.stopPropagation();
170
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
171
+ if (dragDepth.current === 0) setDragging(false);
172
+ };
173
+ const handleDrop = (event) => {
174
+ if (!takesDrag(event)) return;
175
+ event.preventDefault();
176
+ event.stopPropagation();
177
+ dragDepth.current = 0;
178
+ setDragging(false);
179
+ const dropped = Array.from(event.dataTransfer.files ?? []);
180
+ if (dropped.length > 0) onFilesSelected?.(dropped);
181
+ };
60
182
  const submit = () => {
61
183
  if (streaming || !canSend) return;
62
184
  onSubmit?.(currentValue);
@@ -83,6 +205,7 @@ const Composer = React.forwardRef(
83
205
  aiGlow ? `${baseClass}--ai-glow` : "",
84
206
  streaming ? `${baseClass}--streaming` : "",
85
207
  disabled ? `${baseClass}--disabled` : "",
208
+ dragging ? `${baseClass}--dragging` : "",
86
209
  // The above placement moves the text onto the chip's rail; the shell
87
210
  // needs to know, since the chip is no longer inside it.
88
211
  context && contextPlacement === "above" ? `${baseClass}--context-above` : "",
@@ -107,8 +230,28 @@ const Composer = React.forwardRef(
107
230
  className: classes,
108
231
  style: { "--ds-composer-max-rows": maxRows },
109
232
  onClick: handleShellClick,
233
+ onDragEnter: armed ? handleDragEnter : void 0,
234
+ onDragOver: armed ? handleDragOver : void 0,
235
+ onDragLeave: armed ? handleDragLeave : void 0,
236
+ onDrop: armed ? handleDrop : void 0,
110
237
  children: [
111
238
  !contextAbove && contextChip,
239
+ hasFiles && /* @__PURE__ */ jsx("ul", { className: `${baseClass}__tray`, ref: trayRef, "aria-label": filesLabel, children: queued.map((file, index) => /* @__PURE__ */ jsx("li", { className: `${baseClass}__tray-item`, children: /* @__PURE__ */ jsx(
240
+ AttachmentTile,
241
+ {
242
+ name: file.name,
243
+ kind: file.kind,
244
+ status: file.status,
245
+ progress: file.progress,
246
+ error: file.error,
247
+ previewSrc: file.previewSrc,
248
+ previewAlt: file.previewAlt,
249
+ excerpt: file.excerpt,
250
+ meta: file.size === void 0 ? void 0 : formatBytes(file.size),
251
+ onClick: onFileClick ? () => onFileClick(file) : void 0,
252
+ onRemove: onFileRemove && !disabled ? () => handleFileRemove(file.id, index) : void 0
253
+ }
254
+ ) }, file.id)) }),
112
255
  attachments && /* @__PURE__ */ jsx("div", { className: `${baseClass}__attachments`, children: attachments }),
113
256
  /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, ref: contentRef, children: /* @__PURE__ */ jsx(
114
257
  "textarea",
@@ -120,11 +263,24 @@ const Composer = React.forwardRef(
120
263
  value: currentValue,
121
264
  "aria-label": ariaLabel,
122
265
  onChange: handleChange,
123
- onKeyDown: handleKeyDown
266
+ onKeyDown: handleKeyDown,
267
+ onPaste: handlePaste
124
268
  }
125
269
  ) }),
126
270
  /* @__PURE__ */ jsxs("div", { className: `${baseClass}__footer`, children: [
127
- actions && /* @__PURE__ */ jsx("div", { className: `${baseClass}__actions`, children: actions }),
271
+ (actions || armed && attachButton) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__actions`, children: [
272
+ armed && attachButton && /* @__PURE__ */ jsx(
273
+ CircularButton,
274
+ {
275
+ icon: "add",
276
+ variant: "tertiary",
277
+ ariaLabel: attachLabel,
278
+ disabled,
279
+ onClick: () => fileInputRef.current?.click()
280
+ }
281
+ ),
282
+ actions
283
+ ] }),
128
284
  /* @__PURE__ */ jsxs("div", { className: `${baseClass}__trailing`, children: [
129
285
  trailingActions,
130
286
  streaming ? /* @__PURE__ */ jsx(
@@ -147,7 +303,24 @@ const Composer = React.forwardRef(
147
303
  }
148
304
  )
149
305
  ] })
150
- ] })
306
+ ] }),
307
+ armed && /* @__PURE__ */ jsxs(Fragment, { children: [
308
+ /* @__PURE__ */ jsx(
309
+ "input",
310
+ {
311
+ ref: fileInputRef,
312
+ className: `${baseClass}__file-input`,
313
+ type: "file",
314
+ accept,
315
+ multiple,
316
+ tabIndex: -1,
317
+ "aria-hidden": "true",
318
+ onChange: handlePick
319
+ }
320
+ ),
321
+ /* @__PURE__ */ jsx("div", { className: `${baseClass}__drop-hint`, "aria-hidden": "true", children: dropLabel })
322
+ ] }),
323
+ (armed || hasFiles) && /* @__PURE__ */ jsx("span", { className: `${baseClass}__sr-only`, role: "status", children: announcement })
151
324
  ]
152
325
  }
153
326
  );
@@ -28,14 +28,16 @@ type DocumentChipOwnProps = {
28
28
  export interface DocumentChipProps extends DocumentChipOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof DocumentChipOwnProps> {
29
29
  }
30
30
  /**
31
- * DocumentChip is a compact file reference: a two-line tile with a type
32
- * icon, name, metadata line, upload progress, and an optional remove
33
- * button. It references documents attached to chat messages or queued
34
- * above a composer — Chip stays the one-line pill for attributes, and
35
- * FileInput stays the form control that owns selection.
31
+ * DocumentChip is the earlier, one-row file reference: a type icon, name,
32
+ * metadata line, upload progress, and an optional remove button. It keeps
33
+ * working for existing consumers, and new work uses the attachment family:
34
+ * AttachmentTile for a file, Composer's `files` for a queue, and
35
+ * AttachmentGroup for the files on a sent message.
36
36
  *
37
37
  * The root is always a `<div>`; passing `onClick` turns the body into a
38
38
  * `<button>` so click and remove coexist without nesting controls.
39
+ *
40
+ * @deprecated Use `AttachmentTile` instead.
39
41
  */
40
42
  export declare const DocumentChip: React.ForwardRefExoticComponent<DocumentChipProps & React.RefAttributes<HTMLDivElement>>;
41
43
  export {};
@@ -3,14 +3,8 @@ import { jsxs, jsx } from "react/jsx-runtime";
3
3
  import React, { useState, useRef, useId } from "react";
4
4
  import { Field } from "../Field/Field.js";
5
5
  import "./FileInput.css";
6
+ import { formatBytes } from "../Attachment/fileTypes.js";
6
7
  import "../../fonts/material-symbols.css";
7
- const formatBytes = (bytes) => {
8
- if (bytes === 0) return "0 B";
9
- const units = ["B", "KB", "MB", "GB", "TB"];
10
- const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
11
- const value = bytes / Math.pow(1024, exponent);
12
- return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`;
13
- };
14
8
  const FileInput = React.forwardRef(
15
9
  ({
16
10
  label,
@@ -18,7 +18,7 @@ type MessageCardOwnProps = {
18
18
  actions?: React.ReactNode;
19
19
  /** Additional CSS classes */
20
20
  className?: string;
21
- /** Body content between the description and the footer — rich content, a Prose block, a DocumentChip row. */
21
+ /** Body content between the description and the footer — rich content, a Prose block, an AttachmentGroup. */
22
22
  children?: React.ReactNode;
23
23
  };
24
24
  export interface MessageCardProps extends MessageCardOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof MessageCardOwnProps> {
@@ -161,6 +161,42 @@
161
161
  "folder": "Chart",
162
162
  "recharts": true
163
163
  },
164
+ {
165
+ "name": "AttachmentDropZone",
166
+ "label": "Attachment drop zone",
167
+ "slug": "attachment-drop-zone",
168
+ "description": "A wrapper that makes a whole surface a file drop target, with an outlined overlay while files hover over it.",
169
+ "category": "ai",
170
+ "client": true,
171
+ "folder": "Attachment"
172
+ },
173
+ {
174
+ "name": "AttachmentGroup",
175
+ "label": "Attachment group",
176
+ "slug": "attachment-group",
177
+ "description": "The files on a sent message: one picture at its own shape, or a grid of tiles that collapses behind a count.",
178
+ "category": "ai",
179
+ "client": true,
180
+ "folder": "Attachment"
181
+ },
182
+ {
183
+ "name": "AttachmentTile",
184
+ "label": "Attachment tile",
185
+ "slug": "attachment-tile",
186
+ "description": "One file as a square: a picture, a coloured mark for well-known formats, or a neutral badge, with upload states.",
187
+ "category": "ai",
188
+ "client": false,
189
+ "folder": "Attachment"
190
+ },
191
+ {
192
+ "name": "AttachmentViewer",
193
+ "label": "Attachment viewer",
194
+ "slug": "attachment-viewer",
195
+ "description": "A preview dialog for a message's files, with download, stepping, and plain states for files it cannot show.",
196
+ "category": "ai",
197
+ "client": true,
198
+ "folder": "Attachment"
199
+ },
164
200
  {
165
201
  "name": "Avatar",
166
202
  "label": "Avatar",
@@ -369,7 +405,7 @@
369
405
  "name": "Composer",
370
406
  "label": "Composer",
371
407
  "slug": "composer",
372
- "description": "An auto-growing message input with send and stop states, a page-context note, an attachment slot, and Enter-to-send.",
408
+ "description": "An auto-growing message input with send and stop states, a page-context note, a file tray fed by picker, paste and drop, and Enter-to-send.",
373
409
  "category": "ai",
374
410
  "client": true
375
411
  },
@@ -441,7 +477,7 @@
441
477
  "name": "DocumentChip",
442
478
  "label": "Document chip",
443
479
  "slug": "document-chip",
444
- "description": "A compact file reference with a type icon, name, metadata, and optional remove.",
480
+ "description": "A legacy one-row file reference, superseded by the attachment tile and kept for existing consumers.",
445
481
  "category": "ai",
446
482
  "client": false
447
483
  },
@@ -161,6 +161,42 @@ declare const _default: {
161
161
  "folder": "Chart",
162
162
  "recharts": true
163
163
  },
164
+ {
165
+ "name": "AttachmentDropZone",
166
+ "label": "Attachment drop zone",
167
+ "slug": "attachment-drop-zone",
168
+ "description": "A wrapper that makes a whole surface a file drop target, with an outlined overlay while files hover over it.",
169
+ "category": "ai",
170
+ "client": true,
171
+ "folder": "Attachment"
172
+ },
173
+ {
174
+ "name": "AttachmentGroup",
175
+ "label": "Attachment group",
176
+ "slug": "attachment-group",
177
+ "description": "The files on a sent message: one picture at its own shape, or a grid of tiles that collapses behind a count.",
178
+ "category": "ai",
179
+ "client": true,
180
+ "folder": "Attachment"
181
+ },
182
+ {
183
+ "name": "AttachmentTile",
184
+ "label": "Attachment tile",
185
+ "slug": "attachment-tile",
186
+ "description": "One file as a square: a picture, a coloured mark for well-known formats, or a neutral badge, with upload states.",
187
+ "category": "ai",
188
+ "client": false,
189
+ "folder": "Attachment"
190
+ },
191
+ {
192
+ "name": "AttachmentViewer",
193
+ "label": "Attachment viewer",
194
+ "slug": "attachment-viewer",
195
+ "description": "A preview dialog for a message's files, with download, stepping, and plain states for files it cannot show.",
196
+ "category": "ai",
197
+ "client": true,
198
+ "folder": "Attachment"
199
+ },
164
200
  {
165
201
  "name": "Avatar",
166
202
  "label": "Avatar",
@@ -369,7 +405,7 @@ declare const _default: {
369
405
  "name": "Composer",
370
406
  "label": "Composer",
371
407
  "slug": "composer",
372
- "description": "An auto-growing message input with send and stop states, a page-context note, an attachment slot, and Enter-to-send.",
408
+ "description": "An auto-growing message input with send and stop states, a page-context note, a file tray fed by picker, paste and drop, and Enter-to-send.",
373
409
  "category": "ai",
374
410
  "client": true
375
411
  },
@@ -441,7 +477,7 @@ declare const _default: {
441
477
  "name": "DocumentChip",
442
478
  "label": "Document chip",
443
479
  "slug": "document-chip",
444
- "description": "A compact file reference with a type icon, name, metadata, and optional remove.",
480
+ "description": "A legacy one-row file reference, superseded by the attachment tile and kept for existing consumers.",
445
481
  "category": "ai",
446
482
  "client": false
447
483
  },