@eifi1/ui-kit 0.22.0 → 0.24.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 +42 -25
- package/dist/chart.d.ts +8 -7
- package/dist/components/amount-input.d.ts +34 -7
- package/dist/components/amount-input.js +14 -10
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +26 -0
- package/dist/components/autocomplete.js +164 -137
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +8 -7
- package/dist/components/calculator.d.ts +8 -7
- package/dist/components/column-mapper.d.ts +235 -0
- package/dist/components/column-mapper.js +411 -0
- package/dist/components/column-mapper.js.map +1 -0
- package/dist/components/combobox-core.d.ts +39 -2
- package/dist/components/combobox-core.js +29 -6
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +113 -3
- package/dist/components/combobox.js +456 -313
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +8 -7
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +5 -4
- package/dist/components/country-select.d.ts +50 -7
- package/dist/components/country-select.js +65 -9
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +8 -7
- package/dist/components/danger-confirm.js +64 -7
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/date-picker.d.ts +41 -0
- package/dist/components/date-picker.js +269 -8
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +48 -2
- package/dist/components/entity-combobox.js +159 -114
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +8 -7
- package/dist/components/field-parts.d.ts +116 -6
- package/dist/components/field-parts.js +84 -0
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +105 -0
- package/dist/components/field-strip.js +46 -0
- package/dist/components/field-strip.js.map +1 -0
- package/dist/components/file-button.d.ts +8 -7
- package/dist/components/file-dropzone.d.ts +8 -7
- package/dist/components/form-actions.d.ts +8 -7
- package/dist/components/form-actions.js +23 -3
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +8 -7
- package/dist/components/icon-picker.d.ts +10 -1
- package/dist/components/icon-picker.js +12 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/language-select.d.ts +8 -7
- package/dist/components/money-field.d.ts +11 -7
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.js +2 -1
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +31 -2
- package/dist/components/multi-entity-combobox.js +152 -103
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +8 -7
- package/dist/components/number-input.d.ts +8 -7
- package/dist/components/numpad-sheet.d.ts +8 -7
- package/dist/components/phone-input.d.ts +8 -7
- package/dist/components/reauth-dialog.d.ts +2 -2
- package/dist/components/reauth-dialog.js +6 -8
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart.d.ts +8 -7
- package/dist/components/settings-fields.d.ts +8 -7
- package/dist/components/share-card.d.ts +8 -7
- package/dist/components/swatch-picker.d.ts +17 -1
- package/dist/components/swatch-picker.js +10 -4
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/text-link.d.ts +8 -7
- package/dist/components/tile-radio.d.ts +20 -0
- package/dist/components/tile-radio.js +25 -8
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +8 -7
- package/dist/components/toggle-group.js +2 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +3 -0
- package/dist/components/trigger-aria.js +3 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/ui.d.ts +5 -4
- package/dist/components/ui.js +60 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +66 -1
- package/dist/feedback/feedback-attachment.js +185 -65
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +66 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +67 -252
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +66 -287
- package/dist/feedback/feedback-thread.js +16 -3
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
- package/dist/feedback.d.ts +65 -3
- package/dist/hooks/use-file-drop.d.ts +8 -7
- package/dist/i18n/defaults.d.ts +8 -7
- package/dist/i18n/defaults.js +3 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +8 -7
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +5 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +8 -7
- package/dist/i18n/locales/de-CH.d.ts +8 -7
- package/dist/i18n/locales/en.d.ts +8 -7
- package/dist/i18n/locales/en.js +7 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +8 -7
- package/dist/i18n/locales/es.js +37 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +8 -7
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +8 -7
- package/dist/i18n/locales/hu.js +38 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +8 -7
- package/dist/i18n/locales/it.js +37 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +8 -7
- package/dist/i18n/locales/zh.js +34 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +8 -7
- package/dist/i18n/review.js +20 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/column-mapping.d.ts +81 -0
- package/dist/lib/column-mapping.js +108 -0
- package/dist/lib/column-mapping.js.map +1 -0
- package/dist/lib/strip-fade.d.ts +2 -2
- package/dist/lib/strip-fade.js.map +1 -1
- package/dist/lib/table-text.d.ts +109 -1
- package/dist/lib/table-text.js +122 -1
- package/dist/lib/table-text.js.map +1 -1
- package/dist/rhf/fields.d.ts +142 -13
- package/dist/rhf/fields.js +185 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +8 -7
- package/dist/rhf.d.ts +9 -8
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +8 -7
- package/dist/shell/top-bar-brand.d.ts +8 -7
- package/dist/shell/topbar-action-menu.d.ts +36 -3
- package/dist/shell/topbar-action-menu.js +74 -33
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +8 -7
- package/dist/table-text.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +38 -9
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +8 -7
- package/package.json +1 -1
- package/src/components/amount-input.tsx +50 -14
- package/src/components/autocomplete.tsx +188 -116
- package/src/components/column-mapper.tsx +768 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +655 -335
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +135 -22
- package/src/components/danger-confirm.tsx +274 -24
- package/src/components/date-picker.tsx +423 -10
- package/src/components/entity-combobox.tsx +243 -129
- package/src/components/field-parts.tsx +224 -5
- package/src/components/field-strip.tsx +149 -0
- package/src/components/form-actions.tsx +52 -5
- package/src/components/icon-picker.tsx +23 -4
- package/src/components/money-field.tsx +3 -0
- package/src/components/month-picker.tsx +2 -1
- package/src/components/multi-entity-combobox.tsx +221 -121
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/swatch-picker.tsx +29 -5
- package/src/components/tile-radio.tsx +74 -13
- package/src/components/toggle-group.tsx +2 -2
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +114 -33
- package/src/feedback/feedback-attachment.tsx +372 -72
- package/src/feedback/feedback-dialog.tsx +7 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +112 -9
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +42 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/en.ts +27 -5
- package/src/i18n/locales/es.ts +41 -0
- package/src/i18n/locales/fr.ts +42 -0
- package/src/i18n/locales/hu.ts +37 -0
- package/src/i18n/locales/it.ts +41 -0
- package/src/i18n/locales/zh.ts +32 -0
- package/src/i18n/review.ts +19 -0
- package/src/index.ts +18 -0
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/strip-fade.ts +2 -2
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +421 -6
- package/src/rhf.ts +2 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/wizard/stepper-nav.tsx +35 -1
- package/dist/components/field-anatomy.d.ts +0 -95
- package/dist/components/field-anatomy.js +0 -84
- package/dist/components/field-anatomy.js.map +0 -1
- package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
- package/src/components/field-anatomy.tsx +0 -190
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { cn } from "../lib/cn";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ClipboardEvent as ReactClipboardEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type RefObject,
|
|
10
|
+
} from "react";
|
|
3
11
|
import { Camera, FileText, Image as ImageIcon, Paperclip, X } from "lucide-react";
|
|
4
|
-
import { Button, Spinner } from "../components/ui";
|
|
12
|
+
import { Button, IconButton, Spinner } from "../components/ui";
|
|
13
|
+
import type { ButtonSize, ButtonVariant } from "../components/ui";
|
|
14
|
+
import { DEFAULT_FILE_PICKER_LABELS, formatAccept } from "../components/file-button";
|
|
15
|
+
import { useCommitReason } from "../components/write-lock";
|
|
5
16
|
import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
|
|
6
17
|
import type { FeedbackAttachmentLabels } from "./feedback-dialog";
|
|
7
18
|
|
|
@@ -47,6 +58,11 @@ export interface FeedbackAttachmentFieldLabels {
|
|
|
47
58
|
* running, where an uploaded one shows its size — "Uploading…". Optional, like the
|
|
48
59
|
* keys above, so a `UiKitLabels` typed before it still compiles. */
|
|
49
60
|
attachmentUploading?: string;
|
|
61
|
+
/** `refs` mode (0.23.0, keksdose G5a): {@link FeedbackAttachmentErrorInfo.message} for
|
|
62
|
+
* an `"upload"` refusal — "“huge.png” could not be uploaded". The other refusals'
|
|
63
|
+
* sentences are the `filePicker` namespace's, shared with FileButton. Optional, like
|
|
64
|
+
* the keys above. */
|
|
65
|
+
attachmentUploadFailed?: (name: string) => string;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {
|
|
@@ -60,6 +76,7 @@ export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels =
|
|
|
60
76
|
attachmentLimit: (max) =>
|
|
61
77
|
`Up to ${max} ${max === 1 ? "attachment" : "attachments"} — remove one to add another.`,
|
|
62
78
|
attachmentUploading: "Uploading…",
|
|
79
|
+
attachmentUploadFailed: (name) => `“${name}” could not be uploaded`,
|
|
63
80
|
};
|
|
64
81
|
|
|
65
82
|
/** Why a file was turned away. `"count"` only ever comes from `multiple` (and `refs`)
|
|
@@ -72,6 +89,58 @@ export type FeedbackAttachmentError = "type" | "size" | "count";
|
|
|
72
89
|
* own so a host's exhaustive switch over the File modes' reasons stays exhaustive. */
|
|
73
90
|
export type FeedbackAttachmentRefsError = FeedbackAttachmentError | "upload";
|
|
74
91
|
|
|
92
|
+
/**
|
|
93
|
+
* What `onError` knows about a refusal beyond its kind (0.23.0, keksdose G5a): the file
|
|
94
|
+
* it was about, the limit that refused it, and a sentence that says both.
|
|
95
|
+
*
|
|
96
|
+
* keksdose's support chat toasted "File too large" with no name — the field's `onError`
|
|
97
|
+
* carried a kind and nothing else, so a refusal could not say WHICH of three pasted
|
|
98
|
+
* screenshots went, nor what the ceiling was, and its own `AttachmentPicker` (a
|
|
99
|
+
* FileButton, whose `onReject` hands over `{ file, message }`) said more than the kit's
|
|
100
|
+
* field would have. This is that, for every mode.
|
|
101
|
+
*
|
|
102
|
+
* It arrives as an EXTRA argument, after the ones `onError` already had, so a handler
|
|
103
|
+
* written for 0.22 — `(kind) => …`, or `refs` mode's `(kind, error) => …` — compiles
|
|
104
|
+
* and behaves as before:
|
|
105
|
+
*
|
|
106
|
+
* - single and `multiple`: `onError(kind, info)`;
|
|
107
|
+
* - `refs`: `onError(kind, error, info)` — the second place is `onUpload`'s rejection
|
|
108
|
+
* since 0.22 (`undefined` for the other kinds, as it always was), so the info comes
|
|
109
|
+
* third rather than changing what a 0.22 handler reads there. It also carries that
|
|
110
|
+
* `error`, so one helper `(kind, info) => …` serves all three modes.
|
|
111
|
+
*
|
|
112
|
+
* An object rather than more positional arguments because what a host reaches for
|
|
113
|
+
* differs by kind — the bytes for `"size"`, `max` for `"count"`, the server's answer for
|
|
114
|
+
* `"upload"` — and a field added later must not shift anyone's arguments.
|
|
115
|
+
*/
|
|
116
|
+
export interface FeedbackAttachmentErrorInfo {
|
|
117
|
+
/** The file refused — under the name the field would have given it (a pasted image
|
|
118
|
+
* is already "pasted.png"). For `"count"`, the first of the surplus; for
|
|
119
|
+
* `"upload"`, the file whose upload failed. */
|
|
120
|
+
file: File;
|
|
121
|
+
/** `"count"`: every file of the surplus (several can arrive in one pick). Otherwise
|
|
122
|
+
* `[file]`. */
|
|
123
|
+
files: File[];
|
|
124
|
+
/**
|
|
125
|
+
* One translated sentence naming the file and the limit — the ones FileButton's
|
|
126
|
+
* `onReject` hands over, from the same `filePicker` namespace: "“huge.png” is larger
|
|
127
|
+
* than 10 MB", "Only image/png, image/jpeg files", "“c.png” was not added: at most 5
|
|
128
|
+
* files" (several at once: "3 files were not added"). `"upload"`: the field's own
|
|
129
|
+
* `attachmentUploadFailed`, "“huge.png” could not be uploaded" — a host with a better
|
|
130
|
+
* sentence from the server's answer uses `error`. Ready for a toast as it is.
|
|
131
|
+
*/
|
|
132
|
+
message: string;
|
|
133
|
+
/** The field's `maxBytes` — the ceiling a `"size"` refusal went over, in bytes. */
|
|
134
|
+
maxBytes: number;
|
|
135
|
+
/** The field's `accept` — the types a `"type"` refusal was not among. */
|
|
136
|
+
accept: string[];
|
|
137
|
+
/** The field's `max` — the ceiling a `"count"` refusal hit. Absent in single mode,
|
|
138
|
+
* which holds one file by definition. */
|
|
139
|
+
max?: number;
|
|
140
|
+
/** `"upload"` only: what `onUpload` rejected with — `onError`'s second argument. */
|
|
141
|
+
error?: unknown;
|
|
142
|
+
}
|
|
143
|
+
|
|
75
144
|
/**
|
|
76
145
|
* One uploaded attachment in `refs` mode: what the host's upload answered with. `key`
|
|
77
146
|
* identifies it (a storage key, an upload id) and is what a remove goes by; `name` is
|
|
@@ -88,9 +157,25 @@ export interface FeedbackAttachmentRef {
|
|
|
88
157
|
interface FeedbackAttachmentFieldBaseProps {
|
|
89
158
|
/** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
|
|
90
159
|
* since 0.7.0; `attachment` (the heading) is only ever read from here. */
|
|
91
|
-
labels?: Partial<FeedbackAttachmentLabels> &
|
|
160
|
+
labels?: Partial<FeedbackAttachmentLabels> &
|
|
161
|
+
Pick<Partial<FeedbackAttachmentFieldLabels>, "attachmentUploading" | "attachmentUploadFailed">;
|
|
92
162
|
accept?: string[];
|
|
93
163
|
maxBytes?: number;
|
|
164
|
+
/**
|
|
165
|
+
* Take no files for now (0.23.0, keksdose G5b): the add and capture buttons, the
|
|
166
|
+
* hidden file input and every chip's remove button are natively disabled, and a paste
|
|
167
|
+
* is let through untouched. The chips stay as they are — what is about to be sent
|
|
168
|
+
* stays readable.
|
|
169
|
+
*
|
|
170
|
+
* For the moment a send is in flight. keksdose's support picker is `disabled` while
|
|
171
|
+
* the message goes out, because the message carries the attachments AS THEY WERE at
|
|
172
|
+
* the press: a chip removed mid-send has gone with it all the same, and one added
|
|
173
|
+
* mid-send looks as if it had. The field has no drop target; the paste is its drop.
|
|
174
|
+
*
|
|
175
|
+
* This is a pause, not a lock that explains itself — for that, `refs` mode (the mode
|
|
176
|
+
* that commits) takes `commit` and `disabledReason`.
|
|
177
|
+
*/
|
|
178
|
+
disabled?: boolean;
|
|
94
179
|
/** Snapshot the app view behind this and return it as a File. A "Capture
|
|
95
180
|
* screenshot" button appears only when it is given. */
|
|
96
181
|
onCaptureScreenshot?: () => Promise<File | null>;
|
|
@@ -102,20 +187,56 @@ interface FeedbackAttachmentFieldBaseProps {
|
|
|
102
187
|
* in, whose common parent is where the event bubbles to. Ignored when
|
|
103
188
|
* `documentPaste` is set. */
|
|
104
189
|
pasteFrom?: RefObject<HTMLElement | null>;
|
|
190
|
+
/**
|
|
191
|
+
* The add and capture buttons' look (0.24.0, keksdose) — Button's own `variant`.
|
|
192
|
+
* Default `"secondary"`, the look they always had.
|
|
193
|
+
*
|
|
194
|
+
* keksdose's support chat puts this field in ChatComposer's `attachmentSlot`, in the
|
|
195
|
+
* row with Send, where its own picker had been a `ghost` / `sm` FileButton: two
|
|
196
|
+
* bordered full-size buttons under a chat box read as a second form, and the field
|
|
197
|
+
* gave no way to say otherwise. Every mode, wherever the buttons appear; the chips'
|
|
198
|
+
* remove buttons keep their size.
|
|
199
|
+
*/
|
|
200
|
+
buttonVariant?: ButtonVariant;
|
|
201
|
+
/** The add and capture buttons' size — Button's own `size`, with `buttonVariant`.
|
|
202
|
+
* Default `"md"`. `"sm"` draws their icons at 14px, as a small button's are. */
|
|
203
|
+
buttonSize?: ButtonSize;
|
|
105
204
|
className?: string;
|
|
106
205
|
}
|
|
107
206
|
|
|
207
|
+
/**
|
|
208
|
+
* The field's root, in every mode. `relative` for the `sr-only` file input (see the
|
|
209
|
+
* single field). `min-w-0 max-w-full` (0.24.0, keksdose) because the field is often a
|
|
210
|
+
* FLEX ITEM — ChatComposer's `attachmentSlot` row, FormActions' `start` — and a flex item
|
|
211
|
+
* is never narrower than its content's min-content width unless told it may be. A chip
|
|
212
|
+
* truncates its name only inside a box of bounded width, and a screenshot's name
|
|
213
|
+
* ("Screenshot 2026-10-03 at 07.45.12.png") was the min-content: the field grew to it
|
|
214
|
+
* and ran off a 390px phone composer (measured in Chromium: the field's right edge at
|
|
215
|
+
* 592px, the page 614px wide). keksdose passed `className="min-w-0 max-w-full"` at both
|
|
216
|
+
* its callers to stop it; here once, for every host. In normal flow — a dialog, a note
|
|
217
|
+
* editor — both are no-ops.
|
|
218
|
+
*/
|
|
219
|
+
const FIELD_ROOT = "relative min-w-0 max-w-full";
|
|
220
|
+
|
|
221
|
+
/** Paperclip and camera at the size the button's text is: 16px at `md`, 14px at `sm`. */
|
|
222
|
+
const buttonIcon = (size: ButtonSize) => (size === "sm" ? "size-3.5" : "size-4");
|
|
223
|
+
|
|
108
224
|
/** One file: choosing a second means removing the first. The field's only mode until 0.15.5. */
|
|
109
225
|
export interface FeedbackAttachmentFieldSingleProps extends FeedbackAttachmentFieldBaseProps {
|
|
110
226
|
multiple?: false;
|
|
111
227
|
refs?: false;
|
|
112
228
|
value: File | null;
|
|
113
229
|
onChange: (file: File | null) => void;
|
|
114
|
-
|
|
230
|
+
/** A file was refused. `info` (0.23.0) names it and the limit — see
|
|
231
|
+
* {@link FeedbackAttachmentErrorInfo}. */
|
|
232
|
+
onError?: (kind: "type" | "size", info: FeedbackAttachmentErrorInfo) => void;
|
|
115
233
|
// Multiple-mode props, refused here: without `multiple` they would be silently ignored.
|
|
116
234
|
max?: never;
|
|
117
235
|
screenshot?: never;
|
|
118
236
|
onScreenshotChange?: never;
|
|
237
|
+
// `refs` mode's lock: a staged File is not a commit (see the refs props).
|
|
238
|
+
commit?: never;
|
|
239
|
+
disabledReason?: never;
|
|
119
240
|
}
|
|
120
241
|
|
|
121
242
|
/**
|
|
@@ -131,8 +252,10 @@ export interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachment
|
|
|
131
252
|
/** How many files `value` may hold. Default {@link DEFAULT_MAX_ATTACHMENTS} (5). The
|
|
132
253
|
* add buttons and the paste hint are offered only while there is room. */
|
|
133
254
|
max?: number;
|
|
134
|
-
/** Per file: a rejected one is reported and the others are still added.
|
|
135
|
-
|
|
255
|
+
/** Per file: a rejected one is reported and the others are still added. `"count"` is
|
|
256
|
+
* once per pick, with the whole surplus in `info.files`. `info` (0.23.0): see
|
|
257
|
+
* {@link FeedbackAttachmentErrorInfo}. */
|
|
258
|
+
onError?: (kind: FeedbackAttachmentError, info: FeedbackAttachmentErrorInfo) => void;
|
|
136
259
|
/**
|
|
137
260
|
* A separate slot for THE screenshot, outside `value` and `max`. Given
|
|
138
261
|
* `onScreenshotChange`, what `onCaptureScreenshot` returns lands here instead of in
|
|
@@ -143,6 +266,9 @@ export interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachment
|
|
|
143
266
|
*/
|
|
144
267
|
screenshot?: File | null;
|
|
145
268
|
onScreenshotChange?: (file: File | null) => void;
|
|
269
|
+
// `refs` mode's lock: a staged File is not a commit (see the refs props).
|
|
270
|
+
commit?: never;
|
|
271
|
+
disabledReason?: never;
|
|
146
272
|
}
|
|
147
273
|
|
|
148
274
|
/**
|
|
@@ -180,8 +306,28 @@ export interface FeedbackAttachmentFieldRefsProps extends FeedbackAttachmentFiel
|
|
|
180
306
|
/** How many refs `value` may hold, uploads in flight included. Default
|
|
181
307
|
* {@link DEFAULT_MAX_ATTACHMENTS} (5). */
|
|
182
308
|
max?: number;
|
|
183
|
-
/** Per file. `"upload"` carries what `onUpload` rejected with
|
|
184
|
-
|
|
309
|
+
/** Per file. `"upload"` carries what `onUpload` rejected with as `error` (`undefined`
|
|
310
|
+
* for the other kinds). `info` (0.23.0) comes third so a 0.22 handler's `error` is
|
|
311
|
+
* still the second argument — see {@link FeedbackAttachmentErrorInfo}. */
|
|
312
|
+
onError?: (kind: FeedbackAttachmentRefsError, error: unknown, info: FeedbackAttachmentErrorInfo) => void;
|
|
313
|
+
/**
|
|
314
|
+
* This field COMMITS (0.23.0): each file is uploaded the moment it is chosen, and a
|
|
315
|
+
* remove may delete the upload (`onRemove`) — a save, unlike the File modes, which
|
|
316
|
+
* only stage a file for a form sent later (and so stay open under a lock, like every
|
|
317
|
+
* field). Under a locked {@link WriteLockProvider} it takes the `disabledReason` path
|
|
318
|
+
* with the lock's reason, which wins over one of its own. No provider, or an unlocked
|
|
319
|
+
* one: no effect.
|
|
320
|
+
*/
|
|
321
|
+
commit?: boolean;
|
|
322
|
+
/**
|
|
323
|
+
* Why no file can be added or removed — the write lock's sentence, a quota, a thread
|
|
324
|
+
* closed to replies. Unlike `disabled`, it SAYS so, the kit's commit-control way: the
|
|
325
|
+
* buttons (add, capture, every chip's remove) stay focusable, `aria-disabled`, with
|
|
326
|
+
* the reason in the kit Tooltip and their description; no pick, paste or remove gets
|
|
327
|
+
* through; and the reason stands where the paste hint was, for a phone that never
|
|
328
|
+
* hovers. The chips stay readable.
|
|
329
|
+
*/
|
|
330
|
+
disabledReason?: ReactNode;
|
|
185
331
|
screenshot?: never;
|
|
186
332
|
onScreenshotChange?: never;
|
|
187
333
|
}
|
|
@@ -239,6 +385,9 @@ function SingleField({
|
|
|
239
385
|
onCaptureScreenshot,
|
|
240
386
|
documentPaste = false,
|
|
241
387
|
pasteFrom,
|
|
388
|
+
disabled = false,
|
|
389
|
+
buttonVariant = "secondary",
|
|
390
|
+
buttonSize = "md",
|
|
242
391
|
className,
|
|
243
392
|
}: FeedbackAttachmentFieldSingleProps) {
|
|
244
393
|
// The object URL is keyed to the file it was made for, so a stale one (from the
|
|
@@ -249,6 +398,7 @@ function SingleField({
|
|
|
249
398
|
// `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.
|
|
250
399
|
const fileText = useKitFileLabels();
|
|
251
400
|
const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
|
|
401
|
+
const errorInfo = useErrorInfo(accept, maxBytes, undefined, text);
|
|
252
402
|
|
|
253
403
|
// Object-URL preview lifecycle (create on change, revoke on cleanup).
|
|
254
404
|
useEffect(() => {
|
|
@@ -266,7 +416,7 @@ function SingleField({
|
|
|
266
416
|
if (!file) return;
|
|
267
417
|
const problem = rejection(file, accept, maxBytes);
|
|
268
418
|
if (problem) {
|
|
269
|
-
onError?.(problem);
|
|
419
|
+
onError?.(problem, errorInfo(problem, [file]));
|
|
270
420
|
return;
|
|
271
421
|
}
|
|
272
422
|
onChange(file);
|
|
@@ -282,6 +432,7 @@ function SingleField({
|
|
|
282
432
|
},
|
|
283
433
|
documentPaste,
|
|
284
434
|
pasteFrom,
|
|
435
|
+
disabled,
|
|
285
436
|
);
|
|
286
437
|
|
|
287
438
|
return (
|
|
@@ -289,8 +440,9 @@ function SingleField({
|
|
|
289
440
|
// `relative` for the same reason as FileDropzone: the `sr-only` file input below
|
|
290
441
|
// is `position: absolute`, and without a positioned ancestor it is laid out
|
|
291
442
|
// against the initial containing block — extending the DOCUMENT height to its own
|
|
292
|
-
// offset and producing a phantom second scrollbar on any long page.
|
|
293
|
-
|
|
443
|
+
// offset and producing a phantom second scrollbar on any long page. And
|
|
444
|
+
// `min-w-0 max-w-full`, so a long name truncates in a flex row: see FIELD_ROOT.
|
|
445
|
+
className={cn(FIELD_ROOT, className)}
|
|
294
446
|
onPaste={onPaste}
|
|
295
447
|
>
|
|
296
448
|
<Heading text={labelsProp?.attachment} />
|
|
@@ -315,23 +467,29 @@ function SingleField({
|
|
|
315
467
|
{fileText.size(value.size)}
|
|
316
468
|
</div>
|
|
317
469
|
</div>
|
|
318
|
-
<
|
|
319
|
-
type="button"
|
|
320
|
-
onClick={() => onChange(null)}
|
|
321
|
-
aria-label={text.attachmentRemove}
|
|
322
|
-
className="rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
|
|
323
|
-
>
|
|
324
|
-
<X className="size-4" />
|
|
325
|
-
</button>
|
|
470
|
+
<RemoveButton label={text.attachmentRemove} onClick={() => onChange(null)} disabled={disabled} />
|
|
326
471
|
</div>
|
|
327
472
|
) : (
|
|
328
473
|
<div className="space-y-1.5">
|
|
329
474
|
<div className="flex flex-wrap gap-2">
|
|
330
|
-
<Button
|
|
331
|
-
|
|
475
|
+
<Button
|
|
476
|
+
type="button"
|
|
477
|
+
variant={buttonVariant}
|
|
478
|
+
size={buttonSize}
|
|
479
|
+
disabled={disabled}
|
|
480
|
+
onClick={() => fileInputRef.current?.click()}
|
|
481
|
+
>
|
|
482
|
+
<Paperclip className={buttonIcon(buttonSize)} /> {text.attachmentAdd}
|
|
332
483
|
</Button>
|
|
333
484
|
{onCaptureScreenshot && (
|
|
334
|
-
<CaptureButton
|
|
485
|
+
<CaptureButton
|
|
486
|
+
capture={onCaptureScreenshot}
|
|
487
|
+
onFile={pick}
|
|
488
|
+
label={text.attachmentCapture}
|
|
489
|
+
disabled={disabled}
|
|
490
|
+
variant={buttonVariant}
|
|
491
|
+
size={buttonSize}
|
|
492
|
+
/>
|
|
335
493
|
)}
|
|
336
494
|
</div>
|
|
337
495
|
{/* Said out loud, because a gesture with no affordance is a gesture
|
|
@@ -346,6 +504,7 @@ function SingleField({
|
|
|
346
504
|
type="file"
|
|
347
505
|
accept={accept.join(",")}
|
|
348
506
|
className="sr-only"
|
|
507
|
+
disabled={disabled}
|
|
349
508
|
onChange={(e) => {
|
|
350
509
|
pick(e.target.files?.[0]);
|
|
351
510
|
e.target.value = "";
|
|
@@ -368,6 +527,9 @@ function MultipleField({
|
|
|
368
527
|
onCaptureScreenshot,
|
|
369
528
|
documentPaste = false,
|
|
370
529
|
pasteFrom,
|
|
530
|
+
disabled = false,
|
|
531
|
+
buttonVariant = "secondary",
|
|
532
|
+
buttonSize = "md",
|
|
371
533
|
className,
|
|
372
534
|
}: FeedbackAttachmentFieldMultipleProps) {
|
|
373
535
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
@@ -377,6 +539,7 @@ function MultipleField({
|
|
|
377
539
|
const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
|
|
378
540
|
const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
|
|
379
541
|
const screenshotLabel = text.attachmentScreenshot ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentScreenshot!;
|
|
542
|
+
const errorInfo = useErrorInfo(accept, maxBytes, max, text);
|
|
380
543
|
|
|
381
544
|
// What an add or a remove builds on: the `value` prop, plus whatever this field has
|
|
382
545
|
// already handed to `onChange` since the parent last rendered. The field is
|
|
@@ -405,27 +568,28 @@ function MultipleField({
|
|
|
405
568
|
const accepted: File[] = [];
|
|
406
569
|
const taken = new Set([...current, ...(screenshot ? [screenshot] : [])].map((f) => f.name));
|
|
407
570
|
for (const raw of incoming) {
|
|
408
|
-
const problem = rejection(raw, accept, maxBytes);
|
|
409
|
-
if (problem) {
|
|
410
|
-
onError?.(problem);
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
571
|
// Two pastes are two files called "pasted.png", and a backend that stores by
|
|
414
572
|
// name keeps one of them — the very loss this mode exists to end. A picked
|
|
415
|
-
// file keeps its own name: it is the user's, and they may look for it.
|
|
573
|
+
// file keeps its own name: it is the user's, and they may look for it. Named
|
|
574
|
+
// before the check, so a refusal names it as the single field's does.
|
|
416
575
|
const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
|
|
576
|
+
const problem = rejection(file, accept, maxBytes);
|
|
577
|
+
if (problem) {
|
|
578
|
+
onError?.(problem, errorInfo(problem, [file]));
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
417
581
|
taken.add(file.name);
|
|
418
582
|
// The same File object twice is one file chosen twice, not two.
|
|
419
583
|
if (!current.includes(file)) accepted.push(file);
|
|
420
584
|
}
|
|
421
585
|
if (accepted.length === 0) return;
|
|
422
586
|
const space = Math.max(0, max - current.length);
|
|
423
|
-
if (accepted.length > space) onError?.("count");
|
|
587
|
+
if (accepted.length > space) onError?.("count", errorInfo("count", accepted.slice(space)));
|
|
424
588
|
const kept = accepted.slice(0, space);
|
|
425
589
|
if (kept.length > 0) commit([...current, ...kept]);
|
|
426
590
|
};
|
|
427
591
|
|
|
428
|
-
const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
|
|
592
|
+
const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom, disabled);
|
|
429
593
|
|
|
430
594
|
const capture = (file: File) => {
|
|
431
595
|
if (!hasSlot) {
|
|
@@ -433,7 +597,7 @@ function MultipleField({
|
|
|
433
597
|
return;
|
|
434
598
|
}
|
|
435
599
|
const problem = rejection(file, accept, maxBytes);
|
|
436
|
-
if (problem) onError?.(problem);
|
|
600
|
+
if (problem) onError?.(problem, errorInfo(problem, [file]));
|
|
437
601
|
else onScreenshotChange(file);
|
|
438
602
|
};
|
|
439
603
|
|
|
@@ -468,7 +632,7 @@ function MultipleField({
|
|
|
468
632
|
];
|
|
469
633
|
|
|
470
634
|
return (
|
|
471
|
-
<div ref={rootRef} className={cn("
|
|
635
|
+
<div ref={rootRef} className={cn(FIELD_ROOT, "space-y-2", className)} onPaste={onPaste}>
|
|
472
636
|
<Heading text={labelsProp?.attachment} className="mb-0" />
|
|
473
637
|
{chips.length > 0 && (
|
|
474
638
|
// One chip per row, full width: wrapped chips of name-dependent widths made a
|
|
@@ -489,18 +653,14 @@ function MultipleField({
|
|
|
489
653
|
{chip.screenshot ? `${chip.file.name} · ${fileText.size(chip.file.size)}` : fileText.size(chip.file.size)}
|
|
490
654
|
</div>
|
|
491
655
|
</div>
|
|
492
|
-
<
|
|
493
|
-
|
|
494
|
-
|
|
656
|
+
<RemoveButton
|
|
657
|
+
label={removeLabel(chip.name)}
|
|
658
|
+
disabled={disabled}
|
|
495
659
|
onClick={() => {
|
|
496
660
|
pendingFocus.current = index;
|
|
497
661
|
chip.remove();
|
|
498
662
|
}}
|
|
499
|
-
|
|
500
|
-
className="shrink-0 rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
|
|
501
|
-
>
|
|
502
|
-
<X className="size-4" />
|
|
503
|
-
</button>
|
|
663
|
+
/>
|
|
504
664
|
</li>
|
|
505
665
|
))}
|
|
506
666
|
</ul>
|
|
@@ -510,11 +670,13 @@ function MultipleField({
|
|
|
510
670
|
{room > 0 && (
|
|
511
671
|
<Button
|
|
512
672
|
type="button"
|
|
513
|
-
variant=
|
|
673
|
+
variant={buttonVariant}
|
|
674
|
+
size={buttonSize}
|
|
514
675
|
data-attachment-action=""
|
|
676
|
+
disabled={disabled}
|
|
515
677
|
onClick={() => fileInputRef.current?.click()}
|
|
516
678
|
>
|
|
517
|
-
<Paperclip className=
|
|
679
|
+
<Paperclip className={buttonIcon(buttonSize)} /> {text.attachmentAdd}
|
|
518
680
|
</Button>
|
|
519
681
|
)}
|
|
520
682
|
{canCapture && onCaptureScreenshot && (
|
|
@@ -522,6 +684,9 @@ function MultipleField({
|
|
|
522
684
|
capture={onCaptureScreenshot}
|
|
523
685
|
onFile={capture}
|
|
524
686
|
label={text.attachmentCapture}
|
|
687
|
+
disabled={disabled}
|
|
688
|
+
variant={buttonVariant}
|
|
689
|
+
size={buttonSize}
|
|
525
690
|
data-attachment-action=""
|
|
526
691
|
/>
|
|
527
692
|
)}
|
|
@@ -540,6 +705,7 @@ function MultipleField({
|
|
|
540
705
|
// the way in, and a second, unlabelled "Choose files" stop is noise.
|
|
541
706
|
tabIndex={-1}
|
|
542
707
|
aria-hidden
|
|
708
|
+
disabled={disabled}
|
|
543
709
|
onChange={(e) => {
|
|
544
710
|
add(Array.from(e.target.files ?? []));
|
|
545
711
|
e.target.value = "";
|
|
@@ -571,6 +737,11 @@ function RefsField({
|
|
|
571
737
|
onCaptureScreenshot,
|
|
572
738
|
documentPaste = false,
|
|
573
739
|
pasteFrom,
|
|
740
|
+
disabled = false,
|
|
741
|
+
commit: commits,
|
|
742
|
+
disabledReason: ownDisabledReason,
|
|
743
|
+
buttonVariant = "secondary",
|
|
744
|
+
buttonSize = "md",
|
|
574
745
|
className,
|
|
575
746
|
}: FeedbackAttachmentFieldRefsProps) {
|
|
576
747
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
@@ -580,6 +751,14 @@ function RefsField({
|
|
|
580
751
|
const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
|
|
581
752
|
const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
|
|
582
753
|
const uploadingLabel = text.attachmentUploading ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentUploading!;
|
|
754
|
+
const errorInfo = useErrorInfo(accept, maxBytes, max, text);
|
|
755
|
+
// The lock's reason (under `commit`) over the field's own; with either, the controls
|
|
756
|
+
// take the focusable `aria-disabled` path and say why — Button's and IconButton's
|
|
757
|
+
// `disabledReason`, handed down as is.
|
|
758
|
+
const disabledReason = useCommitReason(commits, ownDisabledReason);
|
|
759
|
+
const locked = hasReason(disabledReason);
|
|
760
|
+
const reason = locked ? disabledReason : undefined;
|
|
761
|
+
const inert = disabled || locked;
|
|
583
762
|
|
|
584
763
|
// As in `multiple` mode: `value` plus whatever has been handed to `onChange` since
|
|
585
764
|
// the parent last rendered — two uploads landing in one tick must both survive.
|
|
@@ -637,7 +816,7 @@ function RefsField({
|
|
|
637
816
|
},
|
|
638
817
|
(error: unknown) => {
|
|
639
818
|
settle();
|
|
640
|
-
if (mounted.current) onError?.("upload", error);
|
|
819
|
+
if (mounted.current) onError?.("upload", error, errorInfo("upload", [file], error));
|
|
641
820
|
},
|
|
642
821
|
);
|
|
643
822
|
};
|
|
@@ -645,28 +824,29 @@ function RefsField({
|
|
|
645
824
|
const add = (incoming: File[], pasted = false) => {
|
|
646
825
|
let space = Math.max(0, max - latest.current.length - inFlight.current.length);
|
|
647
826
|
const taken = new Set([...latest.current.map((r) => r.name), ...inFlight.current.map((u) => u.file.name)]);
|
|
648
|
-
|
|
827
|
+
const dropped: File[] = [];
|
|
649
828
|
for (const raw of incoming) {
|
|
650
|
-
|
|
829
|
+
// Pasted images are all "image.png" to the clipboard; as in `multiple` mode,
|
|
830
|
+
// each gets a name of its own before it is checked and uploaded under it.
|
|
831
|
+
const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
|
|
832
|
+
const problem = rejection(file, accept, maxBytes);
|
|
651
833
|
if (problem) {
|
|
652
|
-
|
|
834
|
+
// `undefined` in `error`'s place, as in 0.22: only an upload has one.
|
|
835
|
+
onError?.(problem, undefined, errorInfo(problem, [file]));
|
|
653
836
|
continue;
|
|
654
837
|
}
|
|
838
|
+
taken.add(file.name);
|
|
655
839
|
if (space === 0) {
|
|
656
|
-
dropped
|
|
840
|
+
dropped.push(file);
|
|
657
841
|
continue;
|
|
658
842
|
}
|
|
659
|
-
// Pasted images are all "image.png" to the clipboard; as in `multiple` mode,
|
|
660
|
-
// each gets a name of its own before it is uploaded under it.
|
|
661
|
-
const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
|
|
662
|
-
taken.add(file.name);
|
|
663
843
|
space -= 1;
|
|
664
844
|
start(file);
|
|
665
845
|
}
|
|
666
|
-
if (dropped) onError?.("count");
|
|
846
|
+
if (dropped.length > 0) onError?.("count", undefined, errorInfo("count", dropped));
|
|
667
847
|
};
|
|
668
848
|
|
|
669
|
-
const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
|
|
849
|
+
const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom, inert);
|
|
670
850
|
|
|
671
851
|
// Focus after a remove, as in `multiple` mode: the next chip, else the first action.
|
|
672
852
|
const pendingFocus = useRef<number | null>(null);
|
|
@@ -683,6 +863,7 @@ function RefsField({
|
|
|
683
863
|
}, [value]);
|
|
684
864
|
|
|
685
865
|
const remove = (key: string, index: number) => {
|
|
866
|
+
if (inert) return;
|
|
686
867
|
pendingFocus.current = index;
|
|
687
868
|
commit(latest.current.filter((r) => r.key !== key));
|
|
688
869
|
onRemove?.(key);
|
|
@@ -691,7 +872,7 @@ function RefsField({
|
|
|
691
872
|
const hasChips = value.length > 0 || uploads.length > 0;
|
|
692
873
|
|
|
693
874
|
return (
|
|
694
|
-
<div ref={rootRef} className={cn("
|
|
875
|
+
<div ref={rootRef} className={cn(FIELD_ROOT, "space-y-2", className)} onPaste={onPaste}>
|
|
695
876
|
<Heading text={labelsProp?.attachment} className="mb-0" />
|
|
696
877
|
{hasChips && (
|
|
697
878
|
<ul aria-label={text.attachmentList} className="flex flex-col gap-2">
|
|
@@ -707,15 +888,12 @@ function RefsField({
|
|
|
707
888
|
<div className="truncate text-xs text-[var(--text-muted)]">{fileText.size(ref.size)}</div>
|
|
708
889
|
)}
|
|
709
890
|
</div>
|
|
710
|
-
<
|
|
711
|
-
|
|
712
|
-
data-attachment-remove=""
|
|
891
|
+
<RemoveButton
|
|
892
|
+
label={removeLabel(ref.name)}
|
|
713
893
|
onClick={() => remove(ref.key, index)}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
<X className="size-4" />
|
|
718
|
-
</button>
|
|
894
|
+
disabled={disabled}
|
|
895
|
+
reason={reason}
|
|
896
|
+
/>
|
|
719
897
|
</li>
|
|
720
898
|
))}
|
|
721
899
|
{uploads.map((upload) => (
|
|
@@ -740,23 +918,38 @@ function RefsField({
|
|
|
740
918
|
<div className="flex flex-wrap gap-2">
|
|
741
919
|
<Button
|
|
742
920
|
type="button"
|
|
743
|
-
variant=
|
|
921
|
+
variant={buttonVariant}
|
|
922
|
+
size={buttonSize}
|
|
744
923
|
data-attachment-action=""
|
|
924
|
+
disabled={disabled}
|
|
925
|
+
disabledReason={reason}
|
|
745
926
|
onClick={() => fileInputRef.current?.click()}
|
|
746
927
|
>
|
|
747
|
-
<Paperclip className=
|
|
928
|
+
<Paperclip className={buttonIcon(buttonSize)} /> {text.attachmentAdd}
|
|
748
929
|
</Button>
|
|
749
930
|
{onCaptureScreenshot && (
|
|
750
931
|
<CaptureButton
|
|
751
932
|
capture={onCaptureScreenshot}
|
|
752
933
|
onFile={(file) => add([file])}
|
|
753
934
|
label={text.attachmentCapture}
|
|
935
|
+
disabled={disabled}
|
|
936
|
+
disabledReason={reason}
|
|
937
|
+
variant={buttonVariant}
|
|
938
|
+
size={buttonSize}
|
|
754
939
|
data-attachment-action=""
|
|
755
940
|
/>
|
|
756
941
|
)}
|
|
757
942
|
</div>
|
|
758
943
|
)}
|
|
759
|
-
|
|
944
|
+
{locked ? (
|
|
945
|
+
// Where the paste hint was: a phone never hovers, so the Tooltip alone would
|
|
946
|
+
// leave a touch user with buttons that do nothing and no word why.
|
|
947
|
+
<div data-attachment-reason="" className="text-xs text-[var(--text-muted)]">
|
|
948
|
+
{disabledReason}
|
|
949
|
+
</div>
|
|
950
|
+
) : (
|
|
951
|
+
<p className="text-xs text-[var(--text-muted)]">{room > 0 ? text.attachmentPaste : limitLabel(max)}</p>
|
|
952
|
+
)}
|
|
760
953
|
<input
|
|
761
954
|
ref={fileInputRef}
|
|
762
955
|
type="file"
|
|
@@ -765,6 +958,7 @@ function RefsField({
|
|
|
765
958
|
className="sr-only"
|
|
766
959
|
tabIndex={-1}
|
|
767
960
|
aria-hidden
|
|
961
|
+
disabled={inert}
|
|
768
962
|
onChange={(e) => {
|
|
769
963
|
add(Array.from(e.target.files ?? []));
|
|
770
964
|
e.target.value = "";
|
|
@@ -797,22 +991,32 @@ function CaptureButton({
|
|
|
797
991
|
capture,
|
|
798
992
|
onFile,
|
|
799
993
|
label,
|
|
994
|
+
disabled = false,
|
|
995
|
+
disabledReason,
|
|
996
|
+
variant,
|
|
997
|
+
size,
|
|
800
998
|
...rest
|
|
801
999
|
}: {
|
|
802
1000
|
capture: () => Promise<File | null>;
|
|
803
1001
|
onFile: (file: File) => void;
|
|
804
1002
|
label: string;
|
|
1003
|
+
disabled?: boolean;
|
|
1004
|
+
disabledReason?: ReactNode;
|
|
1005
|
+
variant: ButtonVariant;
|
|
1006
|
+
size: ButtonSize;
|
|
805
1007
|
"data-attachment-action"?: string;
|
|
806
1008
|
}) {
|
|
807
1009
|
const [capturing, setCapturing] = useState(false);
|
|
808
1010
|
return (
|
|
809
1011
|
<Button
|
|
810
1012
|
type="button"
|
|
811
|
-
variant=
|
|
812
|
-
|
|
1013
|
+
variant={variant}
|
|
1014
|
+
size={size}
|
|
1015
|
+
disabled={capturing || disabled}
|
|
1016
|
+
disabledReason={disabledReason}
|
|
813
1017
|
{...rest}
|
|
814
1018
|
onClick={() => {
|
|
815
|
-
if (capturing) return;
|
|
1019
|
+
if (capturing || disabled) return;
|
|
816
1020
|
setCapturing(true);
|
|
817
1021
|
void capture()
|
|
818
1022
|
.then((file) => {
|
|
@@ -821,11 +1025,98 @@ function CaptureButton({
|
|
|
821
1025
|
.finally(() => setCapturing(false));
|
|
822
1026
|
}}
|
|
823
1027
|
>
|
|
824
|
-
<Camera className=
|
|
1028
|
+
<Camera className={buttonIcon(size)} /> {capturing ? "…" : label}
|
|
825
1029
|
</Button>
|
|
826
1030
|
);
|
|
827
1031
|
}
|
|
828
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* A chip's (or the single file's) remove button: the kit's IconButton at the 28px the
|
|
1035
|
+
* hand-built one had, so `disabled` (0.23.0) dims and disables it the kit way and a
|
|
1036
|
+
* `reason` takes IconButton's `disabledReason` path — focusable, `aria-disabled`, the
|
|
1037
|
+
* reason in the Tooltip — rather than a third copy of either here.
|
|
1038
|
+
*/
|
|
1039
|
+
function RemoveButton({
|
|
1040
|
+
label,
|
|
1041
|
+
onClick,
|
|
1042
|
+
disabled,
|
|
1043
|
+
reason,
|
|
1044
|
+
}: {
|
|
1045
|
+
label: string;
|
|
1046
|
+
onClick: () => void;
|
|
1047
|
+
disabled: boolean;
|
|
1048
|
+
reason?: ReactNode;
|
|
1049
|
+
}) {
|
|
1050
|
+
return (
|
|
1051
|
+
<IconButton
|
|
1052
|
+
type="button"
|
|
1053
|
+
size="xs"
|
|
1054
|
+
tone="muted"
|
|
1055
|
+
data-attachment-remove=""
|
|
1056
|
+
aria-label={label}
|
|
1057
|
+
disabled={disabled}
|
|
1058
|
+
disabledReason={reason}
|
|
1059
|
+
onClick={onClick}
|
|
1060
|
+
className="shrink-0"
|
|
1061
|
+
>
|
|
1062
|
+
<X aria-hidden />
|
|
1063
|
+
</IconButton>
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** A reason worth showing: `null`, `false` and `""` are a caller's "no reason". */
|
|
1068
|
+
function hasReason(reason: ReactNode): boolean {
|
|
1069
|
+
return reason !== undefined && reason !== null && reason !== false && reason !== "";
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* The {@link FeedbackAttachmentErrorInfo} builder of one field: its limits, and the
|
|
1074
|
+
* sentence for each refusal. The sentences are the `filePicker` namespace's — the ones
|
|
1075
|
+
* FileButton and FileDropzone say — so an app that translated those has these too, and
|
|
1076
|
+
* a refusal reads the same whichever of the kit's pickers made it. Only the upload
|
|
1077
|
+
* failure, which no other picker has, is this field's own (`attachmentUploadFailed`).
|
|
1078
|
+
*/
|
|
1079
|
+
function useErrorInfo(
|
|
1080
|
+
accept: string[],
|
|
1081
|
+
maxBytes: number,
|
|
1082
|
+
max: number | undefined,
|
|
1083
|
+
text: FeedbackAttachmentFieldLabels,
|
|
1084
|
+
) {
|
|
1085
|
+
const picker = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS);
|
|
1086
|
+
const fileText = useKitFileLabels();
|
|
1087
|
+
const uploadFailed = text.attachmentUploadFailed ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentUploadFailed!;
|
|
1088
|
+
return (kind: FeedbackAttachmentRefsError, files: File[], error?: unknown): FeedbackAttachmentErrorInfo => {
|
|
1089
|
+
const file = files[0];
|
|
1090
|
+
let message: string;
|
|
1091
|
+
if (kind === "type") {
|
|
1092
|
+
// FileButton's rule: name what IS accepted ("Only image/png, image/jpeg files"),
|
|
1093
|
+
// unless the app translated `rejectedType` but not yet `rejectedTypeOnly` — its
|
|
1094
|
+
// own sentence beats an English one.
|
|
1095
|
+
const acceptText = formatAccept(accept.join(","));
|
|
1096
|
+
const useOnly =
|
|
1097
|
+
acceptText !== "" &&
|
|
1098
|
+
(picker.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||
|
|
1099
|
+
picker.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);
|
|
1100
|
+
message = useOnly ? picker.rejectedTypeOnly(acceptText, file.name) : picker.rejectedType(file.name);
|
|
1101
|
+
} else if (kind === "size") {
|
|
1102
|
+
message = picker.rejectedSize(file.name, fileText.size(maxBytes));
|
|
1103
|
+
} else if (kind === "count") {
|
|
1104
|
+
message = files.length === 1 ? picker.rejectedCount(file.name, max ?? 1) : picker.rejectedMany(files.length);
|
|
1105
|
+
} else {
|
|
1106
|
+
message = uploadFailed(file.name);
|
|
1107
|
+
}
|
|
1108
|
+
return {
|
|
1109
|
+
file,
|
|
1110
|
+
files,
|
|
1111
|
+
message,
|
|
1112
|
+
maxBytes,
|
|
1113
|
+
accept,
|
|
1114
|
+
...(max !== undefined ? { max } : null),
|
|
1115
|
+
...(kind === "upload" ? { error } : null),
|
|
1116
|
+
};
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
829
1120
|
/** A chip's picture: the image itself, or a file glyph for anything that is not one.
|
|
830
1121
|
* Decorative (`alt=""`) — the name stands beside it. Its own component so the object
|
|
831
1122
|
* URL lives exactly as long as the chip: removing the chip unmounts it and revokes. */
|
|
@@ -903,10 +1194,15 @@ function usePaste(
|
|
|
903
1194
|
onImages: (images: File[]) => void,
|
|
904
1195
|
documentPaste: boolean,
|
|
905
1196
|
pasteFrom: RefObject<HTMLElement | null> | undefined,
|
|
1197
|
+
/** `disabled` / locked (0.23.0): the paste goes where it was going, untouched — not
|
|
1198
|
+
* swallowed, so a text half still lands in the box it was made in. */
|
|
1199
|
+
off: boolean,
|
|
906
1200
|
) {
|
|
907
|
-
|
|
1201
|
+
// `null` while off, so the listeners below leave the event alone entirely.
|
|
1202
|
+
const handler = off ? null : onImages;
|
|
1203
|
+
const latest = useRef(handler);
|
|
908
1204
|
useEffect(() => {
|
|
909
|
-
latest.current =
|
|
1205
|
+
latest.current = handler;
|
|
910
1206
|
});
|
|
911
1207
|
|
|
912
1208
|
// The element listened on, where it is not this field's own subtree: the
|
|
@@ -917,6 +1213,7 @@ function usePaste(
|
|
|
917
1213
|
const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);
|
|
918
1214
|
if (!target) return;
|
|
919
1215
|
const onPaste = (event: Event) => {
|
|
1216
|
+
if (!latest.current) return;
|
|
920
1217
|
const clipboard = (event as ClipboardEvent).clipboardData;
|
|
921
1218
|
takeImages(clipboard, () => event.preventDefault(), latest.current);
|
|
922
1219
|
};
|
|
@@ -927,7 +1224,10 @@ function usePaste(
|
|
|
927
1224
|
const listensElsewhere = documentPaste || pasteFrom !== undefined;
|
|
928
1225
|
return listensElsewhere
|
|
929
1226
|
? undefined
|
|
930
|
-
: (event: ReactClipboardEvent) =>
|
|
1227
|
+
: (event: ReactClipboardEvent) => {
|
|
1228
|
+
if (!latest.current) return;
|
|
1229
|
+
takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
|
|
1230
|
+
};
|
|
931
1231
|
}
|
|
932
1232
|
|
|
933
1233
|
function takeImages(clipboard: DataTransfer | null, stop: () => void, onImages: (images: File[]) => void) {
|