@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.
Files changed (208) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +8 -7
  3. package/dist/components/amount-input.d.ts +34 -7
  4. package/dist/components/amount-input.js +14 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +26 -0
  7. package/dist/components/autocomplete.js +164 -137
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +8 -7
  10. package/dist/components/calculator.d.ts +8 -7
  11. package/dist/components/column-mapper.d.ts +235 -0
  12. package/dist/components/column-mapper.js +411 -0
  13. package/dist/components/column-mapper.js.map +1 -0
  14. package/dist/components/combobox-core.d.ts +39 -2
  15. package/dist/components/combobox-core.js +29 -6
  16. package/dist/components/combobox-core.js.map +1 -1
  17. package/dist/components/combobox.d.ts +113 -3
  18. package/dist/components/combobox.js +456 -313
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +8 -7
  21. package/dist/components/confirm-dialog.js +7 -8
  22. package/dist/components/confirm-dialog.js.map +1 -1
  23. package/dist/components/copy-button.d.ts +5 -4
  24. package/dist/components/country-select.d.ts +50 -7
  25. package/dist/components/country-select.js +65 -9
  26. package/dist/components/country-select.js.map +1 -1
  27. package/dist/components/danger-confirm.d.ts +8 -7
  28. package/dist/components/danger-confirm.js +64 -7
  29. package/dist/components/danger-confirm.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +41 -0
  31. package/dist/components/date-picker.js +269 -8
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/entity-combobox.d.ts +48 -2
  34. package/dist/components/entity-combobox.js +159 -114
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +8 -7
  37. package/dist/components/field-parts.d.ts +116 -6
  38. package/dist/components/field-parts.js +84 -0
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +105 -0
  41. package/dist/components/field-strip.js +46 -0
  42. package/dist/components/field-strip.js.map +1 -0
  43. package/dist/components/file-button.d.ts +8 -7
  44. package/dist/components/file-dropzone.d.ts +8 -7
  45. package/dist/components/form-actions.d.ts +8 -7
  46. package/dist/components/form-actions.js +23 -3
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +8 -7
  49. package/dist/components/icon-picker.d.ts +10 -1
  50. package/dist/components/icon-picker.js +12 -4
  51. package/dist/components/icon-picker.js.map +1 -1
  52. package/dist/components/language-select.d.ts +8 -7
  53. package/dist/components/money-field.d.ts +11 -7
  54. package/dist/components/money-field.js.map +1 -1
  55. package/dist/components/month-picker.js +2 -1
  56. package/dist/components/month-picker.js.map +1 -1
  57. package/dist/components/multi-entity-combobox.d.ts +31 -2
  58. package/dist/components/multi-entity-combobox.js +152 -103
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +8 -7
  61. package/dist/components/number-input.d.ts +8 -7
  62. package/dist/components/numpad-sheet.d.ts +8 -7
  63. package/dist/components/phone-input.d.ts +8 -7
  64. package/dist/components/reauth-dialog.d.ts +2 -2
  65. package/dist/components/reauth-dialog.js +6 -8
  66. package/dist/components/reauth-dialog.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +8 -7
  68. package/dist/components/settings-fields.d.ts +8 -7
  69. package/dist/components/share-card.d.ts +8 -7
  70. package/dist/components/swatch-picker.d.ts +17 -1
  71. package/dist/components/swatch-picker.js +10 -4
  72. package/dist/components/swatch-picker.js.map +1 -1
  73. package/dist/components/text-link.d.ts +8 -7
  74. package/dist/components/tile-radio.d.ts +20 -0
  75. package/dist/components/tile-radio.js +25 -8
  76. package/dist/components/tile-radio.js.map +1 -1
  77. package/dist/components/time-input.d.ts +8 -7
  78. package/dist/components/toggle-group.js +2 -2
  79. package/dist/components/toggle-group.js.map +1 -1
  80. package/dist/components/trigger-aria.d.ts +3 -0
  81. package/dist/components/trigger-aria.js +3 -1
  82. package/dist/components/trigger-aria.js.map +1 -1
  83. package/dist/components/ui.d.ts +5 -4
  84. package/dist/components/ui.js +60 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +66 -1
  87. package/dist/feedback/feedback-attachment.js +185 -65
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +66 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +67 -252
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +66 -287
  94. package/dist/feedback/feedback-thread.js +16 -3
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
  97. package/dist/feedback.d.ts +65 -3
  98. package/dist/hooks/use-file-drop.d.ts +8 -7
  99. package/dist/i18n/defaults.d.ts +8 -7
  100. package/dist/i18n/defaults.js +3 -1
  101. package/dist/i18n/defaults.js.map +1 -1
  102. package/dist/i18n/german.d.ts +8 -7
  103. package/dist/i18n/german.js +36 -2
  104. package/dist/i18n/german.js.map +1 -1
  105. package/dist/i18n/kit-labels.d.ts +5 -4
  106. package/dist/i18n/kit-labels.js.map +1 -1
  107. package/dist/i18n/languages.d.ts +8 -7
  108. package/dist/i18n/locales/de-CH.d.ts +8 -7
  109. package/dist/i18n/locales/en.d.ts +8 -7
  110. package/dist/i18n/locales/en.js +7 -0
  111. package/dist/i18n/locales/en.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +8 -7
  113. package/dist/i18n/locales/es.js +37 -2
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +8 -7
  116. package/dist/i18n/locales/fr.js +36 -2
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +8 -7
  119. package/dist/i18n/locales/hu.js +38 -2
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +8 -7
  122. package/dist/i18n/locales/it.js +37 -2
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +8 -7
  125. package/dist/i18n/locales/zh.js +34 -2
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/i18n/review.d.ts +8 -7
  128. package/dist/i18n/review.js +20 -1
  129. package/dist/i18n/review.js.map +1 -1
  130. package/dist/index.d.ts +6 -4
  131. package/dist/index.js +16 -0
  132. package/dist/index.js.map +1 -1
  133. package/dist/lib/column-mapping.d.ts +81 -0
  134. package/dist/lib/column-mapping.js +108 -0
  135. package/dist/lib/column-mapping.js.map +1 -0
  136. package/dist/lib/strip-fade.d.ts +2 -2
  137. package/dist/lib/strip-fade.js.map +1 -1
  138. package/dist/lib/table-text.d.ts +109 -1
  139. package/dist/lib/table-text.js +122 -1
  140. package/dist/lib/table-text.js.map +1 -1
  141. package/dist/rhf/fields.d.ts +142 -13
  142. package/dist/rhf/fields.js +185 -3
  143. package/dist/rhf/fields.js.map +1 -1
  144. package/dist/rhf/form.d.ts +8 -7
  145. package/dist/rhf.d.ts +9 -8
  146. package/dist/rhf.js.map +1 -1
  147. package/dist/shell/app-shell.d.ts +8 -7
  148. package/dist/shell/top-bar-brand.d.ts +8 -7
  149. package/dist/shell/topbar-action-menu.d.ts +36 -3
  150. package/dist/shell/topbar-action-menu.js +74 -33
  151. package/dist/shell/topbar-action-menu.js.map +1 -1
  152. package/dist/shell.d.ts +8 -7
  153. package/dist/table-text.d.ts +1 -1
  154. package/dist/wizard/stepper-nav.d.ts +38 -9
  155. package/dist/wizard/stepper-nav.js +4 -0
  156. package/dist/wizard/stepper-nav.js.map +1 -1
  157. package/dist/wizard.d.ts +8 -7
  158. package/package.json +1 -1
  159. package/src/components/amount-input.tsx +50 -14
  160. package/src/components/autocomplete.tsx +188 -116
  161. package/src/components/column-mapper.tsx +768 -0
  162. package/src/components/combobox-core.tsx +82 -9
  163. package/src/components/combobox.tsx +655 -335
  164. package/src/components/confirm-dialog.tsx +12 -8
  165. package/src/components/country-select.tsx +135 -22
  166. package/src/components/danger-confirm.tsx +274 -24
  167. package/src/components/date-picker.tsx +423 -10
  168. package/src/components/entity-combobox.tsx +243 -129
  169. package/src/components/field-parts.tsx +224 -5
  170. package/src/components/field-strip.tsx +149 -0
  171. package/src/components/form-actions.tsx +52 -5
  172. package/src/components/icon-picker.tsx +23 -4
  173. package/src/components/money-field.tsx +3 -0
  174. package/src/components/month-picker.tsx +2 -1
  175. package/src/components/multi-entity-combobox.tsx +221 -121
  176. package/src/components/reauth-dialog.tsx +17 -18
  177. package/src/components/swatch-picker.tsx +29 -5
  178. package/src/components/tile-radio.tsx +74 -13
  179. package/src/components/toggle-group.tsx +2 -2
  180. package/src/components/trigger-aria.ts +5 -0
  181. package/src/components/ui.tsx +114 -33
  182. package/src/feedback/feedback-attachment.tsx +372 -72
  183. package/src/feedback/feedback-dialog.tsx +7 -3
  184. package/src/feedback/feedback-inbox.tsx +3 -2
  185. package/src/feedback/feedback-thread.tsx +112 -9
  186. package/src/i18n/defaults.ts +2 -0
  187. package/src/i18n/german.ts +42 -0
  188. package/src/i18n/kit-labels.tsx +4 -0
  189. package/src/i18n/locales/en.ts +27 -5
  190. package/src/i18n/locales/es.ts +41 -0
  191. package/src/i18n/locales/fr.ts +42 -0
  192. package/src/i18n/locales/hu.ts +37 -0
  193. package/src/i18n/locales/it.ts +41 -0
  194. package/src/i18n/locales/zh.ts +32 -0
  195. package/src/i18n/review.ts +19 -0
  196. package/src/index.ts +18 -0
  197. package/src/lib/column-mapping.ts +234 -0
  198. package/src/lib/strip-fade.ts +2 -2
  199. package/src/lib/table-text.ts +271 -0
  200. package/src/rhf/fields.tsx +421 -6
  201. package/src/rhf.ts +2 -1
  202. package/src/shell/topbar-action-menu.tsx +134 -38
  203. package/src/wizard/stepper-nav.tsx +35 -1
  204. package/dist/components/field-anatomy.d.ts +0 -95
  205. package/dist/components/field-anatomy.js +0 -84
  206. package/dist/components/field-anatomy.js.map +0 -1
  207. package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
  208. package/src/components/field-anatomy.tsx +0 -190
@@ -1,7 +1,18 @@
1
1
  import { cn } from "../lib/cn";
2
- import { useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type RefObject } from "react";
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> & Pick<Partial<FeedbackAttachmentFieldLabels>, "attachmentUploading">;
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
- onError?: (kind: "type" | "size") => void;
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
- onError?: (kind: FeedbackAttachmentError) => void;
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
- onError?: (kind: FeedbackAttachmentRefsError, error?: unknown) => void;
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
- className={cn("relative", className)}
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
- <button
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 type="button" variant="secondary" onClick={() => fileInputRef.current?.click()}>
331
- <Paperclip className="size-4" /> {text.attachmentAdd}
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 capture={onCaptureScreenshot} onFile={pick} label={text.attachmentCapture} />
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("relative space-y-2", className)} onPaste={onPaste}>
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
- <button
493
- type="button"
494
- data-attachment-remove=""
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
- aria-label={removeLabel(chip.name)}
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="secondary"
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="size-4" /> {text.attachmentAdd}
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
- let dropped = false;
827
+ const dropped: File[] = [];
649
828
  for (const raw of incoming) {
650
- const problem = rejection(raw, accept, maxBytes);
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
- onError?.(problem);
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 = true;
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("relative space-y-2", className)} onPaste={onPaste}>
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
- <button
711
- type="button"
712
- data-attachment-remove=""
891
+ <RemoveButton
892
+ label={removeLabel(ref.name)}
713
893
  onClick={() => remove(ref.key, index)}
714
- aria-label={removeLabel(ref.name)}
715
- className="shrink-0 rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
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="secondary"
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="size-4" /> {text.attachmentAdd}
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
- <p className="text-xs text-[var(--text-muted)]">{room > 0 ? text.attachmentPaste : limitLabel(max)}</p>
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="secondary"
812
- disabled={capturing}
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="size-4" /> {capturing ? "…" : label}
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
- const latest = useRef(onImages);
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 = onImages;
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) => takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
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) {