@eifi1/ui-kit 0.21.0 → 0.23.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 +19 -14
- package/dist/components/account-chips.d.ts +10 -3
- package/dist/components/account-chips.js +5 -2
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings.d.ts +33 -2
- package/dist/components/account-settings.js +43 -21
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +53 -14
- package/dist/components/amount-input.js +17 -28
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +29 -0
- package/dist/components/autocomplete.js +134 -104
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +19 -14
- package/dist/components/calculator.d.ts +19 -14
- package/dist/components/checkbox-group.d.ts +104 -0
- package/dist/components/checkbox-group.js +111 -0
- package/dist/components/checkbox-group.js.map +1 -0
- package/dist/components/checkbox.d.ts +19 -0
- package/dist/components/checkbox.js +30 -7
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +18 -0
- package/dist/components/chip.js +47 -10
- package/dist/components/chip.js.map +1 -1
- package/dist/components/column-mapper.d.ts +182 -0
- package/dist/components/column-mapper.js +375 -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 +91 -2
- package/dist/components/combobox.js +454 -308
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +69 -161
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +16 -11
- package/dist/components/country-select.d.ts +167 -0
- package/dist/components/country-select.js +293 -0
- package/dist/components/country-select.js.map +1 -0
- package/dist/components/currency-select.d.ts +17 -1
- package/dist/components/currency-select.js +56 -23
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +69 -166
- package/dist/components/danger-confirm.js +144 -50
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -2
- package/dist/components/data-table-filter-popover.d.ts +1 -2
- package/dist/components/data-table-filters.d.ts +1 -2
- package/dist/components/data-table-labels.d.ts +4 -97
- package/dist/components/data-table-labels.js +9 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table.d.ts +1 -2
- package/dist/components/data-table.js +98 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +66 -5
- package/dist/components/date-picker.js +378 -66
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +42 -1
- package/dist/components/entity-combobox.js +154 -113
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +19 -14
- package/dist/components/field-parts.d.ts +224 -0
- package/dist/components/field-parts.js +156 -0
- package/dist/components/field-parts.js.map +1 -0
- 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 +19 -14
- package/dist/components/file-button.js +13 -3
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +41 -15
- package/dist/components/file-dropzone.js +57 -34
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +18 -13
- package/dist/components/form-actions.js +69 -5
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +69 -0
- package/dist/components/iban-input.js +125 -0
- package/dist/components/iban-input.js.map +1 -0
- package/dist/components/icon-picker.d.ts +21 -2
- package/dist/components/icon-picker.js +19 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-edit-field.d.ts +167 -0
- package/dist/components/inline-edit-field.js +293 -0
- package/dist/components/inline-edit-field.js.map +1 -0
- package/dist/components/language-select.d.ts +130 -0
- package/dist/components/language-select.js +18 -0
- package/dist/components/language-select.js.map +1 -0
- package/dist/components/line-items.d.ts +25 -3
- package/dist/components/line-items.js +55 -46
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +25 -0
- package/dist/components/list.js +16 -5
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +32 -14
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.d.ts +52 -4
- package/dist/components/month-picker.js +291 -86
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +26 -1
- package/dist/components/multi-entity-combobox.js +143 -102
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +8 -1
- package/dist/components/multi-select.js +9 -4
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +34 -19
- package/dist/components/number-field.js +6 -20
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +79 -19
- package/dist/components/number-input.js +90 -38
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +19 -14
- package/dist/components/one-time-code-input.d.ts +100 -0
- package/dist/components/one-time-code-input.js +137 -0
- package/dist/components/one-time-code-input.js.map +1 -0
- package/dist/components/phone-input.d.ts +69 -0
- package/dist/components/phone-input.js +171 -0
- package/dist/components/phone-input.js.map +1 -0
- 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 +19 -14
- package/dist/components/settings-fields.d.ts +19 -14
- package/dist/components/share-card.d.ts +16 -11
- package/dist/components/sign-chip.d.ts +66 -0
- package/dist/components/sign-chip.js +39 -0
- package/dist/components/sign-chip.js.map +1 -0
- package/dist/components/swatch-picker.d.ts +43 -2
- package/dist/components/swatch-picker.js +80 -32
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/switch.d.ts +19 -0
- package/dist/components/switch.js +24 -6
- package/dist/components/switch.js.map +1 -1
- package/dist/components/text-link.d.ts +18 -13
- package/dist/components/tile-radio.d.ts +88 -13
- package/dist/components/tile-radio.js +28 -11
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +24 -15
- package/dist/components/time-input.js.map +1 -1
- package/dist/components/toggle-group.d.ts +47 -1
- package/dist/components/toggle-group.js +110 -69
- 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 +16 -11
- package/dist/components/ui.js +284 -88
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -2
- package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
- package/dist/data-table.d.ts +1 -2
- package/dist/data-table.js +2 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-attachment.js +348 -41
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +3 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +68 -253
- package/dist/feedback/feedback-thread.js +46 -10
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
- package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
- package/dist/feedback.d.ts +66 -2
- package/dist/hooks/use-file-drop.d.ts +19 -14
- package/dist/i18n/defaults.d.ts +19 -14
- package/dist/i18n/defaults.js +19 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +19 -14
- package/dist/i18n/german.js +84 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -10
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +19 -14
- package/dist/i18n/locales/de-CH.d.ts +19 -14
- package/dist/i18n/locales/en.d.ts +19 -14
- package/dist/i18n/locales/en.js +16 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +19 -14
- package/dist/i18n/locales/es.js +83 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +19 -14
- package/dist/i18n/locales/fr.js +82 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +19 -14
- package/dist/i18n/locales/hu.js +85 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +19 -14
- package/dist/i18n/locales/it.js +84 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +19 -14
- package/dist/i18n/locales/zh.js +80 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +19 -14
- package/dist/i18n/review.js +27 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +21 -10
- package/dist/index.js +73 -2
- 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/countries.d.ts +43 -0
- package/dist/lib/countries.js +39 -0
- package/dist/lib/countries.js.map +1 -0
- package/dist/lib/decimal-marks.d.ts +46 -0
- package/dist/lib/decimal-marks.js +40 -0
- package/dist/lib/decimal-marks.js.map +1 -0
- package/dist/lib/iban.d.ts +98 -0
- package/dist/lib/iban.js +154 -0
- package/dist/lib/iban.js.map +1 -0
- package/dist/lib/isin.d.ts +30 -0
- package/dist/lib/isin.js +36 -0
- package/dist/lib/isin.js.map +1 -0
- package/dist/lib/phone.d.ts +69 -0
- package/dist/lib/phone.js +121 -0
- package/dist/lib/phone.js.map +1 -0
- 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 +233 -18
- package/dist/rhf/fields.js +456 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +19 -14
- package/dist/rhf.d.ts +22 -15
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +18 -13
- package/dist/shell/top-bar-brand.d.ts +19 -14
- 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 +18 -13
- package/dist/table-text.d.ts +1 -1
- package/dist/tour/tour.js +20 -24
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +49 -16
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +19 -14
- package/package.json +1 -1
- package/src/components/account-chips.tsx +17 -3
- package/src/components/account-settings.tsx +92 -26
- package/src/components/amount-input.tsx +68 -56
- package/src/components/autocomplete.tsx +198 -114
- package/src/components/checkbox-group.tsx +229 -0
- package/src/components/checkbox.tsx +72 -8
- package/src/components/chip.tsx +80 -10
- package/src/components/column-mapper.tsx +666 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +661 -340
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +556 -0
- package/src/components/currency-select.tsx +109 -33
- package/src/components/danger-confirm.tsx +429 -82
- package/src/components/data-table-labels.ts +16 -1
- package/src/components/data-table.tsx +175 -6
- package/src/components/date-picker.tsx +595 -71
- package/src/components/entity-combobox.tsx +235 -124
- package/src/components/field-parts.tsx +403 -0
- package/src/components/field-strip.tsx +149 -0
- package/src/components/file-button.tsx +36 -3
- package/src/components/file-dropzone.tsx +106 -38
- package/src/components/form-actions.tsx +147 -6
- package/src/components/iban-input.tsx +231 -0
- package/src/components/icon-picker.tsx +47 -6
- package/src/components/inline-edit-field.tsx +533 -0
- package/src/components/language-select.tsx +78 -0
- package/src/components/line-items.tsx +35 -2
- package/src/components/list.tsx +55 -6
- package/src/components/money-field.tsx +13 -0
- package/src/components/month-picker.tsx +400 -63
- package/src/components/multi-entity-combobox.tsx +207 -120
- package/src/components/multi-select.tsx +80 -58
- package/src/components/number-field.tsx +28 -46
- package/src/components/number-input.tsx +210 -43
- package/src/components/one-time-code-input.tsx +288 -0
- package/src/components/phone-input.tsx +299 -0
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/sign-chip.tsx +103 -0
- package/src/components/swatch-picker.tsx +143 -31
- package/src/components/switch.tsx +58 -7
- package/src/components/tile-radio.tsx +145 -29
- package/src/components/time-input.tsx +8 -1
- package/src/components/toggle-group.tsx +206 -73
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +479 -138
- package/src/data-table.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +597 -46
- package/src/feedback/feedback-dialog.tsx +9 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +113 -9
- package/src/i18n/defaults.ts +18 -1
- package/src/i18n/german.ts +89 -0
- package/src/i18n/kit-labels.tsx +82 -2
- package/src/i18n/locales/en.ts +36 -4
- package/src/i18n/locales/es.ts +87 -0
- package/src/i18n/locales/fr.ts +87 -0
- package/src/i18n/locales/hu.ts +83 -0
- package/src/i18n/locales/it.ts +88 -0
- package/src/i18n/locales/zh.ts +76 -0
- package/src/i18n/review.ts +26 -0
- package/src/index.ts +71 -3
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/countries.ts +91 -0
- package/src/lib/decimal-marks.ts +99 -0
- package/src/lib/iban.ts +171 -0
- package/src/lib/isin.ts +62 -0
- package/src/lib/phone.ts +211 -0
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +860 -9
- package/src/rhf.ts +3 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/tour/tour.tsx +66 -28
- package/src/wizard/stepper-nav.tsx +35 -1
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { cn } from "../lib/cn";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ClipboardEvent as ReactClipboardEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type RefObject,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { Camera, FileText, Image as ImageIcon, Paperclip, X } from "lucide-react";
|
|
12
|
+
import { Button, IconButton, Spinner } from "../components/ui";
|
|
13
|
+
import { DEFAULT_FILE_PICKER_LABELS, formatAccept } from "../components/file-button";
|
|
14
|
+
import { useCommitReason } from "../components/write-lock";
|
|
5
15
|
import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
|
|
6
16
|
import type { FeedbackAttachmentLabels } from "./feedback-dialog";
|
|
7
17
|
|
|
@@ -43,6 +53,15 @@ export interface FeedbackAttachmentFieldLabels {
|
|
|
43
53
|
attachmentRemoveFile?: (name: string) => string;
|
|
44
54
|
/** `multiple` mode: the line shown in place of the add buttons once `max` is reached. */
|
|
45
55
|
attachmentLimit?: (max: number) => string;
|
|
56
|
+
/** `refs` mode (0.22.0, keksdose K16): the second line of a chip whose upload is still
|
|
57
|
+
* running, where an uploaded one shows its size — "Uploading…". Optional, like the
|
|
58
|
+
* keys above, so a `UiKitLabels` typed before it still compiles. */
|
|
59
|
+
attachmentUploading?: string;
|
|
60
|
+
/** `refs` mode (0.23.0, keksdose G5a): {@link FeedbackAttachmentErrorInfo.message} for
|
|
61
|
+
* an `"upload"` refusal — "“huge.png” could not be uploaded". The other refusals'
|
|
62
|
+
* sentences are the `filePicker` namespace's, shared with FileButton. Optional, like
|
|
63
|
+
* the keys above. */
|
|
64
|
+
attachmentUploadFailed?: (name: string) => string;
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {
|
|
@@ -55,18 +74,107 @@ export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels =
|
|
|
55
74
|
attachmentRemoveFile: (name) => `Remove ${name}`,
|
|
56
75
|
attachmentLimit: (max) =>
|
|
57
76
|
`Up to ${max} ${max === 1 ? "attachment" : "attachments"} — remove one to add another.`,
|
|
77
|
+
attachmentUploading: "Uploading…",
|
|
78
|
+
attachmentUploadFailed: (name) => `“${name}” could not be uploaded`,
|
|
58
79
|
};
|
|
59
80
|
|
|
60
|
-
/** Why a file was turned away. `"count"` only ever comes from `multiple`
|
|
61
|
-
* files arrived at once than `max` left room for, and the surplus was
|
|
81
|
+
/** Why a file was turned away. `"count"` only ever comes from `multiple` (and `refs`)
|
|
82
|
+
* mode: more files arrived at once than `max` left room for, and the surplus was
|
|
83
|
+
* dropped. */
|
|
62
84
|
export type FeedbackAttachmentError = "type" | "size" | "count";
|
|
63
85
|
|
|
86
|
+
/** `refs` mode's reasons: {@link FeedbackAttachmentError}, or `"upload"` — `onUpload`
|
|
87
|
+
* rejected, and what it rejected with is `onError`'s second argument. A type of its
|
|
88
|
+
* own so a host's exhaustive switch over the File modes' reasons stays exhaustive. */
|
|
89
|
+
export type FeedbackAttachmentRefsError = FeedbackAttachmentError | "upload";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What `onError` knows about a refusal beyond its kind (0.23.0, keksdose G5a): the file
|
|
93
|
+
* it was about, the limit that refused it, and a sentence that says both.
|
|
94
|
+
*
|
|
95
|
+
* keksdose's support chat toasted "File too large" with no name — the field's `onError`
|
|
96
|
+
* carried a kind and nothing else, so a refusal could not say WHICH of three pasted
|
|
97
|
+
* screenshots went, nor what the ceiling was, and its own `AttachmentPicker` (a
|
|
98
|
+
* FileButton, whose `onReject` hands over `{ file, message }`) said more than the kit's
|
|
99
|
+
* field would have. This is that, for every mode.
|
|
100
|
+
*
|
|
101
|
+
* It arrives as an EXTRA argument, after the ones `onError` already had, so a handler
|
|
102
|
+
* written for 0.22 — `(kind) => …`, or `refs` mode's `(kind, error) => …` — compiles
|
|
103
|
+
* and behaves as before:
|
|
104
|
+
*
|
|
105
|
+
* - single and `multiple`: `onError(kind, info)`;
|
|
106
|
+
* - `refs`: `onError(kind, error, info)` — the second place is `onUpload`'s rejection
|
|
107
|
+
* since 0.22 (`undefined` for the other kinds, as it always was), so the info comes
|
|
108
|
+
* third rather than changing what a 0.22 handler reads there. It also carries that
|
|
109
|
+
* `error`, so one helper `(kind, info) => …` serves all three modes.
|
|
110
|
+
*
|
|
111
|
+
* An object rather than more positional arguments because what a host reaches for
|
|
112
|
+
* differs by kind — the bytes for `"size"`, `max` for `"count"`, the server's answer for
|
|
113
|
+
* `"upload"` — and a field added later must not shift anyone's arguments.
|
|
114
|
+
*/
|
|
115
|
+
export interface FeedbackAttachmentErrorInfo {
|
|
116
|
+
/** The file refused — under the name the field would have given it (a pasted image
|
|
117
|
+
* is already "pasted.png"). For `"count"`, the first of the surplus; for
|
|
118
|
+
* `"upload"`, the file whose upload failed. */
|
|
119
|
+
file: File;
|
|
120
|
+
/** `"count"`: every file of the surplus (several can arrive in one pick). Otherwise
|
|
121
|
+
* `[file]`. */
|
|
122
|
+
files: File[];
|
|
123
|
+
/**
|
|
124
|
+
* One translated sentence naming the file and the limit — the ones FileButton's
|
|
125
|
+
* `onReject` hands over, from the same `filePicker` namespace: "“huge.png” is larger
|
|
126
|
+
* than 10 MB", "Only image/png, image/jpeg files", "“c.png” was not added: at most 5
|
|
127
|
+
* files" (several at once: "3 files were not added"). `"upload"`: the field's own
|
|
128
|
+
* `attachmentUploadFailed`, "“huge.png” could not be uploaded" — a host with a better
|
|
129
|
+
* sentence from the server's answer uses `error`. Ready for a toast as it is.
|
|
130
|
+
*/
|
|
131
|
+
message: string;
|
|
132
|
+
/** The field's `maxBytes` — the ceiling a `"size"` refusal went over, in bytes. */
|
|
133
|
+
maxBytes: number;
|
|
134
|
+
/** The field's `accept` — the types a `"type"` refusal was not among. */
|
|
135
|
+
accept: string[];
|
|
136
|
+
/** The field's `max` — the ceiling a `"count"` refusal hit. Absent in single mode,
|
|
137
|
+
* which holds one file by definition. */
|
|
138
|
+
max?: number;
|
|
139
|
+
/** `"upload"` only: what `onUpload` rejected with — `onError`'s second argument. */
|
|
140
|
+
error?: unknown;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One uploaded attachment in `refs` mode: what the host's upload answered with. `key`
|
|
145
|
+
* identifies it (a storage key, an upload id) and is what a remove goes by; `name` is
|
|
146
|
+
* what the chip says. `size` (bytes) and `type` (mime) are optional — given, the chip
|
|
147
|
+
* shows the size and an image icon or a file icon accordingly.
|
|
148
|
+
*/
|
|
149
|
+
export interface FeedbackAttachmentRef {
|
|
150
|
+
key: string;
|
|
151
|
+
name: string;
|
|
152
|
+
size?: number;
|
|
153
|
+
type?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
64
156
|
interface FeedbackAttachmentFieldBaseProps {
|
|
65
157
|
/** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
|
|
66
158
|
* since 0.7.0; `attachment` (the heading) is only ever read from here. */
|
|
67
|
-
labels?: Partial<FeedbackAttachmentLabels
|
|
159
|
+
labels?: Partial<FeedbackAttachmentLabels> &
|
|
160
|
+
Pick<Partial<FeedbackAttachmentFieldLabels>, "attachmentUploading" | "attachmentUploadFailed">;
|
|
68
161
|
accept?: string[];
|
|
69
162
|
maxBytes?: number;
|
|
163
|
+
/**
|
|
164
|
+
* Take no files for now (0.23.0, keksdose G5b): the add and capture buttons, the
|
|
165
|
+
* hidden file input and every chip's remove button are natively disabled, and a paste
|
|
166
|
+
* is let through untouched. The chips stay as they are — what is about to be sent
|
|
167
|
+
* stays readable.
|
|
168
|
+
*
|
|
169
|
+
* For the moment a send is in flight. keksdose's support picker is `disabled` while
|
|
170
|
+
* the message goes out, because the message carries the attachments AS THEY WERE at
|
|
171
|
+
* the press: a chip removed mid-send has gone with it all the same, and one added
|
|
172
|
+
* mid-send looks as if it had. The field has no drop target; the paste is its drop.
|
|
173
|
+
*
|
|
174
|
+
* This is a pause, not a lock that explains itself — for that, `refs` mode (the mode
|
|
175
|
+
* that commits) takes `commit` and `disabledReason`.
|
|
176
|
+
*/
|
|
177
|
+
disabled?: boolean;
|
|
70
178
|
/** Snapshot the app view behind this and return it as a File. A "Capture
|
|
71
179
|
* screenshot" button appears only when it is given. */
|
|
72
180
|
onCaptureScreenshot?: () => Promise<File | null>;
|
|
@@ -84,13 +192,19 @@ interface FeedbackAttachmentFieldBaseProps {
|
|
|
84
192
|
/** One file: choosing a second means removing the first. The field's only mode until 0.15.5. */
|
|
85
193
|
export interface FeedbackAttachmentFieldSingleProps extends FeedbackAttachmentFieldBaseProps {
|
|
86
194
|
multiple?: false;
|
|
195
|
+
refs?: false;
|
|
87
196
|
value: File | null;
|
|
88
197
|
onChange: (file: File | null) => void;
|
|
89
|
-
|
|
198
|
+
/** A file was refused. `info` (0.23.0) names it and the limit — see
|
|
199
|
+
* {@link FeedbackAttachmentErrorInfo}. */
|
|
200
|
+
onError?: (kind: "type" | "size", info: FeedbackAttachmentErrorInfo) => void;
|
|
90
201
|
// Multiple-mode props, refused here: without `multiple` they would be silently ignored.
|
|
91
202
|
max?: never;
|
|
92
203
|
screenshot?: never;
|
|
93
204
|
onScreenshotChange?: never;
|
|
205
|
+
// `refs` mode's lock: a staged File is not a commit (see the refs props).
|
|
206
|
+
commit?: never;
|
|
207
|
+
disabledReason?: never;
|
|
94
208
|
}
|
|
95
209
|
|
|
96
210
|
/**
|
|
@@ -100,13 +214,16 @@ export interface FeedbackAttachmentFieldSingleProps extends FeedbackAttachmentFi
|
|
|
100
214
|
*/
|
|
101
215
|
export interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachmentFieldBaseProps {
|
|
102
216
|
multiple: true;
|
|
217
|
+
refs?: false;
|
|
103
218
|
value: File[];
|
|
104
219
|
onChange: (files: File[]) => void;
|
|
105
220
|
/** How many files `value` may hold. Default {@link DEFAULT_MAX_ATTACHMENTS} (5). The
|
|
106
221
|
* add buttons and the paste hint are offered only while there is room. */
|
|
107
222
|
max?: number;
|
|
108
|
-
/** Per file: a rejected one is reported and the others are still added.
|
|
109
|
-
|
|
223
|
+
/** Per file: a rejected one is reported and the others are still added. `"count"` is
|
|
224
|
+
* once per pick, with the whole surplus in `info.files`. `info` (0.23.0): see
|
|
225
|
+
* {@link FeedbackAttachmentErrorInfo}. */
|
|
226
|
+
onError?: (kind: FeedbackAttachmentError, info: FeedbackAttachmentErrorInfo) => void;
|
|
110
227
|
/**
|
|
111
228
|
* A separate slot for THE screenshot, outside `value` and `max`. Given
|
|
112
229
|
* `onScreenshotChange`, what `onCaptureScreenshot` returns lands here instead of in
|
|
@@ -117,11 +234,76 @@ export interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachment
|
|
|
117
234
|
*/
|
|
118
235
|
screenshot?: File | null;
|
|
119
236
|
onScreenshotChange?: (file: File | null) => void;
|
|
237
|
+
// `refs` mode's lock: a staged File is not a commit (see the refs props).
|
|
238
|
+
commit?: never;
|
|
239
|
+
disabledReason?: never;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Uploaded on pick (0.22.0, keksdose K16). keksdose's support chat uploads each file
|
|
244
|
+
* the moment it is chosen and sends the message with the REFS the uploads answered —
|
|
245
|
+
* a 10 MB screenshot is refused while the reporter is still typing, not when they
|
|
246
|
+
* press Send — so its `AttachmentPicker` could not use this field, whose value is
|
|
247
|
+
* `File`s held until the form is submitted. Here the value is
|
|
248
|
+
* {@link FeedbackAttachmentRef}s: every way in (pick, paste, capture) validates the
|
|
249
|
+
* file as the other modes do, then hands it to `onUpload` and shows a chip with a
|
|
250
|
+
* spinner until it answers; the ref it resolves to joins `value` through `onChange`.
|
|
251
|
+
* A rejection drops the chip and reports `onError("upload", error)`. A chip is removed
|
|
252
|
+
* BY KEY: `onChange` gets the list without it, and `onRemove` the key, for a host that
|
|
253
|
+
* deletes the upload on the server too.
|
|
254
|
+
*
|
|
255
|
+
* Uploads still running count against `max`, so five picks in quick succession cannot
|
|
256
|
+
* overshoot it; they are not in `value` until they land, so a send that reads `value`
|
|
257
|
+
* sends only what has arrived — hold Send back while {@link onUploadingChange} says
|
|
258
|
+
* an upload is running. Several files upload side by side and join in the order they
|
|
259
|
+
* finish. The File modes are unchanged.
|
|
260
|
+
*/
|
|
261
|
+
export interface FeedbackAttachmentFieldRefsProps extends FeedbackAttachmentFieldBaseProps {
|
|
262
|
+
refs: true;
|
|
263
|
+
multiple?: never;
|
|
264
|
+
value: FeedbackAttachmentRef[];
|
|
265
|
+
onChange: (refs: FeedbackAttachmentRef[]) => void;
|
|
266
|
+
/** Upload one file; resolve to its ref, or reject to refuse it. Called once per
|
|
267
|
+
* accepted file, the moment it is chosen. */
|
|
268
|
+
onUpload: (file: File) => Promise<FeedbackAttachmentRef>;
|
|
269
|
+
/** A chip was removed — after `onChange` — with its ref's key. */
|
|
270
|
+
onRemove?: (key: string) => void;
|
|
271
|
+
/** Whether any upload is running: `true` when the first starts, `false` when the
|
|
272
|
+
* last settles — for the host's Send, which should wait. */
|
|
273
|
+
onUploadingChange?: (uploading: boolean) => void;
|
|
274
|
+
/** How many refs `value` may hold, uploads in flight included. Default
|
|
275
|
+
* {@link DEFAULT_MAX_ATTACHMENTS} (5). */
|
|
276
|
+
max?: number;
|
|
277
|
+
/** Per file. `"upload"` carries what `onUpload` rejected with as `error` (`undefined`
|
|
278
|
+
* for the other kinds). `info` (0.23.0) comes third so a 0.22 handler's `error` is
|
|
279
|
+
* still the second argument — see {@link FeedbackAttachmentErrorInfo}. */
|
|
280
|
+
onError?: (kind: FeedbackAttachmentRefsError, error: unknown, info: FeedbackAttachmentErrorInfo) => void;
|
|
281
|
+
/**
|
|
282
|
+
* This field COMMITS (0.23.0): each file is uploaded the moment it is chosen, and a
|
|
283
|
+
* remove may delete the upload (`onRemove`) — a save, unlike the File modes, which
|
|
284
|
+
* only stage a file for a form sent later (and so stay open under a lock, like every
|
|
285
|
+
* field). Under a locked {@link WriteLockProvider} it takes the `disabledReason` path
|
|
286
|
+
* with the lock's reason, which wins over one of its own. No provider, or an unlocked
|
|
287
|
+
* one: no effect.
|
|
288
|
+
*/
|
|
289
|
+
commit?: boolean;
|
|
290
|
+
/**
|
|
291
|
+
* Why no file can be added or removed — the write lock's sentence, a quota, a thread
|
|
292
|
+
* closed to replies. Unlike `disabled`, it SAYS so, the kit's commit-control way: the
|
|
293
|
+
* buttons (add, capture, every chip's remove) stay focusable, `aria-disabled`, with
|
|
294
|
+
* the reason in the kit Tooltip and their description; no pick, paste or remove gets
|
|
295
|
+
* through; and the reason stands where the paste hint was, for a phone that never
|
|
296
|
+
* hovers. The chips stay readable.
|
|
297
|
+
*/
|
|
298
|
+
disabledReason?: ReactNode;
|
|
299
|
+
screenshot?: never;
|
|
300
|
+
onScreenshotChange?: never;
|
|
120
301
|
}
|
|
121
302
|
|
|
122
303
|
export type FeedbackAttachmentFieldProps =
|
|
123
304
|
| FeedbackAttachmentFieldSingleProps
|
|
124
|
-
| FeedbackAttachmentFieldMultipleProps
|
|
305
|
+
| FeedbackAttachmentFieldMultipleProps
|
|
306
|
+
| FeedbackAttachmentFieldRefsProps;
|
|
125
307
|
|
|
126
308
|
/**
|
|
127
309
|
* Picking one picture: the file dialog, a capture of the app view, or a paste.
|
|
@@ -154,9 +336,10 @@ export type FeedbackAttachmentFieldProps =
|
|
|
154
336
|
* handed in by whoever renders both the box and this field side by side.
|
|
155
337
|
*/
|
|
156
338
|
export function FeedbackAttachmentField(props: FeedbackAttachmentFieldProps) {
|
|
157
|
-
//
|
|
158
|
-
// (one preview vs. one per chip), and a hook order that
|
|
159
|
-
// crash the day a caller flips it.
|
|
339
|
+
// One component per mode rather than one with branches: the modes hold different
|
|
340
|
+
// state (one preview vs. one per chip vs. uploads in flight), and a hook order that
|
|
341
|
+
// depends on a prop is a crash the day a caller flips it.
|
|
342
|
+
if (props.refs) return <RefsField {...props} />;
|
|
160
343
|
return props.multiple ? <MultipleField {...props} /> : <SingleField {...props} />;
|
|
161
344
|
}
|
|
162
345
|
|
|
@@ -170,6 +353,7 @@ function SingleField({
|
|
|
170
353
|
onCaptureScreenshot,
|
|
171
354
|
documentPaste = false,
|
|
172
355
|
pasteFrom,
|
|
356
|
+
disabled = false,
|
|
173
357
|
className,
|
|
174
358
|
}: FeedbackAttachmentFieldSingleProps) {
|
|
175
359
|
// The object URL is keyed to the file it was made for, so a stale one (from the
|
|
@@ -180,6 +364,7 @@ function SingleField({
|
|
|
180
364
|
// `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.
|
|
181
365
|
const fileText = useKitFileLabels();
|
|
182
366
|
const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
|
|
367
|
+
const errorInfo = useErrorInfo(accept, maxBytes, undefined, text);
|
|
183
368
|
|
|
184
369
|
// Object-URL preview lifecycle (create on change, revoke on cleanup).
|
|
185
370
|
useEffect(() => {
|
|
@@ -197,7 +382,7 @@ function SingleField({
|
|
|
197
382
|
if (!file) return;
|
|
198
383
|
const problem = rejection(file, accept, maxBytes);
|
|
199
384
|
if (problem) {
|
|
200
|
-
onError?.(problem);
|
|
385
|
+
onError?.(problem, errorInfo(problem, [file]));
|
|
201
386
|
return;
|
|
202
387
|
}
|
|
203
388
|
onChange(file);
|
|
@@ -213,6 +398,7 @@ function SingleField({
|
|
|
213
398
|
},
|
|
214
399
|
documentPaste,
|
|
215
400
|
pasteFrom,
|
|
401
|
+
disabled,
|
|
216
402
|
);
|
|
217
403
|
|
|
218
404
|
return (
|
|
@@ -246,23 +432,26 @@ function SingleField({
|
|
|
246
432
|
{fileText.size(value.size)}
|
|
247
433
|
</div>
|
|
248
434
|
</div>
|
|
249
|
-
<
|
|
250
|
-
type="button"
|
|
251
|
-
onClick={() => onChange(null)}
|
|
252
|
-
aria-label={text.attachmentRemove}
|
|
253
|
-
className="rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
|
|
254
|
-
>
|
|
255
|
-
<X className="size-4" />
|
|
256
|
-
</button>
|
|
435
|
+
<RemoveButton label={text.attachmentRemove} onClick={() => onChange(null)} disabled={disabled} />
|
|
257
436
|
</div>
|
|
258
437
|
) : (
|
|
259
438
|
<div className="space-y-1.5">
|
|
260
439
|
<div className="flex flex-wrap gap-2">
|
|
261
|
-
<Button
|
|
440
|
+
<Button
|
|
441
|
+
type="button"
|
|
442
|
+
variant="secondary"
|
|
443
|
+
disabled={disabled}
|
|
444
|
+
onClick={() => fileInputRef.current?.click()}
|
|
445
|
+
>
|
|
262
446
|
<Paperclip className="size-4" /> {text.attachmentAdd}
|
|
263
447
|
</Button>
|
|
264
448
|
{onCaptureScreenshot && (
|
|
265
|
-
<CaptureButton
|
|
449
|
+
<CaptureButton
|
|
450
|
+
capture={onCaptureScreenshot}
|
|
451
|
+
onFile={pick}
|
|
452
|
+
label={text.attachmentCapture}
|
|
453
|
+
disabled={disabled}
|
|
454
|
+
/>
|
|
266
455
|
)}
|
|
267
456
|
</div>
|
|
268
457
|
{/* Said out loud, because a gesture with no affordance is a gesture
|
|
@@ -277,6 +466,7 @@ function SingleField({
|
|
|
277
466
|
type="file"
|
|
278
467
|
accept={accept.join(",")}
|
|
279
468
|
className="sr-only"
|
|
469
|
+
disabled={disabled}
|
|
280
470
|
onChange={(e) => {
|
|
281
471
|
pick(e.target.files?.[0]);
|
|
282
472
|
e.target.value = "";
|
|
@@ -299,6 +489,7 @@ function MultipleField({
|
|
|
299
489
|
onCaptureScreenshot,
|
|
300
490
|
documentPaste = false,
|
|
301
491
|
pasteFrom,
|
|
492
|
+
disabled = false,
|
|
302
493
|
className,
|
|
303
494
|
}: FeedbackAttachmentFieldMultipleProps) {
|
|
304
495
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
@@ -308,6 +499,7 @@ function MultipleField({
|
|
|
308
499
|
const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
|
|
309
500
|
const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
|
|
310
501
|
const screenshotLabel = text.attachmentScreenshot ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentScreenshot!;
|
|
502
|
+
const errorInfo = useErrorInfo(accept, maxBytes, max, text);
|
|
311
503
|
|
|
312
504
|
// What an add or a remove builds on: the `value` prop, plus whatever this field has
|
|
313
505
|
// already handed to `onChange` since the parent last rendered. The field is
|
|
@@ -336,27 +528,28 @@ function MultipleField({
|
|
|
336
528
|
const accepted: File[] = [];
|
|
337
529
|
const taken = new Set([...current, ...(screenshot ? [screenshot] : [])].map((f) => f.name));
|
|
338
530
|
for (const raw of incoming) {
|
|
339
|
-
const problem = rejection(raw, accept, maxBytes);
|
|
340
|
-
if (problem) {
|
|
341
|
-
onError?.(problem);
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
531
|
// Two pastes are two files called "pasted.png", and a backend that stores by
|
|
345
532
|
// 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.
|
|
533
|
+
// file keeps its own name: it is the user's, and they may look for it. Named
|
|
534
|
+
// before the check, so a refusal names it as the single field's does.
|
|
347
535
|
const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
|
|
536
|
+
const problem = rejection(file, accept, maxBytes);
|
|
537
|
+
if (problem) {
|
|
538
|
+
onError?.(problem, errorInfo(problem, [file]));
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
348
541
|
taken.add(file.name);
|
|
349
542
|
// The same File object twice is one file chosen twice, not two.
|
|
350
543
|
if (!current.includes(file)) accepted.push(file);
|
|
351
544
|
}
|
|
352
545
|
if (accepted.length === 0) return;
|
|
353
546
|
const space = Math.max(0, max - current.length);
|
|
354
|
-
if (accepted.length > space) onError?.("count");
|
|
547
|
+
if (accepted.length > space) onError?.("count", errorInfo("count", accepted.slice(space)));
|
|
355
548
|
const kept = accepted.slice(0, space);
|
|
356
549
|
if (kept.length > 0) commit([...current, ...kept]);
|
|
357
550
|
};
|
|
358
551
|
|
|
359
|
-
const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
|
|
552
|
+
const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom, disabled);
|
|
360
553
|
|
|
361
554
|
const capture = (file: File) => {
|
|
362
555
|
if (!hasSlot) {
|
|
@@ -364,7 +557,7 @@ function MultipleField({
|
|
|
364
557
|
return;
|
|
365
558
|
}
|
|
366
559
|
const problem = rejection(file, accept, maxBytes);
|
|
367
|
-
if (problem) onError?.(problem);
|
|
560
|
+
if (problem) onError?.(problem, errorInfo(problem, [file]));
|
|
368
561
|
else onScreenshotChange(file);
|
|
369
562
|
};
|
|
370
563
|
|
|
@@ -420,18 +613,14 @@ function MultipleField({
|
|
|
420
613
|
{chip.screenshot ? `${chip.file.name} · ${fileText.size(chip.file.size)}` : fileText.size(chip.file.size)}
|
|
421
614
|
</div>
|
|
422
615
|
</div>
|
|
423
|
-
<
|
|
424
|
-
|
|
425
|
-
|
|
616
|
+
<RemoveButton
|
|
617
|
+
label={removeLabel(chip.name)}
|
|
618
|
+
disabled={disabled}
|
|
426
619
|
onClick={() => {
|
|
427
620
|
pendingFocus.current = index;
|
|
428
621
|
chip.remove();
|
|
429
622
|
}}
|
|
430
|
-
|
|
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>
|
|
623
|
+
/>
|
|
435
624
|
</li>
|
|
436
625
|
))}
|
|
437
626
|
</ul>
|
|
@@ -443,6 +632,7 @@ function MultipleField({
|
|
|
443
632
|
type="button"
|
|
444
633
|
variant="secondary"
|
|
445
634
|
data-attachment-action=""
|
|
635
|
+
disabled={disabled}
|
|
446
636
|
onClick={() => fileInputRef.current?.click()}
|
|
447
637
|
>
|
|
448
638
|
<Paperclip className="size-4" /> {text.attachmentAdd}
|
|
@@ -453,6 +643,7 @@ function MultipleField({
|
|
|
453
643
|
capture={onCaptureScreenshot}
|
|
454
644
|
onFile={capture}
|
|
455
645
|
label={text.attachmentCapture}
|
|
646
|
+
disabled={disabled}
|
|
456
647
|
data-attachment-action=""
|
|
457
648
|
/>
|
|
458
649
|
)}
|
|
@@ -471,6 +662,255 @@ function MultipleField({
|
|
|
471
662
|
// the way in, and a second, unlabelled "Choose files" stop is noise.
|
|
472
663
|
tabIndex={-1}
|
|
473
664
|
aria-hidden
|
|
665
|
+
disabled={disabled}
|
|
666
|
+
onChange={(e) => {
|
|
667
|
+
add(Array.from(e.target.files ?? []));
|
|
668
|
+
e.target.value = "";
|
|
669
|
+
}}
|
|
670
|
+
/>
|
|
671
|
+
</div>
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** A file `refs` mode is uploading: its own id (a ref has no key yet), and the file. */
|
|
676
|
+
interface Upload {
|
|
677
|
+
id: number;
|
|
678
|
+
file: File;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
let nextUploadId = 0;
|
|
682
|
+
|
|
683
|
+
function RefsField({
|
|
684
|
+
value,
|
|
685
|
+
onChange,
|
|
686
|
+
onUpload,
|
|
687
|
+
onRemove,
|
|
688
|
+
onUploadingChange,
|
|
689
|
+
max = DEFAULT_MAX_ATTACHMENTS,
|
|
690
|
+
labels: labelsProp,
|
|
691
|
+
accept = DEFAULT_ATTACHMENT_ACCEPT,
|
|
692
|
+
maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
693
|
+
onError,
|
|
694
|
+
onCaptureScreenshot,
|
|
695
|
+
documentPaste = false,
|
|
696
|
+
pasteFrom,
|
|
697
|
+
disabled = false,
|
|
698
|
+
commit: commits,
|
|
699
|
+
disabledReason: ownDisabledReason,
|
|
700
|
+
className,
|
|
701
|
+
}: FeedbackAttachmentFieldRefsProps) {
|
|
702
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
703
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
704
|
+
const fileText = useKitFileLabels();
|
|
705
|
+
const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
|
|
706
|
+
const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
|
|
707
|
+
const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
|
|
708
|
+
const uploadingLabel = text.attachmentUploading ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentUploading!;
|
|
709
|
+
const errorInfo = useErrorInfo(accept, maxBytes, max, text);
|
|
710
|
+
// The lock's reason (under `commit`) over the field's own; with either, the controls
|
|
711
|
+
// take the focusable `aria-disabled` path and say why — Button's and IconButton's
|
|
712
|
+
// `disabledReason`, handed down as is.
|
|
713
|
+
const disabledReason = useCommitReason(commits, ownDisabledReason);
|
|
714
|
+
const locked = hasReason(disabledReason);
|
|
715
|
+
const reason = locked ? disabledReason : undefined;
|
|
716
|
+
const inert = disabled || locked;
|
|
717
|
+
|
|
718
|
+
// As in `multiple` mode: `value` plus whatever has been handed to `onChange` since
|
|
719
|
+
// the parent last rendered — two uploads landing in one tick must both survive.
|
|
720
|
+
const latest = useRef(value);
|
|
721
|
+
useLayoutEffect(() => {
|
|
722
|
+
latest.current = value;
|
|
723
|
+
});
|
|
724
|
+
const commit = (next: FeedbackAttachmentRef[]) => {
|
|
725
|
+
latest.current = next;
|
|
726
|
+
onChange(next);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const [uploads, setUploads] = useState<Upload[]>([]);
|
|
730
|
+
// The uploads in flight, kept in a ref as well: `add` runs several times before a
|
|
731
|
+
// render when files arrive together, and each must see the others — their count
|
|
732
|
+
// against `max`, their names against a pasted image's.
|
|
733
|
+
const inFlight = useRef<Upload[]>([]);
|
|
734
|
+
// Unmounted (the dialog closed mid-upload): a late answer is not this field's to
|
|
735
|
+
// commit, and setting state on it would be a no-op at best.
|
|
736
|
+
const mounted = useRef(true);
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
mounted.current = true;
|
|
739
|
+
return () => {
|
|
740
|
+
mounted.current = false;
|
|
741
|
+
};
|
|
742
|
+
}, []);
|
|
743
|
+
const uploadingChange = useRef(onUploadingChange);
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
uploadingChange.current = onUploadingChange;
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const room = Math.max(0, max - value.length - uploads.length);
|
|
749
|
+
|
|
750
|
+
const start = (file: File) => {
|
|
751
|
+
const upload: Upload = { id: nextUploadId++, file };
|
|
752
|
+
inFlight.current = [...inFlight.current, upload];
|
|
753
|
+
if (inFlight.current.length === 1) uploadingChange.current?.(true);
|
|
754
|
+
setUploads(inFlight.current);
|
|
755
|
+
const settle = () => {
|
|
756
|
+
inFlight.current = inFlight.current.filter((u) => u.id !== upload.id);
|
|
757
|
+
if (!mounted.current) return;
|
|
758
|
+
setUploads(inFlight.current);
|
|
759
|
+
if (inFlight.current.length === 0) uploadingChange.current?.(false);
|
|
760
|
+
};
|
|
761
|
+
let answer: Promise<FeedbackAttachmentRef>;
|
|
762
|
+
try {
|
|
763
|
+
answer = Promise.resolve(onUpload(file));
|
|
764
|
+
} catch (error) {
|
|
765
|
+
answer = Promise.reject(error);
|
|
766
|
+
}
|
|
767
|
+
answer.then(
|
|
768
|
+
(ref) => {
|
|
769
|
+
settle();
|
|
770
|
+
if (mounted.current) commit([...latest.current, ref]);
|
|
771
|
+
},
|
|
772
|
+
(error: unknown) => {
|
|
773
|
+
settle();
|
|
774
|
+
if (mounted.current) onError?.("upload", error, errorInfo("upload", [file], error));
|
|
775
|
+
},
|
|
776
|
+
);
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const add = (incoming: File[], pasted = false) => {
|
|
780
|
+
let space = Math.max(0, max - latest.current.length - inFlight.current.length);
|
|
781
|
+
const taken = new Set([...latest.current.map((r) => r.name), ...inFlight.current.map((u) => u.file.name)]);
|
|
782
|
+
const dropped: File[] = [];
|
|
783
|
+
for (const raw of incoming) {
|
|
784
|
+
// Pasted images are all "image.png" to the clipboard; as in `multiple` mode,
|
|
785
|
+
// each gets a name of its own before it is checked and uploaded under it.
|
|
786
|
+
const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
|
|
787
|
+
const problem = rejection(file, accept, maxBytes);
|
|
788
|
+
if (problem) {
|
|
789
|
+
// `undefined` in `error`'s place, as in 0.22: only an upload has one.
|
|
790
|
+
onError?.(problem, undefined, errorInfo(problem, [file]));
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
taken.add(file.name);
|
|
794
|
+
if (space === 0) {
|
|
795
|
+
dropped.push(file);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
space -= 1;
|
|
799
|
+
start(file);
|
|
800
|
+
}
|
|
801
|
+
if (dropped.length > 0) onError?.("count", undefined, errorInfo("count", dropped));
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom, inert);
|
|
805
|
+
|
|
806
|
+
// Focus after a remove, as in `multiple` mode: the next chip, else the first action.
|
|
807
|
+
const pendingFocus = useRef<number | null>(null);
|
|
808
|
+
useEffect(() => {
|
|
809
|
+
const index = pendingFocus.current;
|
|
810
|
+
if (index === null) return;
|
|
811
|
+
pendingFocus.current = null;
|
|
812
|
+
const root = rootRef.current;
|
|
813
|
+
if (!root) return;
|
|
814
|
+
const removes = root.querySelectorAll<HTMLElement>("[data-attachment-remove]");
|
|
815
|
+
const target =
|
|
816
|
+
removes[index] ?? root.querySelector<HTMLElement>("[data-attachment-action]") ?? removes[index - 1];
|
|
817
|
+
target?.focus();
|
|
818
|
+
}, [value]);
|
|
819
|
+
|
|
820
|
+
const remove = (key: string, index: number) => {
|
|
821
|
+
if (inert) return;
|
|
822
|
+
pendingFocus.current = index;
|
|
823
|
+
commit(latest.current.filter((r) => r.key !== key));
|
|
824
|
+
onRemove?.(key);
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const hasChips = value.length > 0 || uploads.length > 0;
|
|
828
|
+
|
|
829
|
+
return (
|
|
830
|
+
<div ref={rootRef} className={cn("relative space-y-2", className)} onPaste={onPaste}>
|
|
831
|
+
<Heading text={labelsProp?.attachment} className="mb-0" />
|
|
832
|
+
{hasChips && (
|
|
833
|
+
<ul aria-label={text.attachmentList} className="flex flex-col gap-2">
|
|
834
|
+
{value.map((ref, index) => (
|
|
835
|
+
<li
|
|
836
|
+
key={`ref:${ref.key}`}
|
|
837
|
+
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-1"
|
|
838
|
+
>
|
|
839
|
+
<RefGlyph type={ref.type} />
|
|
840
|
+
<div className="min-w-0 flex-1">
|
|
841
|
+
<div className="truncate text-sm text-[var(--text-secondary)]">{ref.name}</div>
|
|
842
|
+
{ref.size !== undefined && (
|
|
843
|
+
<div className="truncate text-xs text-[var(--text-muted)]">{fileText.size(ref.size)}</div>
|
|
844
|
+
)}
|
|
845
|
+
</div>
|
|
846
|
+
<RemoveButton
|
|
847
|
+
label={removeLabel(ref.name)}
|
|
848
|
+
onClick={() => remove(ref.key, index)}
|
|
849
|
+
disabled={disabled}
|
|
850
|
+
reason={reason}
|
|
851
|
+
/>
|
|
852
|
+
</li>
|
|
853
|
+
))}
|
|
854
|
+
{uploads.map((upload) => (
|
|
855
|
+
<li
|
|
856
|
+
key={`upload:${upload.id}`}
|
|
857
|
+
aria-busy="true"
|
|
858
|
+
data-attachment-uploading=""
|
|
859
|
+
className="flex min-w-0 items-center gap-2 rounded-md border border-dashed border-[var(--border)] bg-[var(--bg-surface)] p-1"
|
|
860
|
+
>
|
|
861
|
+
<span className="flex size-12 shrink-0 items-center justify-center rounded border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-placeholder)]">
|
|
862
|
+
<Spinner label={null} className="size-5" />
|
|
863
|
+
</span>
|
|
864
|
+
<div className="min-w-0 flex-1">
|
|
865
|
+
<div className="truncate text-sm text-[var(--text-secondary)]">{upload.file.name}</div>
|
|
866
|
+
<div className="truncate text-xs text-[var(--text-muted)]">{uploadingLabel}</div>
|
|
867
|
+
</div>
|
|
868
|
+
</li>
|
|
869
|
+
))}
|
|
870
|
+
</ul>
|
|
871
|
+
)}
|
|
872
|
+
{room > 0 && (
|
|
873
|
+
<div className="flex flex-wrap gap-2">
|
|
874
|
+
<Button
|
|
875
|
+
type="button"
|
|
876
|
+
variant="secondary"
|
|
877
|
+
data-attachment-action=""
|
|
878
|
+
disabled={disabled}
|
|
879
|
+
disabledReason={reason}
|
|
880
|
+
onClick={() => fileInputRef.current?.click()}
|
|
881
|
+
>
|
|
882
|
+
<Paperclip className="size-4" /> {text.attachmentAdd}
|
|
883
|
+
</Button>
|
|
884
|
+
{onCaptureScreenshot && (
|
|
885
|
+
<CaptureButton
|
|
886
|
+
capture={onCaptureScreenshot}
|
|
887
|
+
onFile={(file) => add([file])}
|
|
888
|
+
label={text.attachmentCapture}
|
|
889
|
+
disabled={disabled}
|
|
890
|
+
disabledReason={reason}
|
|
891
|
+
data-attachment-action=""
|
|
892
|
+
/>
|
|
893
|
+
)}
|
|
894
|
+
</div>
|
|
895
|
+
)}
|
|
896
|
+
{locked ? (
|
|
897
|
+
// Where the paste hint was: a phone never hovers, so the Tooltip alone would
|
|
898
|
+
// leave a touch user with buttons that do nothing and no word why.
|
|
899
|
+
<div data-attachment-reason="" className="text-xs text-[var(--text-muted)]">
|
|
900
|
+
{disabledReason}
|
|
901
|
+
</div>
|
|
902
|
+
) : (
|
|
903
|
+
<p className="text-xs text-[var(--text-muted)]">{room > 0 ? text.attachmentPaste : limitLabel(max)}</p>
|
|
904
|
+
)}
|
|
905
|
+
<input
|
|
906
|
+
ref={fileInputRef}
|
|
907
|
+
type="file"
|
|
908
|
+
multiple={room > 1}
|
|
909
|
+
accept={accept.join(",")}
|
|
910
|
+
className="sr-only"
|
|
911
|
+
tabIndex={-1}
|
|
912
|
+
aria-hidden
|
|
913
|
+
disabled={inert}
|
|
474
914
|
onChange={(e) => {
|
|
475
915
|
add(Array.from(e.target.files ?? []));
|
|
476
916
|
e.target.value = "";
|
|
@@ -480,6 +920,16 @@ function MultipleField({
|
|
|
480
920
|
);
|
|
481
921
|
}
|
|
482
922
|
|
|
923
|
+
/** A ref chip's picture. The bytes are on the server, not here, so there is nothing to
|
|
924
|
+
* preview: an image glyph for an image `type`, a file glyph for anything else. */
|
|
925
|
+
function RefGlyph({ type }: { type?: string }) {
|
|
926
|
+
return (
|
|
927
|
+
<span className="flex size-12 shrink-0 items-center justify-center rounded border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-placeholder)]">
|
|
928
|
+
{type?.startsWith("image/") ? <ImageIcon aria-hidden className="size-6" /> : <FileText aria-hidden className="size-6" />}
|
|
929
|
+
</span>
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
483
933
|
function Heading({ text, className }: { text?: string; className?: string }) {
|
|
484
934
|
if (!text) return null;
|
|
485
935
|
return (
|
|
@@ -493,11 +943,15 @@ function CaptureButton({
|
|
|
493
943
|
capture,
|
|
494
944
|
onFile,
|
|
495
945
|
label,
|
|
946
|
+
disabled = false,
|
|
947
|
+
disabledReason,
|
|
496
948
|
...rest
|
|
497
949
|
}: {
|
|
498
950
|
capture: () => Promise<File | null>;
|
|
499
951
|
onFile: (file: File) => void;
|
|
500
952
|
label: string;
|
|
953
|
+
disabled?: boolean;
|
|
954
|
+
disabledReason?: ReactNode;
|
|
501
955
|
"data-attachment-action"?: string;
|
|
502
956
|
}) {
|
|
503
957
|
const [capturing, setCapturing] = useState(false);
|
|
@@ -505,10 +959,11 @@ function CaptureButton({
|
|
|
505
959
|
<Button
|
|
506
960
|
type="button"
|
|
507
961
|
variant="secondary"
|
|
508
|
-
disabled={capturing}
|
|
962
|
+
disabled={capturing || disabled}
|
|
963
|
+
disabledReason={disabledReason}
|
|
509
964
|
{...rest}
|
|
510
965
|
onClick={() => {
|
|
511
|
-
if (capturing) return;
|
|
966
|
+
if (capturing || disabled) return;
|
|
512
967
|
setCapturing(true);
|
|
513
968
|
void capture()
|
|
514
969
|
.then((file) => {
|
|
@@ -522,6 +977,93 @@ function CaptureButton({
|
|
|
522
977
|
);
|
|
523
978
|
}
|
|
524
979
|
|
|
980
|
+
/**
|
|
981
|
+
* A chip's (or the single file's) remove button: the kit's IconButton at the 28px the
|
|
982
|
+
* hand-built one had, so `disabled` (0.23.0) dims and disables it the kit way and a
|
|
983
|
+
* `reason` takes IconButton's `disabledReason` path — focusable, `aria-disabled`, the
|
|
984
|
+
* reason in the Tooltip — rather than a third copy of either here.
|
|
985
|
+
*/
|
|
986
|
+
function RemoveButton({
|
|
987
|
+
label,
|
|
988
|
+
onClick,
|
|
989
|
+
disabled,
|
|
990
|
+
reason,
|
|
991
|
+
}: {
|
|
992
|
+
label: string;
|
|
993
|
+
onClick: () => void;
|
|
994
|
+
disabled: boolean;
|
|
995
|
+
reason?: ReactNode;
|
|
996
|
+
}) {
|
|
997
|
+
return (
|
|
998
|
+
<IconButton
|
|
999
|
+
type="button"
|
|
1000
|
+
size="xs"
|
|
1001
|
+
tone="muted"
|
|
1002
|
+
data-attachment-remove=""
|
|
1003
|
+
aria-label={label}
|
|
1004
|
+
disabled={disabled}
|
|
1005
|
+
disabledReason={reason}
|
|
1006
|
+
onClick={onClick}
|
|
1007
|
+
className="shrink-0"
|
|
1008
|
+
>
|
|
1009
|
+
<X aria-hidden />
|
|
1010
|
+
</IconButton>
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** A reason worth showing: `null`, `false` and `""` are a caller's "no reason". */
|
|
1015
|
+
function hasReason(reason: ReactNode): boolean {
|
|
1016
|
+
return reason !== undefined && reason !== null && reason !== false && reason !== "";
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The {@link FeedbackAttachmentErrorInfo} builder of one field: its limits, and the
|
|
1021
|
+
* sentence for each refusal. The sentences are the `filePicker` namespace's — the ones
|
|
1022
|
+
* FileButton and FileDropzone say — so an app that translated those has these too, and
|
|
1023
|
+
* a refusal reads the same whichever of the kit's pickers made it. Only the upload
|
|
1024
|
+
* failure, which no other picker has, is this field's own (`attachmentUploadFailed`).
|
|
1025
|
+
*/
|
|
1026
|
+
function useErrorInfo(
|
|
1027
|
+
accept: string[],
|
|
1028
|
+
maxBytes: number,
|
|
1029
|
+
max: number | undefined,
|
|
1030
|
+
text: FeedbackAttachmentFieldLabels,
|
|
1031
|
+
) {
|
|
1032
|
+
const picker = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS);
|
|
1033
|
+
const fileText = useKitFileLabels();
|
|
1034
|
+
const uploadFailed = text.attachmentUploadFailed ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentUploadFailed!;
|
|
1035
|
+
return (kind: FeedbackAttachmentRefsError, files: File[], error?: unknown): FeedbackAttachmentErrorInfo => {
|
|
1036
|
+
const file = files[0];
|
|
1037
|
+
let message: string;
|
|
1038
|
+
if (kind === "type") {
|
|
1039
|
+
// FileButton's rule: name what IS accepted ("Only image/png, image/jpeg files"),
|
|
1040
|
+
// unless the app translated `rejectedType` but not yet `rejectedTypeOnly` — its
|
|
1041
|
+
// own sentence beats an English one.
|
|
1042
|
+
const acceptText = formatAccept(accept.join(","));
|
|
1043
|
+
const useOnly =
|
|
1044
|
+
acceptText !== "" &&
|
|
1045
|
+
(picker.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||
|
|
1046
|
+
picker.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);
|
|
1047
|
+
message = useOnly ? picker.rejectedTypeOnly(acceptText, file.name) : picker.rejectedType(file.name);
|
|
1048
|
+
} else if (kind === "size") {
|
|
1049
|
+
message = picker.rejectedSize(file.name, fileText.size(maxBytes));
|
|
1050
|
+
} else if (kind === "count") {
|
|
1051
|
+
message = files.length === 1 ? picker.rejectedCount(file.name, max ?? 1) : picker.rejectedMany(files.length);
|
|
1052
|
+
} else {
|
|
1053
|
+
message = uploadFailed(file.name);
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
file,
|
|
1057
|
+
files,
|
|
1058
|
+
message,
|
|
1059
|
+
maxBytes,
|
|
1060
|
+
accept,
|
|
1061
|
+
...(max !== undefined ? { max } : null),
|
|
1062
|
+
...(kind === "upload" ? { error } : null),
|
|
1063
|
+
};
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
525
1067
|
/** A chip's picture: the image itself, or a file glyph for anything that is not one.
|
|
526
1068
|
* Decorative (`alt=""`) — the name stands beside it. Its own component so the object
|
|
527
1069
|
* URL lives exactly as long as the chip: removing the chip unmounts it and revokes. */
|
|
@@ -599,10 +1141,15 @@ function usePaste(
|
|
|
599
1141
|
onImages: (images: File[]) => void,
|
|
600
1142
|
documentPaste: boolean,
|
|
601
1143
|
pasteFrom: RefObject<HTMLElement | null> | undefined,
|
|
1144
|
+
/** `disabled` / locked (0.23.0): the paste goes where it was going, untouched — not
|
|
1145
|
+
* swallowed, so a text half still lands in the box it was made in. */
|
|
1146
|
+
off: boolean,
|
|
602
1147
|
) {
|
|
603
|
-
|
|
1148
|
+
// `null` while off, so the listeners below leave the event alone entirely.
|
|
1149
|
+
const handler = off ? null : onImages;
|
|
1150
|
+
const latest = useRef(handler);
|
|
604
1151
|
useEffect(() => {
|
|
605
|
-
latest.current =
|
|
1152
|
+
latest.current = handler;
|
|
606
1153
|
});
|
|
607
1154
|
|
|
608
1155
|
// The element listened on, where it is not this field's own subtree: the
|
|
@@ -613,6 +1160,7 @@ function usePaste(
|
|
|
613
1160
|
const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);
|
|
614
1161
|
if (!target) return;
|
|
615
1162
|
const onPaste = (event: Event) => {
|
|
1163
|
+
if (!latest.current) return;
|
|
616
1164
|
const clipboard = (event as ClipboardEvent).clipboardData;
|
|
617
1165
|
takeImages(clipboard, () => event.preventDefault(), latest.current);
|
|
618
1166
|
};
|
|
@@ -623,7 +1171,10 @@ function usePaste(
|
|
|
623
1171
|
const listensElsewhere = documentPaste || pasteFrom !== undefined;
|
|
624
1172
|
return listensElsewhere
|
|
625
1173
|
? undefined
|
|
626
|
-
: (event: ReactClipboardEvent) =>
|
|
1174
|
+
: (event: ReactClipboardEvent) => {
|
|
1175
|
+
if (!latest.current) return;
|
|
1176
|
+
takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
|
|
1177
|
+
};
|
|
627
1178
|
}
|
|
628
1179
|
|
|
629
1180
|
function takeImages(clipboard: DataTransfer | null, stop: () => void, onImages: (images: File[]) => void) {
|