@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.
- package/README.md +20 -15
- package/dist/chart.d.ts +3 -4
- package/dist/components/alert-banner.js +1 -1
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -4
- package/dist/components/amount-input.js +166 -157
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +14 -3
- package/dist/components/authed-image.js +32 -23
- package/dist/components/authed-image.js.map +1 -1
- package/dist/components/button-group.d.ts +3 -4
- package/dist/components/calculator.d.ts +3 -4
- package/dist/components/checkbox.js +1 -1
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/choice-card.js +1 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +3 -4
- package/dist/components/error-boundary.js +1 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/facing-pair.d.ts +3 -4
- package/dist/components/file-button.d.ts +3 -4
- package/dist/components/file-button.js +64 -31
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +1 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +4 -5
- package/dist/components/form-actions.js +18 -6
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +53 -2
- package/dist/components/line-items.js +73 -7
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +99 -5
- package/dist/components/list.js +103 -38
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +19 -3
- package/dist/components/loading-state.js +14 -3
- package/dist/components/loading-state.js.map +1 -1
- package/dist/components/money-field.d.ts +3 -4
- package/dist/components/number-field.d.ts +3 -4
- package/dist/components/number-input.d.ts +3 -4
- package/dist/components/number-input.js +4 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +3 -4
- package/dist/components/passkeys-setting.d.ts +30 -10
- package/dist/components/passkeys-setting.js +19 -11
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +12 -3
- package/dist/components/progress-bar.js +21 -11
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +3 -4
- package/dist/components/settings-fields.d.ts +3 -4
- package/dist/components/signed-amount.d.ts +28 -4
- package/dist/components/signed-amount.js +17 -8
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/stat-tile.d.ts +31 -2
- package/dist/components/stat-tile.js +37 -6
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +4 -1
- package/dist/components/status-dot.js +20 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +3 -1
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/table.d.ts +18 -13
- package/dist/components/table.js +20 -5
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +3 -4
- package/dist/components/time-input.d.ts +3 -4
- package/dist/components/toggle-legend.d.ts +12 -1
- package/dist/components/toggle-legend.js +6 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/ui.d.ts +3 -4
- package/dist/components/ui.js +156 -52
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +2 -82
- package/dist/feedback/feedback-attachment.js +285 -55
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +2 -105
- package/dist/feedback/feedback-dialog.js +57 -25
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +1 -1
- package/dist/feedback/feedback-thread.d.ts +2 -2
- package/dist/feedback-attachment-WFiXJ8We.d.ts +302 -0
- package/dist/feedback.d.ts +1 -2
- package/dist/hooks/use-file-drop.d.ts +3 -4
- package/dist/i18n/defaults.d.ts +3 -4
- package/dist/i18n/kit-labels.d.ts +2 -3
- package/dist/i18n/locales/de-CH-informal.d.ts +3 -4
- package/dist/i18n/locales/de-CH.d.ts +3 -4
- package/dist/i18n/locales/de-informal.d.ts +3 -4
- package/dist/i18n/locales/de.d.ts +3 -4
- package/dist/i18n/locales/de.js +5 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -4
- package/dist/i18n/locales/es.js +5 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -4
- package/dist/i18n/locales/fr.js +5 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -4
- package/dist/i18n/locales/hu.js +5 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -4
- package/dist/i18n/locales/it.js +5 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -4
- package/dist/i18n/locales/zh.js +5 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +10 -11
- package/dist/{kit-labels-CnnBd-2N.d.ts → kit-labels-DE--LmCk.d.ts} +154 -14
- package/dist/lib/format.d.ts +18 -3
- package/dist/lib/format.js +2 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +3 -4
- package/dist/rhf/form.d.ts +3 -4
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +3 -4
- package/dist/shell/app-shell.d.ts +3 -4
- package/dist/shell/top-bar-brand.d.ts +3 -4
- package/dist/shell.d.ts +3 -4
- package/dist/theme/palette-derive.d.ts +3 -0
- package/dist/theme/palette-derive.js +7 -0
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +3 -4
- package/dist/wizard.d.ts +3 -4
- package/package.json +4 -2
- package/src/components/alert-banner.tsx +1 -1
- package/src/components/amount-input.tsx +47 -16
- package/src/components/authed-image.tsx +20 -2
- package/src/components/checkbox.tsx +1 -1
- package/src/components/choice-card.tsx +1 -1
- package/src/components/error-boundary.tsx +1 -1
- package/src/components/file-button.tsx +77 -27
- package/src/components/file-dropzone.tsx +1 -1
- package/src/components/form-actions.tsx +86 -8
- package/src/components/line-items.tsx +131 -14
- package/src/components/list.tsx +240 -50
- package/src/components/loading-state.tsx +35 -4
- package/src/components/number-input.tsx +18 -2
- package/src/components/passkeys-setting.tsx +45 -18
- package/src/components/progress-bar.tsx +46 -21
- package/src/components/signed-amount.tsx +47 -8
- package/src/components/stat-tile.tsx +76 -6
- package/src/components/status-dot.tsx +31 -0
- package/src/components/swatch-picker.tsx +3 -1
- package/src/components/table.tsx +42 -15
- package/src/components/toggle-legend.tsx +21 -4
- package/src/components/ui.tsx +280 -70
- package/src/feedback/feedback-attachment.tsx +472 -96
- package/src/feedback/feedback-dialog.tsx +162 -50
- package/src/i18n/locales/de.ts +4 -0
- package/src/i18n/locales/es.ts +4 -0
- package/src/i18n/locales/fr.ts +4 -0
- package/src/i18n/locales/hu.ts +4 -0
- package/src/i18n/locales/it.ts +4 -0
- package/src/i18n/locales/zh.ts +4 -0
- package/src/lib/format.ts +24 -3
- package/src/rhf/line-items.tsx +6 -0
- package/src/theme/palette-derive.ts +10 -0
- 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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
<
|
|
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`:
|