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