@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,82 +1,2 @@
1
- import * as react from 'react';
2
- import { RefObject } from 'react';
3
- import { FeedbackAttachmentLabels } from './feedback-dialog.js';
4
-
5
- declare const DEFAULT_ATTACHMENT_ACCEPT: string[];
6
- declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
7
- /**
8
- * The field's own strings — the `feedbackAttachment` namespace of `<UiKitProvider
9
- * labels>`. The keys are {@link FeedbackAttachmentLabels}' own, so the `labels` prop
10
- * (and a whole `FeedbackDialogLabels` handed down by the dialog) merges over them
11
- * one to one. The optional heading, `attachment`, stays a prop: a note editor puts
12
- * the buttons straight under its textarea and wants none.
13
- *
14
- * Before 0.7.0 the two optional keys fell back to hard-coded English, so a German app
15
- * that relied on its provider got "Capture screenshot" under a German form.
16
- */
17
- interface FeedbackAttachmentFieldLabels {
18
- attachmentAdd: string;
19
- attachmentCapture: string;
20
- attachmentPaste: string;
21
- attachmentRemove: string;
22
- }
23
- declare const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels;
24
- /**
25
- * Picking one picture: the file dialog, a capture of the app view, or a paste.
26
- *
27
- * Lifted out of {@link FeedbackDialog} so the *reply* half of the feature can
28
- * have it too (Steering Design feedback #128). A report is a conversation — it
29
- * is filed, it is answered, and the reporter sends it back saying that is not
30
- * what they meant — and the screenshot showing what they mean is taken at
31
- * whichever of those points they looked. Only the first of them had a way to
32
- * attach one, so everything after it had to be described in words.
33
- *
34
- * **Three ways in, and they are three because no one of them covers the others.**
35
- * The file dialog needs a file on disk; `onCaptureScreenshot` snapshots the
36
- * *whole* app view; and the clipboard is where a region snip already is on every
37
- * platform (Steering Design feedback #39). Somebody who wanted to show one panel
38
- * had to save a crop and then find it again.
39
- *
40
- * `documentPaste` and `pasteFrom` decide where the paste is listened for, and
41
- * it is a real choice rather than a flag with a default. A modal traps focus,
42
- * so while it is up every paste in the page is meant for it — including one
43
- * made with nothing in particular focused, which never reaches a React
44
- * `onPaste` on a child: that is `documentPaste`. An editor **inline on a
45
- * page** is not that: the page around it has its own fields, so it listens
46
- * within its own subtree and a paste elsewhere stays where it was aimed. But
47
- * "its own subtree" has to include the text box the paste is actually made in,
48
- * and that box is this field's *sibling*, not its child — a paste in it bubbles
49
- * to their common parent and never through here. `pasteFrom` is that parent
50
- * (Steering Design feedback #140): the element whose subtree is listened to,
51
- * handed in by whoever renders both the box and this field side by side.
52
- */
53
- declare function FeedbackAttachmentField({ value, onChange, labels: labelsProp, accept, maxBytes, onError, onCaptureScreenshot, documentPaste, pasteFrom, className, }: {
54
- value: File | null;
55
- onChange: (file: File | null) => void;
56
- /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
57
- * since 0.7.0; `attachment` (the heading) is only ever read from here. */
58
- labels?: Partial<FeedbackAttachmentLabels>;
59
- accept?: string[];
60
- maxBytes?: number;
61
- onError?: (kind: "type" | "size") => void;
62
- /** Snapshot the app view behind this and return it as a File. A "Capture
63
- * screenshot" button appears only when it is given. */
64
- onCaptureScreenshot?: () => Promise<File | null>;
65
- /** Listen for the paste on `document` rather than on this field's own
66
- * subtree. For a modal, which owns the whole page while it is up. */
67
- documentPaste?: boolean;
68
- /** Listen for the paste within this element's subtree rather than this
69
- * field's own — for a field standing *beside* the text box a paste is made
70
- * in, whose common parent is where the event bubbles to. Ignored when
71
- * `documentPaste` is set. */
72
- pasteFrom?: RefObject<HTMLElement | null>;
73
- className?: string;
74
- }): react.JSX.Element;
75
- /** What a pasted image is called once it is an attachment.
76
- *
77
- * The extension is read off the mime type rather than assumed to be `.png`:
78
- * Safari puts TIFF on the clipboard and a file called `pasted.png` that is not
79
- * a PNG is one the receiving end opens wrong. */
80
- declare function pastedName(type: string): string;
81
-
82
- export { DEFAULT_ATTACHMENT_ACCEPT, DEFAULT_FEEDBACK_ATTACHMENT_LABELS, DEFAULT_MAX_ATTACHMENT_BYTES, FeedbackAttachmentField, type FeedbackAttachmentFieldLabels, pastedName };
1
+ import 'react';
2
+ export { D as DEFAULT_ATTACHMENT_ACCEPT, a as DEFAULT_FEEDBACK_ATTACHMENT_LABELS, c as DEFAULT_MAX_ATTACHMENTS, d as DEFAULT_MAX_ATTACHMENT_BYTES, F as FeedbackAttachmentError, e as FeedbackAttachmentField, f as FeedbackAttachmentFieldLabels, g as FeedbackAttachmentFieldMultipleProps, h as FeedbackAttachmentFieldProps, i as FeedbackAttachmentFieldSingleProps, t as pastedName } from '../feedback-attachment-BYAHJMOr.js';
@@ -7,13 +7,20 @@ import { Button } from "../components/ui.js";
7
7
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
8
8
  const DEFAULT_ATTACHMENT_ACCEPT = ["image/png", "image/jpeg", "image/webp", "image/gif"];
9
9
  const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
10
+ const DEFAULT_MAX_ATTACHMENTS = 5;
10
11
  const DEFAULT_FEEDBACK_ATTACHMENT_LABELS = {
11
12
  attachmentAdd: "Attach image",
12
13
  attachmentCapture: "Capture screenshot",
13
14
  attachmentPaste: "\u2026or paste a screenshot from the clipboard.",
14
- attachmentRemove: "Remove attachment"
15
+ attachmentRemove: "Remove attachment",
16
+ attachmentList: "Attachments",
17
+ attachmentRemoveFile: (name) => `Remove ${name}`,
18
+ attachmentLimit: (max) => `Up to ${max} ${max === 1 ? "attachment" : "attachments"} \u2014 remove one to add another.`
15
19
  };
16
- function FeedbackAttachmentField({
20
+ function FeedbackAttachmentField(props) {
21
+ return props.multiple ? /* @__PURE__ */ jsx(MultipleField, { ...props }) : /* @__PURE__ */ jsx(SingleField, { ...props });
22
+ }
23
+ function SingleField({
17
24
  value,
18
25
  onChange,
19
26
  labels: labelsProp,
@@ -27,7 +34,6 @@ function FeedbackAttachmentField({
27
34
  }) {
28
35
  const [previewFor, setPreviewFor] = useState(null);
29
36
  const preview = value && previewFor?.file === value ? previewFor.url : null;
30
- const [capturing, setCapturing] = useState(false);
31
37
  const fileInputRef = useRef(null);
32
38
  const fileText = useKitFileLabels();
33
39
  const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
@@ -39,46 +45,28 @@ function FeedbackAttachmentField({
39
45
  }, [value]);
40
46
  const pick = (file) => {
41
47
  if (!file) return;
42
- if (!accept.includes(file.type)) {
43
- onError?.("type");
44
- return;
45
- }
46
- if (file.size > maxBytes) {
47
- onError?.("size");
48
+ const problem = rejection(file, accept, maxBytes);
49
+ if (problem) {
50
+ onError?.(problem);
48
51
  return;
49
52
  }
50
53
  onChange(file);
51
54
  };
52
- const latest = useRef(pick);
53
- useEffect(() => {
54
- latest.current = pick;
55
- });
56
- const takeImage = (clipboard, stop) => {
57
- const items = Array.from(clipboard?.items ?? []);
58
- const image = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
59
- const file = image?.getAsFile();
60
- if (!file) return;
61
- stop();
62
- latest.current(new File([file], pastedName(file.type), { type: file.type }));
63
- };
64
- useEffect(() => {
65
- const target = documentPaste ? document : pasteFrom?.current ?? null;
66
- if (!target) return;
67
- const onPaste = (event) => {
68
- const clipboard = event.clipboardData;
69
- takeImage(clipboard, () => event.preventDefault());
70
- };
71
- target.addEventListener("paste", onPaste);
72
- return () => target.removeEventListener("paste", onPaste);
73
- }, [documentPaste, pasteFrom]);
74
- const listensElsewhere = documentPaste || pasteFrom !== void 0;
55
+ const onPaste = usePaste(
56
+ (images) => {
57
+ const file = images[0];
58
+ pick(new File([file], pastedName(file.type), { type: file.type }));
59
+ },
60
+ documentPaste,
61
+ pasteFrom
62
+ );
75
63
  return /* @__PURE__ */ jsxs(
76
64
  "div",
77
65
  {
78
66
  className: cn("relative", className),
79
- onPaste: listensElsewhere ? void 0 : (event) => takeImage(event.clipboardData, () => event.preventDefault()),
67
+ onPaste,
80
68
  children: [
81
- labelsProp?.attachment && /* @__PURE__ */ jsx("div", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]", children: labelsProp.attachment }),
69
+ /* @__PURE__ */ jsx(Heading, { text: labelsProp?.attachment }),
82
70
  value ? /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-2", children: [
83
71
  value.type.startsWith("image/") && preview ? /* @__PURE__ */ jsx(
84
72
  "img",
@@ -113,26 +101,7 @@ function FeedbackAttachmentField({
113
101
  " ",
114
102
  text.attachmentAdd
115
103
  ] }),
116
- onCaptureScreenshot && /* @__PURE__ */ jsxs(
117
- Button,
118
- {
119
- type: "button",
120
- variant: "secondary",
121
- disabled: capturing,
122
- onClick: () => {
123
- if (capturing) return;
124
- setCapturing(true);
125
- void onCaptureScreenshot().then((file) => {
126
- if (file) pick(file);
127
- }).finally(() => setCapturing(false));
128
- },
129
- children: [
130
- /* @__PURE__ */ jsx(Camera, { className: "size-4" }),
131
- " ",
132
- capturing ? "\u2026" : text.attachmentCapture
133
- ]
134
- }
135
- )
104
+ onCaptureScreenshot && /* @__PURE__ */ jsx(CaptureButton, { capture: onCaptureScreenshot, onFile: pick, label: text.attachmentCapture })
136
105
  ] }),
137
106
  /* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--text-muted)]", children: text.attachmentPaste })
138
107
  ] }),
@@ -153,6 +122,253 @@ function FeedbackAttachmentField({
153
122
  }
154
123
  );
155
124
  }
125
+ function MultipleField({
126
+ value,
127
+ onChange,
128
+ max = DEFAULT_MAX_ATTACHMENTS,
129
+ screenshot = null,
130
+ onScreenshotChange,
131
+ labels: labelsProp,
132
+ accept = DEFAULT_ATTACHMENT_ACCEPT,
133
+ maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
134
+ onError,
135
+ onCaptureScreenshot,
136
+ documentPaste = false,
137
+ pasteFrom,
138
+ className
139
+ }) {
140
+ const rootRef = useRef(null);
141
+ const fileInputRef = useRef(null);
142
+ const fileText = useKitFileLabels();
143
+ const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
144
+ const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile;
145
+ const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit;
146
+ const hasSlot = onScreenshotChange !== void 0;
147
+ const room = Math.max(0, max - value.length);
148
+ const canCapture = !!onCaptureScreenshot && (hasSlot ? !screenshot : room > 0);
149
+ const add = (incoming, pasted = false) => {
150
+ const accepted = [];
151
+ const taken = new Set([...value, ...screenshot ? [screenshot] : []].map((f) => f.name));
152
+ for (const raw of incoming) {
153
+ const problem = rejection(raw, accept, maxBytes);
154
+ if (problem) {
155
+ onError?.(problem);
156
+ continue;
157
+ }
158
+ const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
159
+ taken.add(file.name);
160
+ if (!value.includes(file)) accepted.push(file);
161
+ }
162
+ if (accepted.length === 0) return;
163
+ if (accepted.length > room) onError?.("count");
164
+ const kept = accepted.slice(0, room);
165
+ if (kept.length > 0) onChange([...value, ...kept]);
166
+ };
167
+ const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
168
+ const capture = (file) => {
169
+ if (!hasSlot) {
170
+ add([file]);
171
+ return;
172
+ }
173
+ const problem = rejection(file, accept, maxBytes);
174
+ if (problem) onError?.(problem);
175
+ else onScreenshotChange(file);
176
+ };
177
+ const pendingFocus = useRef(null);
178
+ useEffect(() => {
179
+ const index = pendingFocus.current;
180
+ if (index === null) return;
181
+ pendingFocus.current = null;
182
+ const root = rootRef.current;
183
+ if (!root) return;
184
+ const removes = root.querySelectorAll("[data-attachment-remove]");
185
+ const target = removes[index] ?? root.querySelector("[data-attachment-action]") ?? removes[index - 1];
186
+ target?.focus();
187
+ }, [value, screenshot]);
188
+ const chips = [
189
+ ...hasSlot && screenshot ? [{ file: screenshot, screenshot: true, remove: () => onScreenshotChange(null) }] : [],
190
+ ...value.map((file) => ({
191
+ file,
192
+ screenshot: false,
193
+ remove: () => onChange(value.filter((f) => f !== file))
194
+ }))
195
+ ];
196
+ return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("relative space-y-2", className), onPaste, children: [
197
+ /* @__PURE__ */ jsx(Heading, { text: labelsProp?.attachment, className: "mb-0" }),
198
+ chips.length > 0 && // One chip per row, full width: wrapped chips of name-dependent widths made a
199
+ // ragged block in a 430px dialog, and a column keeps every name and remove
200
+ // button in the same place — the next chip's button is right under the last.
201
+ /* @__PURE__ */ jsx("ul", { "aria-label": text.attachmentList, className: "flex flex-col gap-2", children: chips.map((chip, index) => /* @__PURE__ */ jsxs(
202
+ "li",
203
+ {
204
+ className: "flex min-w-0 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-1",
205
+ children: [
206
+ /* @__PURE__ */ jsx(ChipPreview, { file: chip.file, screenshot: chip.screenshot }),
207
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
208
+ /* @__PURE__ */ jsx("div", { className: "truncate text-sm text-[var(--text-secondary)]", children: chip.file.name }),
209
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: fileText.size(chip.file.size) })
210
+ ] }),
211
+ /* @__PURE__ */ jsx(
212
+ "button",
213
+ {
214
+ type: "button",
215
+ "data-attachment-remove": "",
216
+ onClick: () => {
217
+ pendingFocus.current = index;
218
+ chip.remove();
219
+ },
220
+ "aria-label": removeLabel(chip.file.name),
221
+ className: "shrink-0 rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]",
222
+ children: /* @__PURE__ */ jsx(X, { className: "size-4" })
223
+ }
224
+ )
225
+ ]
226
+ },
227
+ fileKey(chip.file)
228
+ )) }),
229
+ (room > 0 || canCapture) && /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
230
+ room > 0 && /* @__PURE__ */ jsxs(
231
+ Button,
232
+ {
233
+ type: "button",
234
+ variant: "secondary",
235
+ "data-attachment-action": "",
236
+ onClick: () => fileInputRef.current?.click(),
237
+ children: [
238
+ /* @__PURE__ */ jsx(Paperclip, { className: "size-4" }),
239
+ " ",
240
+ text.attachmentAdd
241
+ ]
242
+ }
243
+ ),
244
+ canCapture && onCaptureScreenshot && /* @__PURE__ */ jsx(
245
+ CaptureButton,
246
+ {
247
+ capture: onCaptureScreenshot,
248
+ onFile: capture,
249
+ label: text.attachmentCapture,
250
+ "data-attachment-action": ""
251
+ }
252
+ )
253
+ ] }),
254
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--text-muted)]", children: room > 0 ? text.attachmentPaste : limitLabel(max) }),
255
+ /* @__PURE__ */ jsx(
256
+ "input",
257
+ {
258
+ ref: fileInputRef,
259
+ type: "file",
260
+ multiple: room > 1,
261
+ accept: accept.join(","),
262
+ className: "sr-only",
263
+ tabIndex: -1,
264
+ "aria-hidden": true,
265
+ onChange: (e) => {
266
+ add(Array.from(e.target.files ?? []));
267
+ e.target.value = "";
268
+ }
269
+ }
270
+ )
271
+ ] });
272
+ }
273
+ function Heading({ text, className }) {
274
+ if (!text) return null;
275
+ return /* @__PURE__ */ jsx("div", { className: cn("mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]", className), children: text });
276
+ }
277
+ function CaptureButton({
278
+ capture,
279
+ onFile,
280
+ label,
281
+ ...rest
282
+ }) {
283
+ const [capturing, setCapturing] = useState(false);
284
+ return /* @__PURE__ */ jsxs(
285
+ Button,
286
+ {
287
+ type: "button",
288
+ variant: "secondary",
289
+ disabled: capturing,
290
+ ...rest,
291
+ onClick: () => {
292
+ if (capturing) return;
293
+ setCapturing(true);
294
+ void capture().then((file) => {
295
+ if (file) onFile(file);
296
+ }).finally(() => setCapturing(false));
297
+ },
298
+ children: [
299
+ /* @__PURE__ */ jsx(Camera, { className: "size-4" }),
300
+ " ",
301
+ capturing ? "\u2026" : label
302
+ ]
303
+ }
304
+ );
305
+ }
306
+ function ChipPreview({ file, screenshot }) {
307
+ const isImage = file.type.startsWith("image/");
308
+ const [previewFor, setPreviewFor] = useState(null);
309
+ const url = previewFor?.file === file ? previewFor.url : null;
310
+ useEffect(() => {
311
+ if (!isImage) return;
312
+ const next = URL.createObjectURL(file);
313
+ setPreviewFor({ file, url: next });
314
+ return () => URL.revokeObjectURL(next);
315
+ }, [file, isImage]);
316
+ return /* @__PURE__ */ jsxs("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)]", children: [
317
+ isImage && url ? /* @__PURE__ */ jsx("img", { src: url, alt: "", className: "size-full object-cover" }) : /* @__PURE__ */ jsx(FileText, { "aria-hidden": true, className: "size-6" }),
318
+ screenshot && // Marks the slot apart from the photos: it is why the capture button is gone.
319
+ /* @__PURE__ */ jsx("span", { className: "absolute bottom-0.5 end-0.5 rounded-sm bg-[var(--bg-surface)] p-0.5 text-[var(--text-secondary)]", children: /* @__PURE__ */ jsx(Camera, { "aria-hidden": true, className: "size-3" }) })
320
+ ] });
321
+ }
322
+ const fileKeys = /* @__PURE__ */ new WeakMap();
323
+ let nextFileKey = 0;
324
+ function fileKey(file) {
325
+ let key = fileKeys.get(file);
326
+ if (key === void 0) {
327
+ key = nextFileKey++;
328
+ fileKeys.set(file, key);
329
+ }
330
+ return key;
331
+ }
332
+ function rejection(file, accept, maxBytes) {
333
+ if (!accept.includes(file.type)) return "type";
334
+ if (file.size > maxBytes) return "size";
335
+ return null;
336
+ }
337
+ function renamed(file, name) {
338
+ return new File([file], name, { type: file.type });
339
+ }
340
+ function uniqueName(name, taken) {
341
+ if (!taken.has(name)) return name;
342
+ const dot = name.lastIndexOf(".");
343
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
344
+ let n = 2;
345
+ while (taken.has(`${stem}-${n}${ext}`)) n++;
346
+ return `${stem}-${n}${ext}`;
347
+ }
348
+ function usePaste(onImages, documentPaste, pasteFrom) {
349
+ const latest = useRef(onImages);
350
+ useEffect(() => {
351
+ latest.current = onImages;
352
+ });
353
+ useEffect(() => {
354
+ const target = documentPaste ? document : pasteFrom?.current ?? null;
355
+ if (!target) return;
356
+ const onPaste = (event) => {
357
+ const clipboard = event.clipboardData;
358
+ takeImages(clipboard, () => event.preventDefault(), latest.current);
359
+ };
360
+ target.addEventListener("paste", onPaste);
361
+ return () => target.removeEventListener("paste", onPaste);
362
+ }, [documentPaste, pasteFrom]);
363
+ const listensElsewhere = documentPaste || pasteFrom !== void 0;
364
+ return listensElsewhere ? void 0 : (event) => takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
365
+ }
366
+ function takeImages(clipboard, stop, onImages) {
367
+ const images = Array.from(clipboard?.items ?? []).filter((item) => item.kind === "file" && item.type.startsWith("image/")).map((item) => item.getAsFile()).filter((file) => file !== null);
368
+ if (images.length === 0) return;
369
+ stop();
370
+ onImages(images);
371
+ }
156
372
  function pastedName(type) {
157
373
  const subtype = type.split("/")[1] ?? "png";
158
374
  return `pasted.${subtype.split("+")[0]}`;
@@ -160,6 +376,7 @@ function pastedName(type) {
160
376
  export {
161
377
  DEFAULT_ATTACHMENT_ACCEPT,
162
378
  DEFAULT_FEEDBACK_ATTACHMENT_LABELS,
379
+ DEFAULT_MAX_ATTACHMENTS,
163
380
  DEFAULT_MAX_ATTACHMENT_BYTES,
164
381
  FeedbackAttachmentField,
165
382
  pastedName
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/feedback/feedback-attachment.tsx"],"sourcesContent":["import { cn } from \"../lib/cn\";\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\nimport { Camera, FileText, Paperclip, X } from \"lucide-react\";\nimport { Button } from \"../components/ui\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\n\nexport const DEFAULT_ATTACHMENT_ACCEPT = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"];\nexport const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;\n\n/**\n * The field's own strings — the `feedbackAttachment` namespace of `<UiKitProvider\n * labels>`. The keys are {@link FeedbackAttachmentLabels}' own, so the `labels` prop\n * (and a whole `FeedbackDialogLabels` handed down by the dialog) merges over them\n * one to one. The optional heading, `attachment`, stays a prop: a note editor puts\n * the buttons straight under its textarea and wants none.\n *\n * Before 0.7.0 the two optional keys fell back to hard-coded English, so a German app\n * that relied on its provider got \"Capture screenshot\" under a German form.\n */\nexport interface FeedbackAttachmentFieldLabels {\n attachmentAdd: string;\n attachmentCapture: string;\n attachmentPaste: string;\n attachmentRemove: string;\n}\n\nexport const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {\n attachmentAdd: \"Attach image\",\n attachmentCapture: \"Capture screenshot\",\n attachmentPaste: \"…or paste a screenshot from the clipboard.\",\n attachmentRemove: \"Remove attachment\",\n};\n\n/**\n * Picking one picture: the file dialog, a capture of the app view, or a paste.\n *\n * Lifted out of {@link FeedbackDialog} so the *reply* half of the feature can\n * have it too (Steering Design feedback #128). A report is a conversation — it\n * is filed, it is answered, and the reporter sends it back saying that is not\n * what they meant — and the screenshot showing what they mean is taken at\n * whichever of those points they looked. Only the first of them had a way to\n * attach one, so everything after it had to be described in words.\n *\n * **Three ways in, and they are three because no one of them covers the others.**\n * The file dialog needs a file on disk; `onCaptureScreenshot` snapshots the\n * *whole* app view; and the clipboard is where a region snip already is on every\n * platform (Steering Design feedback #39). Somebody who wanted to show one panel\n * had to save a crop and then find it again.\n *\n * `documentPaste` and `pasteFrom` decide where the paste is listened for, and\n * it is a real choice rather than a flag with a default. A modal traps focus,\n * so while it is up every paste in the page is meant for it — including one\n * made with nothing in particular focused, which never reaches a React\n * `onPaste` on a child: that is `documentPaste`. An editor **inline on a\n * page** is not that: the page around it has its own fields, so it listens\n * within its own subtree and a paste elsewhere stays where it was aimed. But\n * \"its own subtree\" has to include the text box the paste is actually made in,\n * and that box is this field's *sibling*, not its child — a paste in it bubbles\n * to their common parent and never through here. `pasteFrom` is that parent\n * (Steering Design feedback #140): the element whose subtree is listened to,\n * handed in by whoever renders both the box and this field side by side.\n */\nexport function FeedbackAttachmentField({\n value,\n onChange,\n labels: labelsProp,\n accept = DEFAULT_ATTACHMENT_ACCEPT,\n maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onError,\n onCaptureScreenshot,\n documentPaste = false,\n pasteFrom,\n className,\n}: {\n value: File | null;\n onChange: (file: File | null) => void;\n /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional\n * since 0.7.0; `attachment` (the heading) is only ever read from here. */\n labels?: Partial<FeedbackAttachmentLabels>;\n accept?: string[];\n maxBytes?: number;\n onError?: (kind: \"type\" | \"size\") => void;\n /** Snapshot the app view behind this and return it as a File. A \"Capture\n * screenshot\" button appears only when it is given. */\n onCaptureScreenshot?: () => Promise<File | null>;\n /** Listen for the paste on `document` rather than on this field's own\n * subtree. For a modal, which owns the whole page while it is up. */\n documentPaste?: boolean;\n /** Listen for the paste within this element's subtree rather than this\n * field's own — for a field standing *beside* the text box a paste is made\n * in, whose common parent is where the event bubbles to. Ignored when\n * `documentPaste` is set. */\n pasteFrom?: RefObject<HTMLElement | null>;\n className?: string;\n}) {\n // The object URL is keyed to the file it was made for, so a stale one (from the\n // previous file, or after `value` is cleared) is never shown — no reset needed.\n const [previewFor, setPreviewFor] = useState<{ file: File; url: string } | null>(null);\n const preview = value && previewFor?.file === value ? previewFor.url : null;\n const [capturing, setCapturing] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.\n const fileText = useKitFileLabels();\n const text = useKitLabels(\"feedbackAttachment\", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);\n\n // Object-URL preview lifecycle (create on change, revoke on cleanup).\n useEffect(() => {\n if (!value) return;\n const url = URL.createObjectURL(value);\n // The URL is an external resource that must be created and revoked in step\n // with the effect (a render-time `createObjectURL` would leak, and StrictMode's\n // remount would revoke a memoised one out from under the <img>).\n // eslint-disable-next-line react-hooks/set-state-in-effect -- publishing an external resource's handle\n setPreviewFor({ file: value, url });\n return () => URL.revokeObjectURL(url);\n }, [value]);\n\n const pick = (file: File | undefined | null) => {\n if (!file) return;\n if (!accept.includes(file.type)) {\n onError?.(\"type\");\n return;\n }\n if (file.size > maxBytes) {\n onError?.(\"size\");\n return;\n }\n onChange(file);\n };\n\n // The paste handler is registered once rather than per render, so it reads\n // `pick` — which closes over props that change identity on every render — out\n // of a ref rather than out of its own dependency list.\n const latest = useRef(pick);\n useEffect(() => {\n latest.current = pick;\n });\n\n const takeImage = (clipboard: DataTransfer | null, stop: () => void) => {\n const items = Array.from(clipboard?.items ?? []);\n const image = items.find((item) => item.kind === \"file\" && item.type.startsWith(\"image/\"));\n const file = image?.getAsFile();\n if (!file) return;\n // Only once there IS an image: a paste of text into a field must stay a\n // paste of text, and a clipboard holding both is a copy whose text half is\n // what the field was focused for.\n stop();\n // Clipboard images arrive named \"image.png\" at best and unnamed at worst,\n // and the name is what the inbox shows beside the thumbnail. A name that\n // says where it came from is more use than the browser's.\n latest.current(new File([file], pastedName(file.type), { type: file.type }));\n };\n\n // The element listened on, where it is not this field's own subtree: the\n // document for a modal, the given parent for an inline editor. Read inside\n // the effect rather than in render, because a ref's `current` is only set\n // once the parent has mounted — which it has by the time effects run.\n useEffect(() => {\n const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);\n if (!target) return;\n const onPaste = (event: Event) => {\n const clipboard = (event as ClipboardEvent).clipboardData;\n takeImage(clipboard, () => event.preventDefault());\n };\n target.addEventListener(\"paste\", onPaste);\n return () => target.removeEventListener(\"paste\", onPaste);\n }, [documentPaste, pasteFrom]);\n const listensElsewhere = documentPaste || pasteFrom !== undefined;\n\n return (\n <div\n // `relative` for the same reason as FileDropzone: the `sr-only` file input below\n // is `position: absolute`, and without a positioned ancestor it is laid out\n // against the initial containing block — extending the DOCUMENT height to its own\n // offset and producing a phantom second scrollbar on any long page.\n className={cn(\"relative\", className)}\n onPaste={\n listensElsewhere\n ? undefined\n : (event) => takeImage(event.clipboardData, () => event.preventDefault())\n }\n >\n {labelsProp?.attachment && (\n <div className=\"mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]\">\n {labelsProp.attachment}\n </div>\n )}\n {value ? (\n <div className=\"flex items-start gap-2\">\n {value.type.startsWith(\"image/\") && preview ? (\n <img\n src={preview}\n alt={value.name}\n className=\"h-20 w-20 rounded border border-[var(--border)] object-cover\"\n />\n ) : (\n // Non-image attachments (PDF, text) can't preview as an <img>, so\n // show a neutral file tile with the name/size beside it instead.\n <div className=\"flex h-20 w-20 items-center justify-center rounded border border-[var(--border)] text-[var(--text-placeholder)]\">\n <FileText className=\"size-8\" />\n </div>\n )}\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm text-[var(--text-secondary)]\">{value.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {fileText.size(value.size)}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => onChange(null)}\n aria-label={text.attachmentRemove}\n className=\"rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </button>\n </div>\n ) : (\n <div className=\"space-y-1.5\">\n <div className=\"flex flex-wrap gap-2\">\n <Button type=\"button\" variant=\"secondary\" onClick={() => fileInputRef.current?.click()}>\n <Paperclip className=\"size-4\" /> {text.attachmentAdd}\n </Button>\n {onCaptureScreenshot && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n disabled={capturing}\n onClick={() => {\n if (capturing) return;\n setCapturing(true);\n void onCaptureScreenshot()\n .then((file) => {\n if (file) pick(file);\n })\n .finally(() => setCapturing(false));\n }}\n >\n <Camera className=\"size-4\" />{\" \"}\n {capturing ? \"…\" : text.attachmentCapture}\n </Button>\n )}\n </div>\n {/* Said out loud, because a gesture with no affordance is a gesture\n nobody finds. */}\n <p className=\"text-xs text-[var(--text-muted)]\">\n {text.attachmentPaste}\n </p>\n </div>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept.join(\",\")}\n className=\"sr-only\"\n onChange={(e) => {\n pick(e.target.files?.[0]);\n e.target.value = \"\";\n }}\n />\n </div>\n );\n}\n\n/** What a pasted image is called once it is an attachment.\n *\n * The extension is read off the mime type rather than assumed to be `.png`:\n * Safari puts TIFF on the clipboard and a file called `pasted.png` that is not\n * a PNG is one the receiving end opens wrong. */\nexport function pastedName(type: string): string {\n const subtype = type.split(\"/\")[1] ?? \"png\";\n // `image/svg+xml` and friends carry a suffix that is not part of the\n // extension, and none of them are in the accepted list anyway.\n return `pasted.${subtype.split(\"+\")[0]}`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/feedback/feedback-attachment.tsx"],"sourcesContent":["import { cn } from \"../lib/cn\";\nimport { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type RefObject } from \"react\";\nimport { Camera, FileText, Paperclip, X } from \"lucide-react\";\nimport { Button } from \"../components/ui\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\n\nexport const DEFAULT_ATTACHMENT_ACCEPT = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"];\nexport const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;\n/** How many files `<FeedbackAttachmentField multiple>` takes when `max` is not given\n * (0.15.5). Five photos of one problem is already a lot to look through; a report\n * that needs more is a conversation, which the thread is for. */\nexport const DEFAULT_MAX_ATTACHMENTS = 5;\n\n/**\n * The field's own strings — the `feedbackAttachment` namespace of `<UiKitProvider\n * labels>`. The keys are {@link FeedbackAttachmentLabels}' own, so the `labels` prop\n * (and a whole `FeedbackDialogLabels` handed down by the dialog) merges over them\n * one to one. The optional heading, `attachment`, stays a prop: a note editor puts\n * the buttons straight under its textarea and wants none.\n *\n * Before 0.7.0 the two optional keys fell back to hard-coded English, so a German app\n * that relied on its provider got \"Capture screenshot\" under a German form.\n *\n * The three keys added in 0.15.5 for `multiple` mode are optional, so a complete\n * `UiKitLabels` typed before it still compiles; they fall back to English.\n */\nexport interface FeedbackAttachmentFieldLabels {\n attachmentAdd: string;\n attachmentCapture: string;\n attachmentPaste: string;\n attachmentRemove: string;\n /** `multiple` mode: the accessible name of the list of chosen files. */\n attachmentList?: string;\n /** `multiple` mode: one chip's remove button — \"Remove photo.jpg\". Named per file,\n * because five buttons all called \"Remove attachment\" say nothing about which. */\n attachmentRemoveFile?: (name: string) => string;\n /** `multiple` mode: the line shown in place of the add buttons once `max` is reached. */\n attachmentLimit?: (max: number) => string;\n}\n\nexport const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {\n attachmentAdd: \"Attach image\",\n attachmentCapture: \"Capture screenshot\",\n attachmentPaste: \"…or paste a screenshot from the clipboard.\",\n attachmentRemove: \"Remove attachment\",\n attachmentList: \"Attachments\",\n attachmentRemoveFile: (name) => `Remove ${name}`,\n attachmentLimit: (max) =>\n `Up to ${max} ${max === 1 ? \"attachment\" : \"attachments\"} — remove one to add another.`,\n};\n\n/** Why a file was turned away. `\"count\"` only ever comes from `multiple` mode: more\n * files arrived at once than `max` left room for, and the surplus was dropped. */\nexport type FeedbackAttachmentError = \"type\" | \"size\" | \"count\";\n\ninterface FeedbackAttachmentFieldBaseProps {\n /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional\n * since 0.7.0; `attachment` (the heading) is only ever read from here. */\n labels?: Partial<FeedbackAttachmentLabels>;\n accept?: string[];\n maxBytes?: number;\n /** Snapshot the app view behind this and return it as a File. A \"Capture\n * screenshot\" button appears only when it is given. */\n onCaptureScreenshot?: () => Promise<File | null>;\n /** Listen for the paste on `document` rather than on this field's own\n * subtree. For a modal, which owns the whole page while it is up. */\n documentPaste?: boolean;\n /** Listen for the paste within this element's subtree rather than this\n * field's own — for a field standing *beside* the text box a paste is made\n * in, whose common parent is where the event bubbles to. Ignored when\n * `documentPaste` is set. */\n pasteFrom?: RefObject<HTMLElement | null>;\n className?: string;\n}\n\n/** One file: choosing a second means removing the first. The field's only mode until 0.15.5. */\nexport interface FeedbackAttachmentFieldSingleProps extends FeedbackAttachmentFieldBaseProps {\n multiple?: false;\n value: File | null;\n onChange: (file: File | null) => void;\n onError?: (kind: \"type\" | \"size\") => void;\n // Multiple-mode props, refused here: without `multiple` they would be silently ignored.\n max?: never;\n screenshot?: never;\n onScreenshotChange?: never;\n}\n\n/**\n * Several files (0.15.5, keksdose dev#578: \"pasted two photos, the second overwrote\n * the first\"). Every way in ADDS — a pick of several files, a paste, a capture — up to\n * `max`, and each file is a removable chip.\n */\nexport interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachmentFieldBaseProps {\n multiple: true;\n value: File[];\n onChange: (files: File[]) => void;\n /** How many files `value` may hold. Default {@link DEFAULT_MAX_ATTACHMENTS} (5). The\n * add buttons and the paste hint are offered only while there is room. */\n max?: number;\n /** Per file: a rejected one is reported and the others are still added. */\n onError?: (kind: FeedbackAttachmentError) => void;\n /**\n * A separate slot for THE screenshot, outside `value` and `max`. Given\n * `onScreenshotChange`, what `onCaptureScreenshot` returns lands here instead of in\n * `value`, shown as the first chip, and the capture button is offered only while the\n * slot is empty — a second snapshot of the same view is not more evidence. This is\n * how `<FeedbackDialog attachments=\"multiple\">` keeps the screenshot apart from the\n * photos; a form built from the parts can do the same.\n */\n screenshot?: File | null;\n onScreenshotChange?: (file: File | null) => void;\n}\n\nexport type FeedbackAttachmentFieldProps =\n | FeedbackAttachmentFieldSingleProps\n | FeedbackAttachmentFieldMultipleProps;\n\n/**\n * Picking one picture: the file dialog, a capture of the app view, or a paste.\n * With `multiple`, picking several (see {@link FeedbackAttachmentFieldMultipleProps}).\n *\n * Lifted out of {@link FeedbackDialog} so the *reply* half of the feature can\n * have it too (Steering Design feedback #128). A report is a conversation — it\n * is filed, it is answered, and the reporter sends it back saying that is not\n * what they meant — and the screenshot showing what they mean is taken at\n * whichever of those points they looked. Only the first of them had a way to\n * attach one, so everything after it had to be described in words.\n *\n * **Three ways in, and they are three because no one of them covers the others.**\n * The file dialog needs a file on disk; `onCaptureScreenshot` snapshots the\n * *whole* app view; and the clipboard is where a region snip already is on every\n * platform (Steering Design feedback #39). Somebody who wanted to show one panel\n * had to save a crop and then find it again.\n *\n * `documentPaste` and `pasteFrom` decide where the paste is listened for, and\n * it is a real choice rather than a flag with a default. A modal traps focus,\n * so while it is up every paste in the page is meant for it — including one\n * made with nothing in particular focused, which never reaches a React\n * `onPaste` on a child: that is `documentPaste`. An editor **inline on a\n * page** is not that: the page around it has its own fields, so it listens\n * within its own subtree and a paste elsewhere stays where it was aimed. But\n * \"its own subtree\" has to include the text box the paste is actually made in,\n * and that box is this field's *sibling*, not its child — a paste in it bubbles\n * to their common parent and never through here. `pasteFrom` is that parent\n * (Steering Design feedback #140): the element whose subtree is listened to,\n * handed in by whoever renders both the box and this field side by side.\n */\nexport function FeedbackAttachmentField(props: FeedbackAttachmentFieldProps) {\n // Two components rather than one with branches: the modes hold different state\n // (one preview vs. one per chip), and a hook order that depends on a prop is a\n // crash the day a caller flips it.\n return props.multiple ? <MultipleField {...props} /> : <SingleField {...props} />;\n}\n\nfunction SingleField({\n value,\n onChange,\n labels: labelsProp,\n accept = DEFAULT_ATTACHMENT_ACCEPT,\n maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onError,\n onCaptureScreenshot,\n documentPaste = false,\n pasteFrom,\n className,\n}: FeedbackAttachmentFieldSingleProps) {\n // The object URL is keyed to the file it was made for, so a stale one (from the\n // previous file, or after `value` is cleared) is never shown — no reset needed.\n const [previewFor, setPreviewFor] = useState<{ file: File; url: string } | null>(null);\n const preview = value && previewFor?.file === value ? previewFor.url : null;\n const fileInputRef = useRef<HTMLInputElement>(null);\n // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.\n const fileText = useKitFileLabels();\n const text = useKitLabels(\"feedbackAttachment\", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);\n\n // Object-URL preview lifecycle (create on change, revoke on cleanup).\n useEffect(() => {\n if (!value) return;\n const url = URL.createObjectURL(value);\n // The URL is an external resource that must be created and revoked in step\n // with the effect (a render-time `createObjectURL` would leak, and StrictMode's\n // remount would revoke a memoised one out from under the <img>).\n // eslint-disable-next-line react-hooks/set-state-in-effect -- publishing an external resource's handle\n setPreviewFor({ file: value, url });\n return () => URL.revokeObjectURL(url);\n }, [value]);\n\n const pick = (file: File | undefined | null) => {\n if (!file) return;\n const problem = rejection(file, accept, maxBytes);\n if (problem) {\n onError?.(problem);\n return;\n }\n onChange(file);\n };\n\n const onPaste = usePaste(\n (images) => {\n // Clipboard images arrive named \"image.png\" at best and unnamed at worst,\n // and the name is what the inbox shows beside the thumbnail. A name that\n // says where it came from is more use than the browser's.\n const file = images[0];\n pick(new File([file], pastedName(file.type), { type: file.type }));\n },\n documentPaste,\n pasteFrom,\n );\n\n return (\n <div\n // `relative` for the same reason as FileDropzone: the `sr-only` file input below\n // is `position: absolute`, and without a positioned ancestor it is laid out\n // against the initial containing block — extending the DOCUMENT height to its own\n // offset and producing a phantom second scrollbar on any long page.\n className={cn(\"relative\", className)}\n onPaste={onPaste}\n >\n <Heading text={labelsProp?.attachment} />\n {value ? (\n <div className=\"flex items-start gap-2\">\n {value.type.startsWith(\"image/\") && preview ? (\n <img\n src={preview}\n alt={value.name}\n className=\"h-20 w-20 rounded border border-[var(--border)] object-cover\"\n />\n ) : (\n // Non-image attachments (PDF, text) can't preview as an <img>, so\n // show a neutral file tile with the name/size beside it instead.\n <div className=\"flex h-20 w-20 items-center justify-center rounded border border-[var(--border)] text-[var(--text-placeholder)]\">\n <FileText className=\"size-8\" />\n </div>\n )}\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm text-[var(--text-secondary)]\">{value.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {fileText.size(value.size)}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => onChange(null)}\n aria-label={text.attachmentRemove}\n className=\"rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </button>\n </div>\n ) : (\n <div className=\"space-y-1.5\">\n <div className=\"flex flex-wrap gap-2\">\n <Button type=\"button\" variant=\"secondary\" onClick={() => fileInputRef.current?.click()}>\n <Paperclip className=\"size-4\" /> {text.attachmentAdd}\n </Button>\n {onCaptureScreenshot && (\n <CaptureButton capture={onCaptureScreenshot} onFile={pick} label={text.attachmentCapture} />\n )}\n </div>\n {/* Said out loud, because a gesture with no affordance is a gesture\n nobody finds. */}\n <p className=\"text-xs text-[var(--text-muted)]\">\n {text.attachmentPaste}\n </p>\n </div>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept.join(\",\")}\n className=\"sr-only\"\n onChange={(e) => {\n pick(e.target.files?.[0]);\n e.target.value = \"\";\n }}\n />\n </div>\n );\n}\n\nfunction MultipleField({\n value,\n onChange,\n max = DEFAULT_MAX_ATTACHMENTS,\n screenshot = null,\n onScreenshotChange,\n labels: labelsProp,\n accept = DEFAULT_ATTACHMENT_ACCEPT,\n maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onError,\n onCaptureScreenshot,\n documentPaste = false,\n pasteFrom,\n className,\n}: FeedbackAttachmentFieldMultipleProps) {\n const rootRef = useRef<HTMLDivElement>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n const fileText = useKitFileLabels();\n const text = useKitLabels(\"feedbackAttachment\", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);\n const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;\n const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;\n\n const hasSlot = onScreenshotChange !== undefined;\n const room = Math.max(0, max - value.length);\n // The screenshot slot is its own thing: it neither counts against `max` nor is\n // closed by it, so a full set of photos still leaves the capture on offer.\n const canCapture = !!onCaptureScreenshot && (hasSlot ? !screenshot : room > 0);\n\n const add = (incoming: File[], pasted = false) => {\n const accepted: File[] = [];\n const taken = new Set([...value, ...(screenshot ? [screenshot] : [])].map((f) => f.name));\n for (const raw of incoming) {\n const problem = rejection(raw, accept, maxBytes);\n if (problem) {\n onError?.(problem);\n continue;\n }\n // Two pastes are two files called \"pasted.png\", and a backend that stores by\n // name keeps one of them — the very loss this mode exists to end. A picked\n // file keeps its own name: it is the user's, and they may look for it.\n const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;\n taken.add(file.name);\n // The same File object twice is one file chosen twice, not two.\n if (!value.includes(file)) accepted.push(file);\n }\n if (accepted.length === 0) return;\n if (accepted.length > room) onError?.(\"count\");\n const kept = accepted.slice(0, room);\n if (kept.length > 0) onChange([...value, ...kept]);\n };\n\n const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);\n\n const capture = (file: File) => {\n if (!hasSlot) {\n add([file]);\n return;\n }\n const problem = rejection(file, accept, maxBytes);\n if (problem) onError?.(problem);\n else onScreenshotChange(file);\n };\n\n // Removing a chip removes the button that had focus. Focus goes to the chip that\n // took its place — the next one — and, when it was the last, to the first action\n // button (there is room again, so \"Attach\" is back), else to the chip before it.\n // Applied once the parent has re-rendered with the shorter list: the field is\n // controlled, and focusing before that would land on the chip being removed.\n const pendingFocus = useRef<number | null>(null);\n useEffect(() => {\n const index = pendingFocus.current;\n if (index === null) return;\n pendingFocus.current = null;\n const root = rootRef.current;\n if (!root) return;\n const removes = root.querySelectorAll<HTMLElement>(\"[data-attachment-remove]\");\n const target =\n removes[index] ?? root.querySelector<HTMLElement>(\"[data-attachment-action]\") ?? removes[index - 1];\n target?.focus();\n }, [value, screenshot]);\n\n const chips: Array<{ file: File; screenshot: boolean; remove: () => void }> = [\n ...(hasSlot && screenshot\n ? [{ file: screenshot, screenshot: true, remove: () => onScreenshotChange(null) }]\n : []),\n ...value.map((file) => ({\n file,\n screenshot: false,\n remove: () => onChange(value.filter((f) => f !== file)),\n })),\n ];\n\n return (\n <div ref={rootRef} className={cn(\"relative space-y-2\", className)} onPaste={onPaste}>\n <Heading text={labelsProp?.attachment} className=\"mb-0\" />\n {chips.length > 0 && (\n // One chip per row, full width: wrapped chips of name-dependent widths made a\n // ragged block in a 430px dialog, and a column keeps every name and remove\n // button in the same place — the next chip's button is right under the last.\n <ul aria-label={text.attachmentList} className=\"flex flex-col gap-2\">\n {chips.map((chip, index) => (\n <li\n key={fileKey(chip.file)}\n className=\"flex min-w-0 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-1\"\n >\n <ChipPreview file={chip.file} screenshot={chip.screenshot} />\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm text-[var(--text-secondary)]\">{chip.file.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">{fileText.size(chip.file.size)}</div>\n </div>\n <button\n type=\"button\"\n data-attachment-remove=\"\"\n onClick={() => {\n pendingFocus.current = index;\n chip.remove();\n }}\n aria-label={removeLabel(chip.file.name)}\n className=\"shrink-0 rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </button>\n </li>\n ))}\n </ul>\n )}\n {(room > 0 || canCapture) && (\n <div className=\"flex flex-wrap gap-2\">\n {room > 0 && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n data-attachment-action=\"\"\n onClick={() => fileInputRef.current?.click()}\n >\n <Paperclip className=\"size-4\" /> {text.attachmentAdd}\n </Button>\n )}\n {canCapture && onCaptureScreenshot && (\n <CaptureButton\n capture={onCaptureScreenshot}\n onFile={capture}\n label={text.attachmentCapture}\n data-attachment-action=\"\"\n />\n )}\n </div>\n )}\n <p className=\"text-xs text-[var(--text-muted)]\">\n {room > 0 ? text.attachmentPaste : limitLabel(max)}\n </p>\n <input\n ref={fileInputRef}\n type=\"file\"\n multiple={room > 1}\n accept={accept.join(\",\")}\n className=\"sr-only\"\n // Out of the tab order and the accessibility tree: the button above is\n // the way in, and a second, unlabelled \"Choose files\" stop is noise.\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n add(Array.from(e.target.files ?? []));\n e.target.value = \"\";\n }}\n />\n </div>\n );\n}\n\nfunction Heading({ text, className }: { text?: string; className?: string }) {\n if (!text) return null;\n return (\n <div className={cn(\"mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]\", className)}>\n {text}\n </div>\n );\n}\n\nfunction CaptureButton({\n capture,\n onFile,\n label,\n ...rest\n}: {\n capture: () => Promise<File | null>;\n onFile: (file: File) => void;\n label: string;\n \"data-attachment-action\"?: string;\n}) {\n const [capturing, setCapturing] = useState(false);\n return (\n <Button\n type=\"button\"\n variant=\"secondary\"\n disabled={capturing}\n {...rest}\n onClick={() => {\n if (capturing) return;\n setCapturing(true);\n void capture()\n .then((file) => {\n if (file) onFile(file);\n })\n .finally(() => setCapturing(false));\n }}\n >\n <Camera className=\"size-4\" /> {capturing ? \"…\" : label}\n </Button>\n );\n}\n\n/** A chip's picture: the image itself, or a file glyph for anything that is not one.\n * Decorative (`alt=\"\"`) — the name stands beside it. Its own component so the object\n * URL lives exactly as long as the chip: removing the chip unmounts it and revokes. */\nfunction ChipPreview({ file, screenshot }: { file: File; screenshot: boolean }) {\n const isImage = file.type.startsWith(\"image/\");\n const [previewFor, setPreviewFor] = useState<{ file: File; url: string } | null>(null);\n const url = previewFor?.file === file ? previewFor.url : null;\n useEffect(() => {\n if (!isImage) return;\n const next = URL.createObjectURL(file);\n // As in the single field: an external resource created and revoked with the effect.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- publishing an external resource's handle\n setPreviewFor({ file, url: next });\n return () => URL.revokeObjectURL(next);\n }, [file, isImage]);\n\n return (\n <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)]\">\n {isImage && url ? (\n <img src={url} alt=\"\" className=\"size-full object-cover\" />\n ) : (\n <FileText aria-hidden className=\"size-6\" />\n )}\n {screenshot && (\n // Marks the slot apart from the photos: it is why the capture button is gone.\n <span className=\"absolute bottom-0.5 end-0.5 rounded-sm bg-[var(--bg-surface)] p-0.5 text-[var(--text-secondary)]\">\n <Camera aria-hidden className=\"size-3\" />\n </span>\n )}\n </span>\n );\n}\n\n/** A stable React key per File object — the name is not one (two pastes, two\n * \"pasted.png\" before renaming; the same photo picked from two folders). */\nconst fileKeys = new WeakMap<File, number>();\nlet nextFileKey = 0;\nfunction fileKey(file: File): number {\n let key = fileKeys.get(file);\n if (key === undefined) {\n key = nextFileKey++;\n fileKeys.set(file, key);\n }\n return key;\n}\n\nfunction rejection(file: File, accept: string[], maxBytes: number): \"type\" | \"size\" | null {\n if (!accept.includes(file.type)) return \"type\";\n if (file.size > maxBytes) return \"size\";\n return null;\n}\n\nfunction renamed(file: File, name: string): File {\n return new File([file], name, { type: file.type });\n}\n\n/** `pasted.png`, then `pasted-2.png`, `pasted-3.png`… — the first name not in `taken`. */\nfunction uniqueName(name: string, taken: Set<string>): string {\n if (!taken.has(name)) return name;\n const dot = name.lastIndexOf(\".\");\n const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, \"\"];\n let n = 2;\n while (taken.has(`${stem}-${n}${ext}`)) n++;\n return `${stem}-${n}${ext}`;\n}\n\n/**\n * The paste listener both modes share. `onImages` gets every image file on the\n * clipboard (the single field takes the first). It is registered once rather than per\n * render, so it reads the handler — which closes over props that change identity on\n * every render — out of a ref rather than out of its own dependency list. Returns the\n * `onPaste` for the field's root, or `undefined` when listening elsewhere.\n */\nfunction usePaste(\n onImages: (images: File[]) => void,\n documentPaste: boolean,\n pasteFrom: RefObject<HTMLElement | null> | undefined,\n) {\n const latest = useRef(onImages);\n useEffect(() => {\n latest.current = onImages;\n });\n\n // The element listened on, where it is not this field's own subtree: the\n // document for a modal, the given parent for an inline editor. Read inside\n // the effect rather than in render, because a ref's `current` is only set\n // once the parent has mounted — which it has by the time effects run.\n useEffect(() => {\n const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);\n if (!target) return;\n const onPaste = (event: Event) => {\n const clipboard = (event as ClipboardEvent).clipboardData;\n takeImages(clipboard, () => event.preventDefault(), latest.current);\n };\n target.addEventListener(\"paste\", onPaste);\n return () => target.removeEventListener(\"paste\", onPaste);\n }, [documentPaste, pasteFrom]);\n\n const listensElsewhere = documentPaste || pasteFrom !== undefined;\n return listensElsewhere\n ? undefined\n : (event: ReactClipboardEvent) => takeImages(event.clipboardData, () => event.preventDefault(), latest.current);\n}\n\nfunction takeImages(clipboard: DataTransfer | null, stop: () => void, onImages: (images: File[]) => void) {\n const images = Array.from(clipboard?.items ?? [])\n .filter((item) => item.kind === \"file\" && item.type.startsWith(\"image/\"))\n .map((item) => item.getAsFile())\n .filter((file): file is File => file !== null);\n if (images.length === 0) return;\n // Only once there IS an image: a paste of text into a field must stay a\n // paste of text, and a clipboard holding both is a copy whose text half is\n // what the field was focused for.\n stop();\n onImages(images);\n}\n\n/** What a pasted image is called once it is an attachment.\n *\n * The extension is read off the mime type rather than assumed to be `.png`:\n * Safari puts TIFF on the clipboard and a file called `pasted.png` that is not\n * a PNG is one the receiving end opens wrong. */\nexport function pastedName(type: string): string {\n const subtype = type.split(\"/\")[1] ?? \"png\";\n // `image/svg+xml` and friends carry a suffix that is not part of the\n // extension, and none of them are in the accepted list anyway.\n return `pasted.${subtype.split(\"+\")[0]}`;\n}\n"],"mappings":"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","names":[]}