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
@@ -0,0 +1,201 @@
1
+ "use client";
2
+ import { useState, useRef, useEffect, useCallback } from "react";
3
+ import { matchesAccept, getFileKind } from "./fileTypes.js";
4
+ function revokeAttachmentUrls(items) {
5
+ for (const item of items) {
6
+ if (item.previewSrc?.startsWith("blob:")) URL.revokeObjectURL(item.previewSrc);
7
+ }
8
+ }
9
+ const DEFAULT_EXCERPT_LENGTH = 240;
10
+ let nextId = 0;
11
+ const createId = () => `attachment-${Date.now().toString(36)}-${(nextId++).toString(36)}`;
12
+ const fileIdentity = (file) => `${file.name}:${file.size}:${file.lastModified}`;
13
+ function useAttachments(options = {}) {
14
+ const [items, setItems] = useState([]);
15
+ const itemsRef = useRef([]);
16
+ const sourcesRef = useRef(/* @__PURE__ */ new Map());
17
+ const abortsRef = useRef(/* @__PURE__ */ new Map());
18
+ const optionsRef = useRef(options);
19
+ useEffect(() => {
20
+ optionsRef.current = options;
21
+ });
22
+ const commit = useCallback((next) => {
23
+ itemsRef.current = next;
24
+ setItems(next);
25
+ }, []);
26
+ const patch = useCallback(
27
+ (id, change) => {
28
+ if (!itemsRef.current.some((item) => item.id === id)) return;
29
+ commit(itemsRef.current.map((item) => item.id === id ? { ...item, ...change } : item));
30
+ },
31
+ [commit]
32
+ );
33
+ const startUpload = useCallback(
34
+ (id, file) => {
35
+ const upload = optionsRef.current.upload;
36
+ if (!upload) return;
37
+ const controller = new AbortController();
38
+ abortsRef.current.set(id, controller);
39
+ upload(file, {
40
+ signal: controller.signal,
41
+ onProgress: (percent) => {
42
+ if (!controller.signal.aborted)
43
+ patch(id, { progress: Math.max(0, Math.min(100, percent)) });
44
+ }
45
+ }).then(
46
+ () => {
47
+ if (controller.signal.aborted) return;
48
+ abortsRef.current.delete(id);
49
+ patch(id, { status: "ready", progress: void 0, error: void 0 });
50
+ },
51
+ (reason) => {
52
+ if (controller.signal.aborted) return;
53
+ abortsRef.current.delete(id);
54
+ patch(id, {
55
+ status: "error",
56
+ progress: void 0,
57
+ error: reason instanceof Error && reason.message !== "" ? reason.message : void 0
58
+ });
59
+ }
60
+ );
61
+ },
62
+ [patch]
63
+ );
64
+ const add = useCallback(
65
+ (files) => {
66
+ const { accept, maxSize, maxCount, allowDuplicates, upload, onReject } = optionsRef.current;
67
+ const rejections = [];
68
+ const accepted = [];
69
+ const known = /* @__PURE__ */ new Set();
70
+ for (const source of sourcesRef.current.values()) {
71
+ if (typeof source !== "string") known.add(fileIdentity(source));
72
+ }
73
+ for (const file of files) {
74
+ if (accept && !matchesAccept(file, accept)) {
75
+ rejections.push({ file, reason: "type" });
76
+ } else if (maxSize !== void 0 && file.size > maxSize) {
77
+ rejections.push({ file, reason: "size" });
78
+ } else if (!allowDuplicates && known.has(fileIdentity(file))) {
79
+ rejections.push({ file, reason: "duplicate" });
80
+ } else if (maxCount !== void 0 && itemsRef.current.length + accepted.length >= maxCount) {
81
+ rejections.push({ file, reason: "count" });
82
+ } else {
83
+ known.add(fileIdentity(file));
84
+ const kind = getFileKind(file);
85
+ accepted.push({
86
+ file,
87
+ item: {
88
+ id: createId(),
89
+ name: file.name,
90
+ kind,
91
+ size: file.size,
92
+ mimeType: file.type || void 0,
93
+ status: upload ? "uploading" : "ready",
94
+ previewSrc: kind === "image" ? URL.createObjectURL(file) : void 0
95
+ }
96
+ });
97
+ }
98
+ }
99
+ if (accepted.length > 0) {
100
+ for (const { item, file } of accepted) sourcesRef.current.set(item.id, file);
101
+ commit([...itemsRef.current, ...accepted.map(({ item }) => item)]);
102
+ for (const { item, file } of accepted) startUpload(item.id, file);
103
+ }
104
+ if (rejections.length > 0) onReject?.(rejections);
105
+ return rejections;
106
+ },
107
+ [commit, startUpload]
108
+ );
109
+ const addText = useCallback(
110
+ (text, name = "Pasted text") => {
111
+ const { maxCount, excerptLength = DEFAULT_EXCERPT_LENGTH } = optionsRef.current;
112
+ if (maxCount !== void 0 && itemsRef.current.length >= maxCount) return;
113
+ const id = createId();
114
+ sourcesRef.current.set(id, text);
115
+ commit([
116
+ ...itemsRef.current,
117
+ {
118
+ id,
119
+ name,
120
+ kind: "pasted",
121
+ // Bytes, not characters: the size line should match what a file of
122
+ // this text would weigh.
123
+ size: new Blob([text]).size,
124
+ mimeType: "text/plain",
125
+ status: "ready",
126
+ excerpt: text.trim().slice(0, excerptLength)
127
+ }
128
+ ]);
129
+ },
130
+ [commit]
131
+ );
132
+ const release = useCallback((dropped) => {
133
+ for (const item of dropped) {
134
+ abortsRef.current.get(item.id)?.abort();
135
+ abortsRef.current.delete(item.id);
136
+ sourcesRef.current.delete(item.id);
137
+ }
138
+ revokeAttachmentUrls(dropped);
139
+ }, []);
140
+ const remove = useCallback(
141
+ (id) => {
142
+ const dropped = itemsRef.current.filter((item) => item.id === id);
143
+ if (dropped.length === 0) return;
144
+ release(dropped);
145
+ commit(itemsRef.current.filter((item) => item.id !== id));
146
+ },
147
+ [commit, release]
148
+ );
149
+ const retry = useCallback(
150
+ (id) => {
151
+ const source = sourcesRef.current.get(id);
152
+ if (source === void 0 || typeof source === "string") return;
153
+ if (!optionsRef.current.upload) return;
154
+ patch(id, { status: "uploading", progress: void 0, error: void 0 });
155
+ startUpload(id, source);
156
+ },
157
+ [patch, startUpload]
158
+ );
159
+ const clear = useCallback(() => {
160
+ release(itemsRef.current);
161
+ commit([]);
162
+ }, [commit, release]);
163
+ const take = useCallback(() => {
164
+ const taken = itemsRef.current;
165
+ const files = [];
166
+ for (const item of taken) {
167
+ const source = sourcesRef.current.get(item.id);
168
+ if (source !== void 0 && typeof source !== "string") files.push(source);
169
+ abortsRef.current.get(item.id)?.abort();
170
+ }
171
+ abortsRef.current.clear();
172
+ sourcesRef.current.clear();
173
+ commit([]);
174
+ return { items: taken, files };
175
+ }, [commit]);
176
+ const getSource = useCallback((id) => sourcesRef.current.get(id), []);
177
+ useEffect(() => {
178
+ const aborts = abortsRef.current;
179
+ return () => {
180
+ for (const controller of aborts.values()) controller.abort();
181
+ aborts.clear();
182
+ revokeAttachmentUrls(itemsRef.current);
183
+ itemsRef.current = [];
184
+ };
185
+ }, []);
186
+ return {
187
+ items,
188
+ add,
189
+ addText,
190
+ remove,
191
+ retry,
192
+ clear,
193
+ take,
194
+ getSource,
195
+ isUploading: items.some((item) => item.status === "uploading")
196
+ };
197
+ }
198
+ export {
199
+ revokeAttachmentUrls,
200
+ useAttachments
201
+ };
@@ -87,6 +87,30 @@
87
87
  color: var(--color-text-tertiary);
88
88
  }
89
89
 
90
+ /* ============================================
91
+ ATTACHMENTS
92
+ Files sent with the turn: outside the bubble, above the text, on the
93
+ speaker's side (the body's own alignment places them).
94
+ ============================================ */
95
+
96
+ .ds-chat-message__attachments {
97
+ display: flex;
98
+ /* The full row, not shrink-to-fit: the group inside sizes itself against
99
+ this width, and a row that took its width from the group would give it
100
+ nothing to measure */
101
+ width: 100%;
102
+ min-width: 0;
103
+ }
104
+
105
+ .ds-chat-message--user .ds-chat-message__attachments {
106
+ justify-content: flex-end;
107
+ }
108
+
109
+ /* A compact thread seats smaller tiles */
110
+ .ds-chat-message--compact .ds-chat-message__attachments {
111
+ --ds-attachment-tile-size: 96px;
112
+ }
113
+
90
114
  /* ============================================
91
115
  CONTENT
92
116
  ============================================ */
@@ -49,6 +49,12 @@ type ChatMessageOwnProps = {
49
49
  * feedback row — rather than a secondary affordance.
50
50
  */
51
51
  showActions?: boolean;
52
+ /**
53
+ * Files sent with this turn, drawn outside the bubble on the speaker's
54
+ * side, above the text. Pass an AttachmentGroup. A turn that carries
55
+ * files and no text draws no bubble at all.
56
+ */
57
+ attachments?: React.ReactNode;
52
58
  /** Footer slot under the content — a sources row, an edited note. */
53
59
  footer?: React.ReactNode;
54
60
  /** Additional CSS classes */
@@ -16,6 +16,7 @@ const ChatMessage = React.forwardRef(
16
16
  pendingLabel = "Waiting for a reply",
17
17
  actions,
18
18
  showActions = false,
19
+ attachments,
19
20
  footer,
20
21
  className = "",
21
22
  children,
@@ -24,6 +25,10 @@ const ChatMessage = React.forwardRef(
24
25
  const baseClass = "ds-chat-message";
25
26
  const hasBubble = bubble ?? role === "user";
26
27
  const hideAvatar = grouped || !showAvatar;
28
+ const hasChildren = React.Children.toArray(children).some(
29
+ (child) => typeof child !== "string" || child.trim() !== ""
30
+ );
31
+ const showContent = pending || hasChildren || !attachments;
27
32
  const classes = [
28
33
  baseClass,
29
34
  `${baseClass}--${role}`,
@@ -39,10 +44,7 @@ const ChatMessage = React.forwardRef(
39
44
  avatar !== void 0 && /* @__PURE__ */ jsx(
40
45
  "span",
41
46
  {
42
- className: [
43
- `${baseClass}__gutter`,
44
- hideAvatar ? `${baseClass}__gutter--hidden` : ""
45
- ].filter(Boolean).join(" "),
47
+ className: [`${baseClass}__gutter`, hideAvatar ? `${baseClass}__gutter--hidden` : ""].filter(Boolean).join(" "),
46
48
  children: avatar
47
49
  }
48
50
  ),
@@ -51,7 +53,8 @@ const ChatMessage = React.forwardRef(
51
53
  author && /* @__PURE__ */ jsx("span", { className: `${baseClass}__author`, children: author }),
52
54
  timestamp && /* @__PURE__ */ jsx("span", { className: `${baseClass}__timestamp`, children: timestamp })
53
55
  ] }),
54
- /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, children: pending ? /* @__PURE__ */ jsxs("span", { className: `${baseClass}__pending`, role: "status", children: [
56
+ attachments && /* @__PURE__ */ jsx("div", { className: `${baseClass}__attachments`, children: attachments }),
57
+ showContent && /* @__PURE__ */ jsx("div", { className: `${baseClass}__content`, children: pending ? /* @__PURE__ */ jsxs("span", { className: `${baseClass}__pending`, role: "status", children: [
55
58
  /* @__PURE__ */ jsx("span", { className: `${baseClass}__sr-only`, children: pendingLabel }),
56
59
  /* @__PURE__ */ jsxs("span", { className: `${baseClass}__dots`, "aria-hidden": "true", children: [
57
60
  /* @__PURE__ */ jsx("span", { className: `${baseClass}__dot` }),
@@ -1,6 +1,6 @@
1
1
  /* ============================================
2
2
  COMPOSER COMPONENT
3
- The chat input shell: attachments, auto-growing textarea, send/stop
3
+ The chat input shell: file tray, auto-growing textarea, send/stop
4
4
  ============================================ */
5
5
 
6
6
  /* The focus treatment animates a registered custom property, exactly as
@@ -319,7 +319,100 @@
319
319
  }
320
320
 
321
321
  /* ============================================
322
- ATTACHMENTS ROW
322
+ FILE TRAY
323
+ The queued files: one row of square tiles that scrolls sideways rather
324
+ than growing the shell.
325
+ ============================================ */
326
+
327
+ .ds-composer__tray {
328
+ /* How far the tiles sit in from the shell's edge. The tile corner below
329
+ is derived from it, so the two can only move together. */
330
+ --ds-composer-tray-inset: var(--padding-300);
331
+ /* Concentric with the shell: its radius, less the border, less the inset.
332
+ Floored so a squared theme never computes a negative corner. */
333
+ --ds-attachment-tile-radius: max(
334
+ var(--radius-100),
335
+ calc(var(--ds-composer-radius) - var(--border-025) - var(--ds-composer-tray-inset))
336
+ );
337
+
338
+ display: flex;
339
+ /* A scroller may be squeezed by the column it sits in; the tray keeps
340
+ its tiles' full height */
341
+ flex: none;
342
+ gap: var(--gap-200);
343
+ margin: 0;
344
+ /* The inset on every side but the foot, where the text zone's own top
345
+ padding takes over and only a sliver is kept so the scrollbar of an
346
+ overflowing row does not touch the tiles. The top inset also houses the part of a remove
347
+ button that hangs above its tile: a scroller clips what leaves it. */
348
+ padding: var(--ds-composer-tray-inset) var(--ds-composer-tray-inset) var(--padding-100);
349
+ list-style: none;
350
+ overflow-x: auto;
351
+ /* Tiles are objects, not text: the shell's text cursor stops at them */
352
+ cursor: default;
353
+ scrollbar-width: thin;
354
+ scrollbar-color: var(--color-bg-container-secondary) transparent;
355
+ }
356
+
357
+ .ds-composer__tray-item {
358
+ display: flex;
359
+ flex: none;
360
+ }
361
+
362
+ /* ============================================
363
+ DROP HINT
364
+ While files hover over the shell it says what letting go will do.
365
+ ============================================ */
366
+
367
+ .ds-composer--dragging {
368
+ border-color: var(--color-input-border-selected);
369
+ }
370
+
371
+ .ds-composer__drop-hint {
372
+ position: absolute;
373
+ inset: 0;
374
+ z-index: 1;
375
+ display: flex;
376
+ align-items: center;
377
+ justify-content: center;
378
+ border-radius: inherit;
379
+ background-color: var(--color-bg-container-primary-semi);
380
+ font-family: var(--font-paragraph-sm-emphasis-family);
381
+ font-size: var(--font-paragraph-sm-emphasis-size);
382
+ font-weight: var(--font-paragraph-sm-emphasis-weight);
383
+ line-height: var(--font-paragraph-sm-emphasis-line-height);
384
+ letter-spacing: var(--font-paragraph-sm-emphasis-letter-spacing);
385
+ color: var(--color-text-primary);
386
+ /* Never a target: the shell hears the drag, and a hint that caught the
387
+ pointer would fire a leave on everything under it */
388
+ pointer-events: none;
389
+ opacity: 0;
390
+ transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
391
+ }
392
+
393
+ .ds-composer--dragging .ds-composer__drop-hint {
394
+ opacity: 1;
395
+ }
396
+
397
+ /* The picker itself is never seen: the attach button opens it */
398
+ .ds-composer__file-input {
399
+ display: none;
400
+ }
401
+
402
+ .ds-composer__sr-only {
403
+ position: absolute;
404
+ width: 1px;
405
+ height: 1px;
406
+ padding: 0;
407
+ margin: -1px;
408
+ overflow: hidden;
409
+ clip: rect(0, 0, 0, 0);
410
+ white-space: nowrap;
411
+ border: 0;
412
+ }
413
+
414
+ /* ============================================
415
+ ATTACHMENTS ROW (legacy free-form slot)
323
416
  ============================================ */
324
417
 
325
418
  .ds-composer__attachments {
@@ -1,4 +1,5 @@
1
1
  import { default as React } from 'react';
2
+ import { AttachmentItem } from '../Attachment/fileTypes';
2
3
  /** Props owned by Composer itself — everything else falls through to the <textarea>. */
3
4
  type ComposerOwnProps = {
4
5
  /** Current value for controlled use. Pair with `onValueChange`. */
@@ -13,10 +14,12 @@ type ComposerOwnProps = {
13
14
  onValueChange?: (value: string) => void;
14
15
  /**
15
16
  * Fires with the current value on Enter (without Shift) and on the send
16
- * button — never while `streaming`, and never when the trimmed value is
17
- * empty. Composer does not clear the value: the consumer owns it and clears
18
- * it after a successful submit. Shadows the native `onSubmit` attribute,
19
- * which never fires on a textarea anyway.
17
+ * button — never while `streaming`, never while a file in `files` is
18
+ * still uploading, and never when there is nothing to send. Text is
19
+ * enough, and so is a ready file on its own, in which case the value is
20
+ * an empty string. Composer does not clear the value: the consumer owns
21
+ * it and clears it after a successful submit. Shadows the native
22
+ * `onSubmit` attribute, which never fires on a textarea anyway.
20
23
  */
21
24
  onSubmit?: (value: string) => void;
22
25
  /**
@@ -63,11 +66,57 @@ type ComposerOwnProps = {
63
66
  */
64
67
  contextIcon?: string | React.ReactNode;
65
68
  /**
66
- * Attachment row rendered above the textarea (DocumentChips). Fully
67
- * controlled by the caller — Composer never owns the list.
69
+ * The files queued on this message, drawn as a scrolling row of square
70
+ * tiles above the textarea. Controlled by the caller: Composer draws the
71
+ * list and reports what was added or removed, and never stores a file.
72
+ * `useAttachments` is the usual owner.
73
+ */
74
+ files?: AttachmentItem[];
75
+ /**
76
+ * Fires with the files a person picked, pasted or dropped. Providing it
77
+ * arms all three: the attach button and its picker, file paste in the
78
+ * textarea, and drop on the shell.
79
+ */
80
+ onFilesSelected?: (files: File[]) => void;
81
+ /** Fires with the id of the file whose remove button was pressed. Its presence renders the buttons. */
82
+ onFileRemove?: (id: string) => void;
83
+ /** Fires with a queued file when its tile is pressed. Its presence makes the tiles buttons. */
84
+ onFileClick?: (item: AttachmentItem) => void;
85
+ /** File types the picker offers, in the native `accept` syntax. */
86
+ accept?: string;
87
+ /** Whether the picker allows several files at once. */
88
+ multiple?: boolean;
89
+ /** Whether the built-in attach button shows when `onFilesSelected` is set. */
90
+ attachButton?: boolean;
91
+ /**
92
+ * Pasted text at least this many characters long becomes an attachment
93
+ * instead of landing in the textarea. Off unless set, and inert without
94
+ * `onPasteAsAttachment`.
95
+ */
96
+ pasteThreshold?: number;
97
+ /** Receives pasted text that met `pasteThreshold`. */
98
+ onPasteAsAttachment?: (text: string) => void;
99
+ /** Accessible label for the attach button. */
100
+ attachLabel?: string;
101
+ /** Text shown on the shell while files are dragged over it. */
102
+ dropLabel?: string;
103
+ /** Accessible label for the list of queued files. */
104
+ filesLabel?: string;
105
+ /**
106
+ * Builds the sentence announced to assistive technology when files join
107
+ * the queue, leave it, or fail.
108
+ */
109
+ formatFileAnnouncement?: (change: {
110
+ type: 'added' | 'removed' | 'failed';
111
+ names: string[];
112
+ }) => string;
113
+ /**
114
+ * Free-form row rendered above the textarea, after any `files`.
115
+ *
116
+ * @deprecated Use `files` with `onFilesSelected` and `onFileRemove`, which draw the queue as tiles.
68
117
  */
69
118
  attachments?: React.ReactNode;
70
- /** Leading actions on the left of the action bar (attach button, model picker). */
119
+ /** Leading actions on the left of the action bar, after the attach button (a model picker). */
71
120
  actions?: React.ReactNode;
72
121
  /**
73
122
  * Trailing actions on the right of the action bar, just before the send
@@ -85,7 +134,7 @@ export interface ComposerProps extends ComposerOwnProps, Omit<React.ComponentPro
85
134
  }
86
135
  /**
87
136
  * Composer is the chat input shell: an optional context note ("Looking at
88
- * “Page name”"), an attachments row, an auto-growing textarea, a leading
137
+ * “Page name”"), a tray of queued files, an auto-growing textarea, a leading
89
138
  * actions slot, and a trailing send button — the one sanctioned
90
139
  * primary-action teal in the chat set, because sending a message is a
91
140
  * genuine primary CTA. While `streaming`, send becomes stop and Enter