@eifi1/ui-kit 0.15.3 → 0.15.5

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 (122) hide show
  1. package/README.md +11 -10
  2. package/dist/chart.d.ts +50 -1
  3. package/dist/components/alert-banner.js +1 -1
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +5 -3
  6. package/dist/components/amount-input.js +6 -11
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/button-group.d.ts +2 -3
  9. package/dist/components/calculator.d.ts +2 -3
  10. package/dist/components/chart.d.ts +16 -1
  11. package/dist/components/chart.js +65 -37
  12. package/dist/components/chart.js.map +1 -1
  13. package/dist/components/checkbox.js +1 -1
  14. package/dist/components/checkbox.js.map +1 -1
  15. package/dist/components/chip.d.ts +8 -2
  16. package/dist/components/chip.js +12 -3
  17. package/dist/components/chip.js.map +1 -1
  18. package/dist/components/choice-card.js +1 -1
  19. package/dist/components/choice-card.js.map +1 -1
  20. package/dist/components/copy-button.d.ts +2 -3
  21. package/dist/components/error-boundary.js +1 -1
  22. package/dist/components/error-boundary.js.map +1 -1
  23. package/dist/components/facing-pair.d.ts +50 -0
  24. package/dist/components/file-button.d.ts +2 -3
  25. package/dist/components/file-dropzone.d.ts +2 -3
  26. package/dist/components/file-dropzone.js +1 -1
  27. package/dist/components/file-dropzone.js.map +1 -1
  28. package/dist/components/form-actions.d.ts +2 -3
  29. package/dist/components/form-actions.js +9 -1
  30. package/dist/components/form-actions.js.map +1 -1
  31. package/dist/components/money-field.d.ts +2 -3
  32. package/dist/components/number-field.d.ts +2 -3
  33. package/dist/components/number-input.d.ts +2 -3
  34. package/dist/components/number-input.js +17 -3
  35. package/dist/components/number-input.js.map +1 -1
  36. package/dist/components/numpad-sheet.d.ts +2 -3
  37. package/dist/components/series-chart.d.ts +78 -1
  38. package/dist/components/series-chart.js +74 -13
  39. package/dist/components/series-chart.js.map +1 -1
  40. package/dist/components/settings-fields.d.ts +2 -3
  41. package/dist/components/text-link.d.ts +2 -3
  42. package/dist/components/time-input.d.ts +2 -3
  43. package/dist/components/ui.d.ts +2 -3
  44. package/dist/components/ui.js +7 -7
  45. package/dist/components/ui.js.map +1 -1
  46. package/dist/feedback/feedback-attachment.d.ts +2 -82
  47. package/dist/feedback/feedback-attachment.js +271 -54
  48. package/dist/feedback/feedback-attachment.js.map +1 -1
  49. package/dist/feedback/feedback-dialog.d.ts +2 -105
  50. package/dist/feedback/feedback-dialog.js +56 -25
  51. package/dist/feedback/feedback-dialog.js.map +1 -1
  52. package/dist/feedback/feedback-inbox.d.ts +1 -1
  53. package/dist/feedback/feedback-thread.d.ts +2 -2
  54. package/dist/feedback-attachment-BYAHJMOr.d.ts +289 -0
  55. package/dist/feedback.d.ts +1 -2
  56. package/dist/hooks/use-file-drop.d.ts +2 -3
  57. package/dist/i18n/defaults.d.ts +2 -3
  58. package/dist/i18n/kit-labels.d.ts +2 -3
  59. package/dist/i18n/kit-labels.js +8 -2
  60. package/dist/i18n/kit-labels.js.map +1 -1
  61. package/dist/i18n/locales/de-CH-informal.d.ts +2 -3
  62. package/dist/i18n/locales/de-CH.d.ts +2 -3
  63. package/dist/i18n/locales/de-informal.d.ts +2 -3
  64. package/dist/i18n/locales/de.d.ts +2 -3
  65. package/dist/i18n/locales/de.js +5 -1
  66. package/dist/i18n/locales/de.js.map +1 -1
  67. package/dist/i18n/locales/es.d.ts +2 -3
  68. package/dist/i18n/locales/es.js +5 -1
  69. package/dist/i18n/locales/es.js.map +1 -1
  70. package/dist/i18n/locales/fr.d.ts +2 -3
  71. package/dist/i18n/locales/fr.js +5 -1
  72. package/dist/i18n/locales/fr.js.map +1 -1
  73. package/dist/i18n/locales/hu.d.ts +2 -3
  74. package/dist/i18n/locales/hu.js +5 -1
  75. package/dist/i18n/locales/hu.js.map +1 -1
  76. package/dist/i18n/locales/it.d.ts +2 -3
  77. package/dist/i18n/locales/it.js +5 -1
  78. package/dist/i18n/locales/it.js.map +1 -1
  79. package/dist/i18n/locales/zh.d.ts +2 -3
  80. package/dist/i18n/locales/zh.js +5 -1
  81. package/dist/i18n/locales/zh.js.map +1 -1
  82. package/dist/index.d.ts +4 -5
  83. package/dist/{kit-labels-BHfRBbHs.d.ts → kit-labels-BD2F7sOz.d.ts} +15 -5
  84. package/dist/lib/format.d.ts +15 -1
  85. package/dist/lib/format.js +16 -0
  86. package/dist/lib/format.js.map +1 -1
  87. package/dist/rhf/fields.d.ts +2 -3
  88. package/dist/rhf/form.d.ts +2 -3
  89. package/dist/rhf.d.ts +2 -3
  90. package/dist/shell/app-shell.d.ts +2 -3
  91. package/dist/shell/top-bar-brand.d.ts +2 -3
  92. package/dist/shell.d.ts +2 -3
  93. package/dist/theme/palette-derive.d.ts +3 -0
  94. package/dist/theme/palette-derive.js +7 -0
  95. package/dist/theme/palette-derive.js.map +1 -1
  96. package/dist/wizard/stepper-nav.d.ts +2 -3
  97. package/dist/wizard.d.ts +2 -3
  98. package/package.json +4 -2
  99. package/src/components/alert-banner.tsx +1 -1
  100. package/src/components/amount-input.tsx +16 -14
  101. package/src/components/chart.tsx +102 -41
  102. package/src/components/checkbox.tsx +1 -1
  103. package/src/components/chip.tsx +29 -4
  104. package/src/components/choice-card.tsx +1 -1
  105. package/src/components/error-boundary.tsx +1 -1
  106. package/src/components/file-dropzone.tsx +1 -1
  107. package/src/components/form-actions.tsx +9 -1
  108. package/src/components/number-input.tsx +22 -2
  109. package/src/components/series-chart.tsx +122 -20
  110. package/src/components/ui.tsx +7 -7
  111. package/src/feedback/feedback-attachment.tsx +441 -96
  112. package/src/feedback/feedback-dialog.tsx +147 -50
  113. package/src/i18n/kit-labels.tsx +19 -1
  114. package/src/i18n/locales/de.ts +4 -0
  115. package/src/i18n/locales/es.ts +4 -0
  116. package/src/i18n/locales/fr.ts +4 -0
  117. package/src/i18n/locales/hu.ts +4 -0
  118. package/src/i18n/locales/it.ts +4 -0
  119. package/src/i18n/locales/zh.ts +4 -0
  120. package/src/lib/format.ts +30 -0
  121. package/src/theme/palette-derive.ts +10 -0
  122. package/tokens.css +9 -1
@@ -1,5 +1,5 @@
1
1
  import { cn } from "../lib/cn";
2
- import { useEffect, useRef, useState, type RefObject } from "react";
2
+ import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type RefObject } from "react";
3
3
  import { Camera, FileText, Paperclip, X } from "lucide-react";
4
4
  import { Button } from "../components/ui";
5
5
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
@@ -7,6 +7,10 @@ import type { FeedbackAttachmentLabels } from "./feedback-dialog";
7
7
 
8
8
  export const DEFAULT_ATTACHMENT_ACCEPT = ["image/png", "image/jpeg", "image/webp", "image/gif"];
9
9
  export const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
10
+ /** How many files `<FeedbackAttachmentField multiple>` takes when `max` is not given
11
+ * (0.15.5). Five photos of one problem is already a lot to look through; a report
12
+ * that needs more is a conversation, which the thread is for. */
13
+ export const DEFAULT_MAX_ATTACHMENTS = 5;
10
14
 
11
15
  /**
12
16
  * The field's own strings — the `feedbackAttachment` namespace of `<UiKitProvider
@@ -17,12 +21,22 @@ export const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
17
21
  *
18
22
  * Before 0.7.0 the two optional keys fell back to hard-coded English, so a German app
19
23
  * that relied on its provider got "Capture screenshot" under a German form.
24
+ *
25
+ * The three keys added in 0.15.5 for `multiple` mode are optional, so a complete
26
+ * `UiKitLabels` typed before it still compiles; they fall back to English.
20
27
  */
21
28
  export interface FeedbackAttachmentFieldLabels {
22
29
  attachmentAdd: string;
23
30
  attachmentCapture: string;
24
31
  attachmentPaste: string;
25
32
  attachmentRemove: string;
33
+ /** `multiple` mode: the accessible name of the list of chosen files. */
34
+ attachmentList?: string;
35
+ /** `multiple` mode: one chip's remove button — "Remove photo.jpg". Named per file,
36
+ * because five buttons all called "Remove attachment" say nothing about which. */
37
+ attachmentRemoveFile?: (name: string) => string;
38
+ /** `multiple` mode: the line shown in place of the add buttons once `max` is reached. */
39
+ attachmentLimit?: (max: number) => string;
26
40
  }
27
41
 
28
42
  export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {
@@ -30,10 +44,81 @@ export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels =
30
44
  attachmentCapture: "Capture screenshot",
31
45
  attachmentPaste: "…or paste a screenshot from the clipboard.",
32
46
  attachmentRemove: "Remove attachment",
47
+ attachmentList: "Attachments",
48
+ attachmentRemoveFile: (name) => `Remove ${name}`,
49
+ attachmentLimit: (max) =>
50
+ `Up to ${max} ${max === 1 ? "attachment" : "attachments"} — remove one to add another.`,
33
51
  };
34
52
 
53
+ /** Why a file was turned away. `"count"` only ever comes from `multiple` mode: more
54
+ * files arrived at once than `max` left room for, and the surplus was dropped. */
55
+ export type FeedbackAttachmentError = "type" | "size" | "count";
56
+
57
+ interface FeedbackAttachmentFieldBaseProps {
58
+ /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
59
+ * since 0.7.0; `attachment` (the heading) is only ever read from here. */
60
+ labels?: Partial<FeedbackAttachmentLabels>;
61
+ accept?: string[];
62
+ maxBytes?: number;
63
+ /** Snapshot the app view behind this and return it as a File. A "Capture
64
+ * screenshot" button appears only when it is given. */
65
+ onCaptureScreenshot?: () => Promise<File | null>;
66
+ /** Listen for the paste on `document` rather than on this field's own
67
+ * subtree. For a modal, which owns the whole page while it is up. */
68
+ documentPaste?: boolean;
69
+ /** Listen for the paste within this element's subtree rather than this
70
+ * field's own — for a field standing *beside* the text box a paste is made
71
+ * in, whose common parent is where the event bubbles to. Ignored when
72
+ * `documentPaste` is set. */
73
+ pasteFrom?: RefObject<HTMLElement | null>;
74
+ className?: string;
75
+ }
76
+
77
+ /** One file: choosing a second means removing the first. The field's only mode until 0.15.5. */
78
+ export interface FeedbackAttachmentFieldSingleProps extends FeedbackAttachmentFieldBaseProps {
79
+ multiple?: false;
80
+ value: File | null;
81
+ onChange: (file: File | null) => void;
82
+ onError?: (kind: "type" | "size") => void;
83
+ // Multiple-mode props, refused here: without `multiple` they would be silently ignored.
84
+ max?: never;
85
+ screenshot?: never;
86
+ onScreenshotChange?: never;
87
+ }
88
+
89
+ /**
90
+ * Several files (0.15.5, keksdose dev#578: "pasted two photos, the second overwrote
91
+ * the first"). Every way in ADDS — a pick of several files, a paste, a capture — up to
92
+ * `max`, and each file is a removable chip.
93
+ */
94
+ export interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachmentFieldBaseProps {
95
+ multiple: true;
96
+ value: File[];
97
+ onChange: (files: File[]) => void;
98
+ /** How many files `value` may hold. Default {@link DEFAULT_MAX_ATTACHMENTS} (5). The
99
+ * add buttons and the paste hint are offered only while there is room. */
100
+ max?: number;
101
+ /** Per file: a rejected one is reported and the others are still added. */
102
+ onError?: (kind: FeedbackAttachmentError) => void;
103
+ /**
104
+ * A separate slot for THE screenshot, outside `value` and `max`. Given
105
+ * `onScreenshotChange`, what `onCaptureScreenshot` returns lands here instead of in
106
+ * `value`, shown as the first chip, and the capture button is offered only while the
107
+ * slot is empty — a second snapshot of the same view is not more evidence. This is
108
+ * how `<FeedbackDialog attachments="multiple">` keeps the screenshot apart from the
109
+ * photos; a form built from the parts can do the same.
110
+ */
111
+ screenshot?: File | null;
112
+ onScreenshotChange?: (file: File | null) => void;
113
+ }
114
+
115
+ export type FeedbackAttachmentFieldProps =
116
+ | FeedbackAttachmentFieldSingleProps
117
+ | FeedbackAttachmentFieldMultipleProps;
118
+
35
119
  /**
36
120
  * Picking one picture: the file dialog, a capture of the app view, or a paste.
121
+ * With `multiple`, picking several (see {@link FeedbackAttachmentFieldMultipleProps}).
37
122
  *
38
123
  * Lifted out of {@link FeedbackDialog} so the *reply* half of the feature can
39
124
  * have it too (Steering Design feedback #128). A report is a conversation — it
@@ -61,7 +146,14 @@ export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels =
61
146
  * (Steering Design feedback #140): the element whose subtree is listened to,
62
147
  * handed in by whoever renders both the box and this field side by side.
63
148
  */
64
- export function FeedbackAttachmentField({
149
+ export function FeedbackAttachmentField(props: FeedbackAttachmentFieldProps) {
150
+ // Two components rather than one with branches: the modes hold different state
151
+ // (one preview vs. one per chip), and a hook order that depends on a prop is a
152
+ // crash the day a caller flips it.
153
+ return props.multiple ? <MultipleField {...props} /> : <SingleField {...props} />;
154
+ }
155
+
156
+ function SingleField({
65
157
  value,
66
158
  onChange,
67
159
  labels: labelsProp,
@@ -72,33 +164,11 @@ export function FeedbackAttachmentField({
72
164
  documentPaste = false,
73
165
  pasteFrom,
74
166
  className,
75
- }: {
76
- value: File | null;
77
- onChange: (file: File | null) => void;
78
- /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
79
- * since 0.7.0; `attachment` (the heading) is only ever read from here. */
80
- labels?: Partial<FeedbackAttachmentLabels>;
81
- accept?: string[];
82
- maxBytes?: number;
83
- onError?: (kind: "type" | "size") => void;
84
- /** Snapshot the app view behind this and return it as a File. A "Capture
85
- * screenshot" button appears only when it is given. */
86
- onCaptureScreenshot?: () => Promise<File | null>;
87
- /** Listen for the paste on `document` rather than on this field's own
88
- * subtree. For a modal, which owns the whole page while it is up. */
89
- documentPaste?: boolean;
90
- /** Listen for the paste within this element's subtree rather than this
91
- * field's own — for a field standing *beside* the text box a paste is made
92
- * in, whose common parent is where the event bubbles to. Ignored when
93
- * `documentPaste` is set. */
94
- pasteFrom?: RefObject<HTMLElement | null>;
95
- className?: string;
96
- }) {
167
+ }: FeedbackAttachmentFieldSingleProps) {
97
168
  // The object URL is keyed to the file it was made for, so a stale one (from the
98
169
  // previous file, or after `value` is cleared) is never shown — no reset needed.
99
170
  const [previewFor, setPreviewFor] = useState<{ file: File; url: string } | null>(null);
100
171
  const preview = value && previewFor?.file === value ? previewFor.url : null;
101
- const [capturing, setCapturing] = useState(false);
102
172
  const fileInputRef = useRef<HTMLInputElement>(null);
103
173
  // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.
104
174
  const fileText = useKitFileLabels();
@@ -118,55 +188,25 @@ export function FeedbackAttachmentField({
118
188
 
119
189
  const pick = (file: File | undefined | null) => {
120
190
  if (!file) return;
121
- if (!accept.includes(file.type)) {
122
- onError?.("type");
123
- return;
124
- }
125
- if (file.size > maxBytes) {
126
- onError?.("size");
191
+ const problem = rejection(file, accept, maxBytes);
192
+ if (problem) {
193
+ onError?.(problem);
127
194
  return;
128
195
  }
129
196
  onChange(file);
130
197
  };
131
198
 
132
- // The paste handler is registered once rather than per render, so it reads
133
- // `pick` — which closes over props that change identity on every render — out
134
- // of a ref rather than out of its own dependency list.
135
- const latest = useRef(pick);
136
- useEffect(() => {
137
- latest.current = pick;
138
- });
139
-
140
- const takeImage = (clipboard: DataTransfer | null, stop: () => void) => {
141
- const items = Array.from(clipboard?.items ?? []);
142
- const image = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
143
- const file = image?.getAsFile();
144
- if (!file) return;
145
- // Only once there IS an image: a paste of text into a field must stay a
146
- // paste of text, and a clipboard holding both is a copy whose text half is
147
- // what the field was focused for.
148
- stop();
149
- // Clipboard images arrive named "image.png" at best and unnamed at worst,
150
- // and the name is what the inbox shows beside the thumbnail. A name that
151
- // says where it came from is more use than the browser's.
152
- latest.current(new File([file], pastedName(file.type), { type: file.type }));
153
- };
154
-
155
- // The element listened on, where it is not this field's own subtree: the
156
- // document for a modal, the given parent for an inline editor. Read inside
157
- // the effect rather than in render, because a ref's `current` is only set
158
- // once the parent has mounted — which it has by the time effects run.
159
- useEffect(() => {
160
- const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);
161
- if (!target) return;
162
- const onPaste = (event: Event) => {
163
- const clipboard = (event as ClipboardEvent).clipboardData;
164
- takeImage(clipboard, () => event.preventDefault());
165
- };
166
- target.addEventListener("paste", onPaste);
167
- return () => target.removeEventListener("paste", onPaste);
168
- }, [documentPaste, pasteFrom]);
169
- const listensElsewhere = documentPaste || pasteFrom !== undefined;
199
+ const onPaste = usePaste(
200
+ (images) => {
201
+ // Clipboard images arrive named "image.png" at best and unnamed at worst,
202
+ // and the name is what the inbox shows beside the thumbnail. A name that
203
+ // says where it came from is more use than the browser's.
204
+ const file = images[0];
205
+ pick(new File([file], pastedName(file.type), { type: file.type }));
206
+ },
207
+ documentPaste,
208
+ pasteFrom,
209
+ );
170
210
 
171
211
  return (
172
212
  <div
@@ -175,17 +215,9 @@ export function FeedbackAttachmentField({
175
215
  // against the initial containing block — extending the DOCUMENT height to its own
176
216
  // offset and producing a phantom second scrollbar on any long page.
177
217
  className={cn("relative", className)}
178
- onPaste={
179
- listensElsewhere
180
- ? undefined
181
- : (event) => takeImage(event.clipboardData, () => event.preventDefault())
182
- }
218
+ onPaste={onPaste}
183
219
  >
184
- {labelsProp?.attachment && (
185
- <div className="mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
186
- {labelsProp.attachment}
187
- </div>
188
- )}
220
+ <Heading text={labelsProp?.attachment} />
189
221
  {value ? (
190
222
  <div className="flex items-start gap-2">
191
223
  {value.type.startsWith("image/") && preview ? (
@@ -223,23 +255,7 @@ export function FeedbackAttachmentField({
223
255
  <Paperclip className="size-4" /> {text.attachmentAdd}
224
256
  </Button>
225
257
  {onCaptureScreenshot && (
226
- <Button
227
- type="button"
228
- variant="secondary"
229
- disabled={capturing}
230
- onClick={() => {
231
- if (capturing) return;
232
- setCapturing(true);
233
- void onCaptureScreenshot()
234
- .then((file) => {
235
- if (file) pick(file);
236
- })
237
- .finally(() => setCapturing(false));
238
- }}
239
- >
240
- <Camera className="size-4" />{" "}
241
- {capturing ? "…" : text.attachmentCapture}
242
- </Button>
258
+ <CaptureButton capture={onCaptureScreenshot} onFile={pick} label={text.attachmentCapture} />
243
259
  )}
244
260
  </div>
245
261
  {/* Said out loud, because a gesture with no affordance is a gesture
@@ -263,6 +279,335 @@ export function FeedbackAttachmentField({
263
279
  );
264
280
  }
265
281
 
282
+ function MultipleField({
283
+ value,
284
+ onChange,
285
+ max = DEFAULT_MAX_ATTACHMENTS,
286
+ screenshot = null,
287
+ onScreenshotChange,
288
+ labels: labelsProp,
289
+ accept = DEFAULT_ATTACHMENT_ACCEPT,
290
+ maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
291
+ onError,
292
+ onCaptureScreenshot,
293
+ documentPaste = false,
294
+ pasteFrom,
295
+ className,
296
+ }: FeedbackAttachmentFieldMultipleProps) {
297
+ const rootRef = useRef<HTMLDivElement>(null);
298
+ const fileInputRef = useRef<HTMLInputElement>(null);
299
+ const fileText = useKitFileLabels();
300
+ const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
301
+ const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
302
+ const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
303
+
304
+ const hasSlot = onScreenshotChange !== undefined;
305
+ const room = Math.max(0, max - value.length);
306
+ // The screenshot slot is its own thing: it neither counts against `max` nor is
307
+ // closed by it, so a full set of photos still leaves the capture on offer.
308
+ const canCapture = !!onCaptureScreenshot && (hasSlot ? !screenshot : room > 0);
309
+
310
+ const add = (incoming: File[], pasted = false) => {
311
+ const accepted: File[] = [];
312
+ const taken = new Set([...value, ...(screenshot ? [screenshot] : [])].map((f) => f.name));
313
+ for (const raw of incoming) {
314
+ const problem = rejection(raw, accept, maxBytes);
315
+ if (problem) {
316
+ onError?.(problem);
317
+ continue;
318
+ }
319
+ // Two pastes are two files called "pasted.png", and a backend that stores by
320
+ // name keeps one of them — the very loss this mode exists to end. A picked
321
+ // file keeps its own name: it is the user's, and they may look for it.
322
+ const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
323
+ taken.add(file.name);
324
+ // The same File object twice is one file chosen twice, not two.
325
+ if (!value.includes(file)) accepted.push(file);
326
+ }
327
+ if (accepted.length === 0) return;
328
+ if (accepted.length > room) onError?.("count");
329
+ const kept = accepted.slice(0, room);
330
+ if (kept.length > 0) onChange([...value, ...kept]);
331
+ };
332
+
333
+ const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
334
+
335
+ const capture = (file: File) => {
336
+ if (!hasSlot) {
337
+ add([file]);
338
+ return;
339
+ }
340
+ const problem = rejection(file, accept, maxBytes);
341
+ if (problem) onError?.(problem);
342
+ else onScreenshotChange(file);
343
+ };
344
+
345
+ // Removing a chip removes the button that had focus. Focus goes to the chip that
346
+ // took its place — the next one — and, when it was the last, to the first action
347
+ // button (there is room again, so "Attach" is back), else to the chip before it.
348
+ // Applied once the parent has re-rendered with the shorter list: the field is
349
+ // controlled, and focusing before that would land on the chip being removed.
350
+ const pendingFocus = useRef<number | null>(null);
351
+ useEffect(() => {
352
+ const index = pendingFocus.current;
353
+ if (index === null) return;
354
+ pendingFocus.current = null;
355
+ const root = rootRef.current;
356
+ if (!root) return;
357
+ const removes = root.querySelectorAll<HTMLElement>("[data-attachment-remove]");
358
+ const target =
359
+ removes[index] ?? root.querySelector<HTMLElement>("[data-attachment-action]") ?? removes[index - 1];
360
+ target?.focus();
361
+ }, [value, screenshot]);
362
+
363
+ const chips: Array<{ file: File; screenshot: boolean; remove: () => void }> = [
364
+ ...(hasSlot && screenshot
365
+ ? [{ file: screenshot, screenshot: true, remove: () => onScreenshotChange(null) }]
366
+ : []),
367
+ ...value.map((file) => ({
368
+ file,
369
+ screenshot: false,
370
+ remove: () => onChange(value.filter((f) => f !== file)),
371
+ })),
372
+ ];
373
+
374
+ return (
375
+ <div ref={rootRef} className={cn("relative space-y-2", className)} onPaste={onPaste}>
376
+ <Heading text={labelsProp?.attachment} className="mb-0" />
377
+ {chips.length > 0 && (
378
+ // One chip per row, full width: wrapped chips of name-dependent widths made a
379
+ // ragged block in a 430px dialog, and a column keeps every name and remove
380
+ // button in the same place — the next chip's button is right under the last.
381
+ <ul aria-label={text.attachmentList} className="flex flex-col gap-2">
382
+ {chips.map((chip, index) => (
383
+ <li
384
+ key={fileKey(chip.file)}
385
+ className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-1"
386
+ >
387
+ <ChipPreview file={chip.file} screenshot={chip.screenshot} />
388
+ <div className="min-w-0 flex-1">
389
+ <div className="truncate text-sm text-[var(--text-secondary)]">{chip.file.name}</div>
390
+ <div className="text-xs text-[var(--text-muted)]">{fileText.size(chip.file.size)}</div>
391
+ </div>
392
+ <button
393
+ type="button"
394
+ data-attachment-remove=""
395
+ onClick={() => {
396
+ pendingFocus.current = index;
397
+ chip.remove();
398
+ }}
399
+ aria-label={removeLabel(chip.file.name)}
400
+ className="shrink-0 rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
401
+ >
402
+ <X className="size-4" />
403
+ </button>
404
+ </li>
405
+ ))}
406
+ </ul>
407
+ )}
408
+ {(room > 0 || canCapture) && (
409
+ <div className="flex flex-wrap gap-2">
410
+ {room > 0 && (
411
+ <Button
412
+ type="button"
413
+ variant="secondary"
414
+ data-attachment-action=""
415
+ onClick={() => fileInputRef.current?.click()}
416
+ >
417
+ <Paperclip className="size-4" /> {text.attachmentAdd}
418
+ </Button>
419
+ )}
420
+ {canCapture && onCaptureScreenshot && (
421
+ <CaptureButton
422
+ capture={onCaptureScreenshot}
423
+ onFile={capture}
424
+ label={text.attachmentCapture}
425
+ data-attachment-action=""
426
+ />
427
+ )}
428
+ </div>
429
+ )}
430
+ <p className="text-xs text-[var(--text-muted)]">
431
+ {room > 0 ? text.attachmentPaste : limitLabel(max)}
432
+ </p>
433
+ <input
434
+ ref={fileInputRef}
435
+ type="file"
436
+ multiple={room > 1}
437
+ accept={accept.join(",")}
438
+ className="sr-only"
439
+ // Out of the tab order and the accessibility tree: the button above is
440
+ // the way in, and a second, unlabelled "Choose files" stop is noise.
441
+ tabIndex={-1}
442
+ aria-hidden
443
+ onChange={(e) => {
444
+ add(Array.from(e.target.files ?? []));
445
+ e.target.value = "";
446
+ }}
447
+ />
448
+ </div>
449
+ );
450
+ }
451
+
452
+ function Heading({ text, className }: { text?: string; className?: string }) {
453
+ if (!text) return null;
454
+ return (
455
+ <div className={cn("mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]", className)}>
456
+ {text}
457
+ </div>
458
+ );
459
+ }
460
+
461
+ function CaptureButton({
462
+ capture,
463
+ onFile,
464
+ label,
465
+ ...rest
466
+ }: {
467
+ capture: () => Promise<File | null>;
468
+ onFile: (file: File) => void;
469
+ label: string;
470
+ "data-attachment-action"?: string;
471
+ }) {
472
+ const [capturing, setCapturing] = useState(false);
473
+ return (
474
+ <Button
475
+ type="button"
476
+ variant="secondary"
477
+ disabled={capturing}
478
+ {...rest}
479
+ onClick={() => {
480
+ if (capturing) return;
481
+ setCapturing(true);
482
+ void capture()
483
+ .then((file) => {
484
+ if (file) onFile(file);
485
+ })
486
+ .finally(() => setCapturing(false));
487
+ }}
488
+ >
489
+ <Camera className="size-4" /> {capturing ? "…" : label}
490
+ </Button>
491
+ );
492
+ }
493
+
494
+ /** A chip's picture: the image itself, or a file glyph for anything that is not one.
495
+ * Decorative (`alt=""`) — the name stands beside it. Its own component so the object
496
+ * URL lives exactly as long as the chip: removing the chip unmounts it and revokes. */
497
+ function ChipPreview({ file, screenshot }: { file: File; screenshot: boolean }) {
498
+ const isImage = file.type.startsWith("image/");
499
+ const [previewFor, setPreviewFor] = useState<{ file: File; url: string } | null>(null);
500
+ const url = previewFor?.file === file ? previewFor.url : null;
501
+ useEffect(() => {
502
+ if (!isImage) return;
503
+ const next = URL.createObjectURL(file);
504
+ // As in the single field: an external resource created and revoked with the effect.
505
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- publishing an external resource's handle
506
+ setPreviewFor({ file, url: next });
507
+ return () => URL.revokeObjectURL(next);
508
+ }, [file, isImage]);
509
+
510
+ return (
511
+ <span className="relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-placeholder)]">
512
+ {isImage && url ? (
513
+ <img src={url} alt="" className="size-full object-cover" />
514
+ ) : (
515
+ <FileText aria-hidden className="size-6" />
516
+ )}
517
+ {screenshot && (
518
+ // Marks the slot apart from the photos: it is why the capture button is gone.
519
+ <span className="absolute bottom-0.5 end-0.5 rounded-sm bg-[var(--bg-surface)] p-0.5 text-[var(--text-secondary)]">
520
+ <Camera aria-hidden className="size-3" />
521
+ </span>
522
+ )}
523
+ </span>
524
+ );
525
+ }
526
+
527
+ /** A stable React key per File object — the name is not one (two pastes, two
528
+ * "pasted.png" before renaming; the same photo picked from two folders). */
529
+ const fileKeys = new WeakMap<File, number>();
530
+ let nextFileKey = 0;
531
+ function fileKey(file: File): number {
532
+ let key = fileKeys.get(file);
533
+ if (key === undefined) {
534
+ key = nextFileKey++;
535
+ fileKeys.set(file, key);
536
+ }
537
+ return key;
538
+ }
539
+
540
+ function rejection(file: File, accept: string[], maxBytes: number): "type" | "size" | null {
541
+ if (!accept.includes(file.type)) return "type";
542
+ if (file.size > maxBytes) return "size";
543
+ return null;
544
+ }
545
+
546
+ function renamed(file: File, name: string): File {
547
+ return new File([file], name, { type: file.type });
548
+ }
549
+
550
+ /** `pasted.png`, then `pasted-2.png`, `pasted-3.png`… — the first name not in `taken`. */
551
+ function uniqueName(name: string, taken: Set<string>): string {
552
+ if (!taken.has(name)) return name;
553
+ const dot = name.lastIndexOf(".");
554
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
555
+ let n = 2;
556
+ while (taken.has(`${stem}-${n}${ext}`)) n++;
557
+ return `${stem}-${n}${ext}`;
558
+ }
559
+
560
+ /**
561
+ * The paste listener both modes share. `onImages` gets every image file on the
562
+ * clipboard (the single field takes the first). It is registered once rather than per
563
+ * render, so it reads the handler — which closes over props that change identity on
564
+ * every render — out of a ref rather than out of its own dependency list. Returns the
565
+ * `onPaste` for the field's root, or `undefined` when listening elsewhere.
566
+ */
567
+ function usePaste(
568
+ onImages: (images: File[]) => void,
569
+ documentPaste: boolean,
570
+ pasteFrom: RefObject<HTMLElement | null> | undefined,
571
+ ) {
572
+ const latest = useRef(onImages);
573
+ useEffect(() => {
574
+ latest.current = onImages;
575
+ });
576
+
577
+ // The element listened on, where it is not this field's own subtree: the
578
+ // document for a modal, the given parent for an inline editor. Read inside
579
+ // the effect rather than in render, because a ref's `current` is only set
580
+ // once the parent has mounted — which it has by the time effects run.
581
+ useEffect(() => {
582
+ const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);
583
+ if (!target) return;
584
+ const onPaste = (event: Event) => {
585
+ const clipboard = (event as ClipboardEvent).clipboardData;
586
+ takeImages(clipboard, () => event.preventDefault(), latest.current);
587
+ };
588
+ target.addEventListener("paste", onPaste);
589
+ return () => target.removeEventListener("paste", onPaste);
590
+ }, [documentPaste, pasteFrom]);
591
+
592
+ const listensElsewhere = documentPaste || pasteFrom !== undefined;
593
+ return listensElsewhere
594
+ ? undefined
595
+ : (event: ReactClipboardEvent) => takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
596
+ }
597
+
598
+ function takeImages(clipboard: DataTransfer | null, stop: () => void, onImages: (images: File[]) => void) {
599
+ const images = Array.from(clipboard?.items ?? [])
600
+ .filter((item) => item.kind === "file" && item.type.startsWith("image/"))
601
+ .map((item) => item.getAsFile())
602
+ .filter((file): file is File => file !== null);
603
+ if (images.length === 0) return;
604
+ // Only once there IS an image: a paste of text into a field must stay a
605
+ // paste of text, and a clipboard holding both is a copy whose text half is
606
+ // what the field was focused for.
607
+ stop();
608
+ onImages(images);
609
+ }
610
+
266
611
  /** What a pasted image is called once it is an attachment.
267
612
  *
268
613
  * The extension is read off the mime type rather than assumed to be `.png`: