@eifi1/ui-kit 0.22.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.
Files changed (205) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +11 -8
  3. package/dist/components/amount-input.d.ts +28 -8
  4. package/dist/components/amount-input.js +3 -1
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +21 -0
  7. package/dist/components/autocomplete.js +128 -103
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +11 -8
  10. package/dist/components/calculator.d.ts +11 -8
  11. package/dist/components/column-mapper.d.ts +182 -0
  12. package/dist/components/column-mapper.js +375 -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 +80 -2
  18. package/dist/components/combobox.js +448 -311
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +11 -8
  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 +8 -5
  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 +11 -8
  28. package/dist/components/danger-confirm.js +13 -3
  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 +35 -1
  34. package/dist/components/entity-combobox.js +148 -112
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +11 -8
  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 +11 -8
  44. package/dist/components/file-dropzone.d.ts +11 -8
  45. package/dist/components/form-actions.d.ts +10 -7
  46. package/dist/components/form-actions.js +8 -2
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +11 -8
  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 +11 -8
  53. package/dist/components/money-field.d.ts +14 -8
  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 +26 -1
  58. package/dist/components/multi-entity-combobox.js +143 -102
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +11 -8
  61. package/dist/components/number-input.d.ts +11 -8
  62. package/dist/components/numpad-sheet.d.ts +11 -8
  63. package/dist/components/phone-input.d.ts +11 -8
  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 +11 -8
  68. package/dist/components/settings-fields.d.ts +11 -8
  69. package/dist/components/share-card.d.ts +10 -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 +10 -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 +11 -8
  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 +8 -5
  84. package/dist/components/ui.js +5 -11
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +1 -1
  87. package/dist/feedback/feedback-attachment.js +156 -56
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +1 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +3 -2
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +68 -287
  94. package/dist/feedback/feedback-thread.js +8 -1
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-BxeQVzwq.d.ts} +405 -22
  97. package/dist/{feedback-attachment-NhUm7Zga.d.ts → feedback-attachment-fGAzZPf0.d.ts} +112 -10
  98. package/dist/feedback.d.ts +66 -2
  99. package/dist/hooks/use-file-drop.d.ts +11 -8
  100. package/dist/i18n/defaults.d.ts +11 -8
  101. package/dist/i18n/defaults.js +3 -1
  102. package/dist/i18n/defaults.js.map +1 -1
  103. package/dist/i18n/german.d.ts +11 -8
  104. package/dist/i18n/german.js +36 -2
  105. package/dist/i18n/german.js.map +1 -1
  106. package/dist/i18n/kit-labels.d.ts +7 -4
  107. package/dist/i18n/kit-labels.js.map +1 -1
  108. package/dist/i18n/languages.d.ts +11 -8
  109. package/dist/i18n/locales/de-CH.d.ts +11 -8
  110. package/dist/i18n/locales/en.d.ts +11 -8
  111. package/dist/i18n/locales/en.js +7 -0
  112. package/dist/i18n/locales/en.js.map +1 -1
  113. package/dist/i18n/locales/es.d.ts +11 -8
  114. package/dist/i18n/locales/es.js +37 -2
  115. package/dist/i18n/locales/es.js.map +1 -1
  116. package/dist/i18n/locales/fr.d.ts +11 -8
  117. package/dist/i18n/locales/fr.js +36 -2
  118. package/dist/i18n/locales/fr.js.map +1 -1
  119. package/dist/i18n/locales/hu.d.ts +11 -8
  120. package/dist/i18n/locales/hu.js +38 -2
  121. package/dist/i18n/locales/hu.js.map +1 -1
  122. package/dist/i18n/locales/it.d.ts +11 -8
  123. package/dist/i18n/locales/it.js +37 -2
  124. package/dist/i18n/locales/it.js.map +1 -1
  125. package/dist/i18n/locales/zh.d.ts +11 -8
  126. package/dist/i18n/locales/zh.js +34 -2
  127. package/dist/i18n/locales/zh.js.map +1 -1
  128. package/dist/i18n/review.d.ts +11 -8
  129. package/dist/i18n/review.js +20 -1
  130. package/dist/i18n/review.js.map +1 -1
  131. package/dist/index.d.ts +7 -3
  132. package/dist/index.js +16 -0
  133. package/dist/index.js.map +1 -1
  134. package/dist/lib/column-mapping.d.ts +81 -0
  135. package/dist/lib/column-mapping.js +108 -0
  136. package/dist/lib/column-mapping.js.map +1 -0
  137. package/dist/lib/table-text.d.ts +109 -1
  138. package/dist/lib/table-text.js +122 -1
  139. package/dist/lib/table-text.js.map +1 -1
  140. package/dist/rhf/fields.d.ts +89 -11
  141. package/dist/rhf/fields.js +124 -0
  142. package/dist/rhf/fields.js.map +1 -1
  143. package/dist/rhf/form.d.ts +11 -8
  144. package/dist/rhf.d.ts +12 -9
  145. package/dist/rhf.js.map +1 -1
  146. package/dist/shell/app-shell.d.ts +10 -7
  147. package/dist/shell/top-bar-brand.d.ts +11 -8
  148. package/dist/shell/topbar-action-menu.d.ts +36 -3
  149. package/dist/shell/topbar-action-menu.js +74 -33
  150. package/dist/shell/topbar-action-menu.js.map +1 -1
  151. package/dist/shell.d.ts +10 -7
  152. package/dist/table-text.d.ts +1 -1
  153. package/dist/wizard/stepper-nav.d.ts +41 -10
  154. package/dist/wizard/stepper-nav.js +4 -0
  155. package/dist/wizard/stepper-nav.js.map +1 -1
  156. package/dist/wizard.d.ts +11 -8
  157. package/package.json +1 -1
  158. package/src/components/amount-input.tsx +20 -1
  159. package/src/components/autocomplete.tsx +172 -111
  160. package/src/components/column-mapper.tsx +666 -0
  161. package/src/components/combobox-core.tsx +82 -9
  162. package/src/components/combobox.tsx +614 -332
  163. package/src/components/confirm-dialog.tsx +12 -8
  164. package/src/components/country-select.tsx +135 -22
  165. package/src/components/danger-confirm.tsx +91 -12
  166. package/src/components/date-picker.tsx +423 -10
  167. package/src/components/entity-combobox.tsx +217 -126
  168. package/src/components/field-parts.tsx +224 -5
  169. package/src/components/field-strip.tsx +149 -0
  170. package/src/components/form-actions.tsx +32 -4
  171. package/src/components/icon-picker.tsx +23 -4
  172. package/src/components/money-field.tsx +3 -0
  173. package/src/components/month-picker.tsx +2 -1
  174. package/src/components/multi-entity-combobox.tsx +207 -120
  175. package/src/components/reauth-dialog.tsx +17 -18
  176. package/src/components/swatch-picker.tsx +29 -5
  177. package/src/components/tile-radio.tsx +74 -13
  178. package/src/components/toggle-group.tsx +2 -2
  179. package/src/components/trigger-aria.ts +5 -0
  180. package/src/components/ui.tsx +7 -33
  181. package/src/feedback/feedback-attachment.tsx +308 -61
  182. package/src/feedback/feedback-dialog.tsx +7 -3
  183. package/src/feedback/feedback-inbox.tsx +3 -2
  184. package/src/feedback/feedback-thread.tsx +47 -4
  185. package/src/i18n/defaults.ts +2 -0
  186. package/src/i18n/german.ts +42 -0
  187. package/src/i18n/kit-labels.tsx +4 -0
  188. package/src/i18n/locales/en.ts +27 -5
  189. package/src/i18n/locales/es.ts +41 -0
  190. package/src/i18n/locales/fr.ts +42 -0
  191. package/src/i18n/locales/hu.ts +37 -0
  192. package/src/i18n/locales/it.ts +41 -0
  193. package/src/i18n/locales/zh.ts +32 -0
  194. package/src/i18n/review.ts +19 -0
  195. package/src/index.ts +18 -0
  196. package/src/lib/column-mapping.ts +234 -0
  197. package/src/lib/table-text.ts +271 -0
  198. package/src/rhf/fields.tsx +254 -0
  199. package/src/rhf.ts +2 -1
  200. package/src/shell/topbar-action-menu.tsx +134 -38
  201. package/src/wizard/stepper-nav.tsx +35 -1
  202. package/dist/components/field-anatomy.d.ts +0 -95
  203. package/dist/components/field-anatomy.js +0 -84
  204. package/dist/components/field-anatomy.js.map +0 -1
  205. package/src/components/field-anatomy.tsx +0 -190
@@ -1,7 +1,17 @@
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 { 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
 
@@ -47,6 +57,11 @@ export interface FeedbackAttachmentFieldLabels {
47
57
  * running, where an uploaded one shows its size — "Uploading…". Optional, like the
48
58
  * keys above, so a `UiKitLabels` typed before it still compiles. */
49
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;
50
65
  }
51
66
 
52
67
  export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {
@@ -60,6 +75,7 @@ export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels =
60
75
  attachmentLimit: (max) =>
61
76
  `Up to ${max} ${max === 1 ? "attachment" : "attachments"} — remove one to add another.`,
62
77
  attachmentUploading: "Uploading…",
78
+ attachmentUploadFailed: (name) => `“${name}” could not be uploaded`,
63
79
  };
64
80
 
65
81
  /** Why a file was turned away. `"count"` only ever comes from `multiple` (and `refs`)
@@ -72,6 +88,58 @@ export type FeedbackAttachmentError = "type" | "size" | "count";
72
88
  * own so a host's exhaustive switch over the File modes' reasons stays exhaustive. */
73
89
  export type FeedbackAttachmentRefsError = FeedbackAttachmentError | "upload";
74
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
+
75
143
  /**
76
144
  * One uploaded attachment in `refs` mode: what the host's upload answered with. `key`
77
145
  * identifies it (a storage key, an upload id) and is what a remove goes by; `name` is
@@ -88,9 +156,25 @@ export interface FeedbackAttachmentRef {
88
156
  interface FeedbackAttachmentFieldBaseProps {
89
157
  /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
90
158
  * since 0.7.0; `attachment` (the heading) is only ever read from here. */
91
- labels?: Partial<FeedbackAttachmentLabels> & Pick<Partial<FeedbackAttachmentFieldLabels>, "attachmentUploading">;
159
+ labels?: Partial<FeedbackAttachmentLabels> &
160
+ Pick<Partial<FeedbackAttachmentFieldLabels>, "attachmentUploading" | "attachmentUploadFailed">;
92
161
  accept?: string[];
93
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;
94
178
  /** Snapshot the app view behind this and return it as a File. A "Capture
95
179
  * screenshot" button appears only when it is given. */
96
180
  onCaptureScreenshot?: () => Promise<File | null>;
@@ -111,11 +195,16 @@ export interface FeedbackAttachmentFieldSingleProps extends FeedbackAttachmentFi
111
195
  refs?: false;
112
196
  value: File | null;
113
197
  onChange: (file: File | null) => void;
114
- onError?: (kind: "type" | "size") => void;
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;
115
201
  // Multiple-mode props, refused here: without `multiple` they would be silently ignored.
116
202
  max?: never;
117
203
  screenshot?: never;
118
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;
119
208
  }
120
209
 
121
210
  /**
@@ -131,8 +220,10 @@ export interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachment
131
220
  /** How many files `value` may hold. Default {@link DEFAULT_MAX_ATTACHMENTS} (5). The
132
221
  * add buttons and the paste hint are offered only while there is room. */
133
222
  max?: number;
134
- /** Per file: a rejected one is reported and the others are still added. */
135
- onError?: (kind: FeedbackAttachmentError) => void;
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;
136
227
  /**
137
228
  * A separate slot for THE screenshot, outside `value` and `max`. Given
138
229
  * `onScreenshotChange`, what `onCaptureScreenshot` returns lands here instead of in
@@ -143,6 +234,9 @@ export interface FeedbackAttachmentFieldMultipleProps extends FeedbackAttachment
143
234
  */
144
235
  screenshot?: File | null;
145
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;
146
240
  }
147
241
 
148
242
  /**
@@ -180,8 +274,28 @@ export interface FeedbackAttachmentFieldRefsProps extends FeedbackAttachmentFiel
180
274
  /** How many refs `value` may hold, uploads in flight included. Default
181
275
  * {@link DEFAULT_MAX_ATTACHMENTS} (5). */
182
276
  max?: number;
183
- /** Per file. `"upload"` carries what `onUpload` rejected with. */
184
- onError?: (kind: FeedbackAttachmentRefsError, error?: unknown) => void;
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;
185
299
  screenshot?: never;
186
300
  onScreenshotChange?: never;
187
301
  }
@@ -239,6 +353,7 @@ function SingleField({
239
353
  onCaptureScreenshot,
240
354
  documentPaste = false,
241
355
  pasteFrom,
356
+ disabled = false,
242
357
  className,
243
358
  }: FeedbackAttachmentFieldSingleProps) {
244
359
  // The object URL is keyed to the file it was made for, so a stale one (from the
@@ -249,6 +364,7 @@ function SingleField({
249
364
  // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.
250
365
  const fileText = useKitFileLabels();
251
366
  const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
367
+ const errorInfo = useErrorInfo(accept, maxBytes, undefined, text);
252
368
 
253
369
  // Object-URL preview lifecycle (create on change, revoke on cleanup).
254
370
  useEffect(() => {
@@ -266,7 +382,7 @@ function SingleField({
266
382
  if (!file) return;
267
383
  const problem = rejection(file, accept, maxBytes);
268
384
  if (problem) {
269
- onError?.(problem);
385
+ onError?.(problem, errorInfo(problem, [file]));
270
386
  return;
271
387
  }
272
388
  onChange(file);
@@ -282,6 +398,7 @@ function SingleField({
282
398
  },
283
399
  documentPaste,
284
400
  pasteFrom,
401
+ disabled,
285
402
  );
286
403
 
287
404
  return (
@@ -315,23 +432,26 @@ function SingleField({
315
432
  {fileText.size(value.size)}
316
433
  </div>
317
434
  </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>
435
+ <RemoveButton label={text.attachmentRemove} onClick={() => onChange(null)} disabled={disabled} />
326
436
  </div>
327
437
  ) : (
328
438
  <div className="space-y-1.5">
329
439
  <div className="flex flex-wrap gap-2">
330
- <Button type="button" variant="secondary" onClick={() => fileInputRef.current?.click()}>
440
+ <Button
441
+ type="button"
442
+ variant="secondary"
443
+ disabled={disabled}
444
+ onClick={() => fileInputRef.current?.click()}
445
+ >
331
446
  <Paperclip className="size-4" /> {text.attachmentAdd}
332
447
  </Button>
333
448
  {onCaptureScreenshot && (
334
- <CaptureButton capture={onCaptureScreenshot} onFile={pick} label={text.attachmentCapture} />
449
+ <CaptureButton
450
+ capture={onCaptureScreenshot}
451
+ onFile={pick}
452
+ label={text.attachmentCapture}
453
+ disabled={disabled}
454
+ />
335
455
  )}
336
456
  </div>
337
457
  {/* Said out loud, because a gesture with no affordance is a gesture
@@ -346,6 +466,7 @@ function SingleField({
346
466
  type="file"
347
467
  accept={accept.join(",")}
348
468
  className="sr-only"
469
+ disabled={disabled}
349
470
  onChange={(e) => {
350
471
  pick(e.target.files?.[0]);
351
472
  e.target.value = "";
@@ -368,6 +489,7 @@ function MultipleField({
368
489
  onCaptureScreenshot,
369
490
  documentPaste = false,
370
491
  pasteFrom,
492
+ disabled = false,
371
493
  className,
372
494
  }: FeedbackAttachmentFieldMultipleProps) {
373
495
  const rootRef = useRef<HTMLDivElement>(null);
@@ -377,6 +499,7 @@ function MultipleField({
377
499
  const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
378
500
  const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
379
501
  const screenshotLabel = text.attachmentScreenshot ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentScreenshot!;
502
+ const errorInfo = useErrorInfo(accept, maxBytes, max, text);
380
503
 
381
504
  // What an add or a remove builds on: the `value` prop, plus whatever this field has
382
505
  // already handed to `onChange` since the parent last rendered. The field is
@@ -405,27 +528,28 @@ function MultipleField({
405
528
  const accepted: File[] = [];
406
529
  const taken = new Set([...current, ...(screenshot ? [screenshot] : [])].map((f) => f.name));
407
530
  for (const raw of incoming) {
408
- const problem = rejection(raw, accept, maxBytes);
409
- if (problem) {
410
- onError?.(problem);
411
- continue;
412
- }
413
531
  // Two pastes are two files called "pasted.png", and a backend that stores by
414
532
  // 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.
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.
416
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
+ }
417
541
  taken.add(file.name);
418
542
  // The same File object twice is one file chosen twice, not two.
419
543
  if (!current.includes(file)) accepted.push(file);
420
544
  }
421
545
  if (accepted.length === 0) return;
422
546
  const space = Math.max(0, max - current.length);
423
- if (accepted.length > space) onError?.("count");
547
+ if (accepted.length > space) onError?.("count", errorInfo("count", accepted.slice(space)));
424
548
  const kept = accepted.slice(0, space);
425
549
  if (kept.length > 0) commit([...current, ...kept]);
426
550
  };
427
551
 
428
- const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
552
+ const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom, disabled);
429
553
 
430
554
  const capture = (file: File) => {
431
555
  if (!hasSlot) {
@@ -433,7 +557,7 @@ function MultipleField({
433
557
  return;
434
558
  }
435
559
  const problem = rejection(file, accept, maxBytes);
436
- if (problem) onError?.(problem);
560
+ if (problem) onError?.(problem, errorInfo(problem, [file]));
437
561
  else onScreenshotChange(file);
438
562
  };
439
563
 
@@ -489,18 +613,14 @@ function MultipleField({
489
613
  {chip.screenshot ? `${chip.file.name} · ${fileText.size(chip.file.size)}` : fileText.size(chip.file.size)}
490
614
  </div>
491
615
  </div>
492
- <button
493
- type="button"
494
- data-attachment-remove=""
616
+ <RemoveButton
617
+ label={removeLabel(chip.name)}
618
+ disabled={disabled}
495
619
  onClick={() => {
496
620
  pendingFocus.current = index;
497
621
  chip.remove();
498
622
  }}
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>
623
+ />
504
624
  </li>
505
625
  ))}
506
626
  </ul>
@@ -512,6 +632,7 @@ function MultipleField({
512
632
  type="button"
513
633
  variant="secondary"
514
634
  data-attachment-action=""
635
+ disabled={disabled}
515
636
  onClick={() => fileInputRef.current?.click()}
516
637
  >
517
638
  <Paperclip className="size-4" /> {text.attachmentAdd}
@@ -522,6 +643,7 @@ function MultipleField({
522
643
  capture={onCaptureScreenshot}
523
644
  onFile={capture}
524
645
  label={text.attachmentCapture}
646
+ disabled={disabled}
525
647
  data-attachment-action=""
526
648
  />
527
649
  )}
@@ -540,6 +662,7 @@ function MultipleField({
540
662
  // the way in, and a second, unlabelled "Choose files" stop is noise.
541
663
  tabIndex={-1}
542
664
  aria-hidden
665
+ disabled={disabled}
543
666
  onChange={(e) => {
544
667
  add(Array.from(e.target.files ?? []));
545
668
  e.target.value = "";
@@ -571,6 +694,9 @@ function RefsField({
571
694
  onCaptureScreenshot,
572
695
  documentPaste = false,
573
696
  pasteFrom,
697
+ disabled = false,
698
+ commit: commits,
699
+ disabledReason: ownDisabledReason,
574
700
  className,
575
701
  }: FeedbackAttachmentFieldRefsProps) {
576
702
  const rootRef = useRef<HTMLDivElement>(null);
@@ -580,6 +706,14 @@ function RefsField({
580
706
  const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
581
707
  const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
582
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;
583
717
 
584
718
  // As in `multiple` mode: `value` plus whatever has been handed to `onChange` since
585
719
  // the parent last rendered — two uploads landing in one tick must both survive.
@@ -637,7 +771,7 @@ function RefsField({
637
771
  },
638
772
  (error: unknown) => {
639
773
  settle();
640
- if (mounted.current) onError?.("upload", error);
774
+ if (mounted.current) onError?.("upload", error, errorInfo("upload", [file], error));
641
775
  },
642
776
  );
643
777
  };
@@ -645,28 +779,29 @@ function RefsField({
645
779
  const add = (incoming: File[], pasted = false) => {
646
780
  let space = Math.max(0, max - latest.current.length - inFlight.current.length);
647
781
  const taken = new Set([...latest.current.map((r) => r.name), ...inFlight.current.map((u) => u.file.name)]);
648
- let dropped = false;
782
+ const dropped: File[] = [];
649
783
  for (const raw of incoming) {
650
- const problem = rejection(raw, accept, maxBytes);
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);
651
788
  if (problem) {
652
- onError?.(problem);
789
+ // `undefined` in `error`'s place, as in 0.22: only an upload has one.
790
+ onError?.(problem, undefined, errorInfo(problem, [file]));
653
791
  continue;
654
792
  }
793
+ taken.add(file.name);
655
794
  if (space === 0) {
656
- dropped = true;
795
+ dropped.push(file);
657
796
  continue;
658
797
  }
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
798
  space -= 1;
664
799
  start(file);
665
800
  }
666
- if (dropped) onError?.("count");
801
+ if (dropped.length > 0) onError?.("count", undefined, errorInfo("count", dropped));
667
802
  };
668
803
 
669
- const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
804
+ const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom, inert);
670
805
 
671
806
  // Focus after a remove, as in `multiple` mode: the next chip, else the first action.
672
807
  const pendingFocus = useRef<number | null>(null);
@@ -683,6 +818,7 @@ function RefsField({
683
818
  }, [value]);
684
819
 
685
820
  const remove = (key: string, index: number) => {
821
+ if (inert) return;
686
822
  pendingFocus.current = index;
687
823
  commit(latest.current.filter((r) => r.key !== key));
688
824
  onRemove?.(key);
@@ -707,15 +843,12 @@ function RefsField({
707
843
  <div className="truncate text-xs text-[var(--text-muted)]">{fileText.size(ref.size)}</div>
708
844
  )}
709
845
  </div>
710
- <button
711
- type="button"
712
- data-attachment-remove=""
846
+ <RemoveButton
847
+ label={removeLabel(ref.name)}
713
848
  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>
849
+ disabled={disabled}
850
+ reason={reason}
851
+ />
719
852
  </li>
720
853
  ))}
721
854
  {uploads.map((upload) => (
@@ -742,6 +875,8 @@ function RefsField({
742
875
  type="button"
743
876
  variant="secondary"
744
877
  data-attachment-action=""
878
+ disabled={disabled}
879
+ disabledReason={reason}
745
880
  onClick={() => fileInputRef.current?.click()}
746
881
  >
747
882
  <Paperclip className="size-4" /> {text.attachmentAdd}
@@ -751,12 +886,22 @@ function RefsField({
751
886
  capture={onCaptureScreenshot}
752
887
  onFile={(file) => add([file])}
753
888
  label={text.attachmentCapture}
889
+ disabled={disabled}
890
+ disabledReason={reason}
754
891
  data-attachment-action=""
755
892
  />
756
893
  )}
757
894
  </div>
758
895
  )}
759
- <p className="text-xs text-[var(--text-muted)]">{room > 0 ? text.attachmentPaste : limitLabel(max)}</p>
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
+ )}
760
905
  <input
761
906
  ref={fileInputRef}
762
907
  type="file"
@@ -765,6 +910,7 @@ function RefsField({
765
910
  className="sr-only"
766
911
  tabIndex={-1}
767
912
  aria-hidden
913
+ disabled={inert}
768
914
  onChange={(e) => {
769
915
  add(Array.from(e.target.files ?? []));
770
916
  e.target.value = "";
@@ -797,11 +943,15 @@ function CaptureButton({
797
943
  capture,
798
944
  onFile,
799
945
  label,
946
+ disabled = false,
947
+ disabledReason,
800
948
  ...rest
801
949
  }: {
802
950
  capture: () => Promise<File | null>;
803
951
  onFile: (file: File) => void;
804
952
  label: string;
953
+ disabled?: boolean;
954
+ disabledReason?: ReactNode;
805
955
  "data-attachment-action"?: string;
806
956
  }) {
807
957
  const [capturing, setCapturing] = useState(false);
@@ -809,10 +959,11 @@ function CaptureButton({
809
959
  <Button
810
960
  type="button"
811
961
  variant="secondary"
812
- disabled={capturing}
962
+ disabled={capturing || disabled}
963
+ disabledReason={disabledReason}
813
964
  {...rest}
814
965
  onClick={() => {
815
- if (capturing) return;
966
+ if (capturing || disabled) return;
816
967
  setCapturing(true);
817
968
  void capture()
818
969
  .then((file) => {
@@ -826,6 +977,93 @@ function CaptureButton({
826
977
  );
827
978
  }
828
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
+
829
1067
  /** A chip's picture: the image itself, or a file glyph for anything that is not one.
830
1068
  * Decorative (`alt=""`) — the name stands beside it. Its own component so the object
831
1069
  * URL lives exactly as long as the chip: removing the chip unmounts it and revokes. */
@@ -903,10 +1141,15 @@ function usePaste(
903
1141
  onImages: (images: File[]) => void,
904
1142
  documentPaste: boolean,
905
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,
906
1147
  ) {
907
- const latest = useRef(onImages);
1148
+ // `null` while off, so the listeners below leave the event alone entirely.
1149
+ const handler = off ? null : onImages;
1150
+ const latest = useRef(handler);
908
1151
  useEffect(() => {
909
- latest.current = onImages;
1152
+ latest.current = handler;
910
1153
  });
911
1154
 
912
1155
  // The element listened on, where it is not this field's own subtree: the
@@ -917,6 +1160,7 @@ function usePaste(
917
1160
  const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);
918
1161
  if (!target) return;
919
1162
  const onPaste = (event: Event) => {
1163
+ if (!latest.current) return;
920
1164
  const clipboard = (event as ClipboardEvent).clipboardData;
921
1165
  takeImages(clipboard, () => event.preventDefault(), latest.current);
922
1166
  };
@@ -927,7 +1171,10 @@ function usePaste(
927
1171
  const listensElsewhere = documentPaste || pasteFrom !== undefined;
928
1172
  return listensElsewhere
929
1173
  ? undefined
930
- : (event: ReactClipboardEvent) => takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
1174
+ : (event: ReactClipboardEvent) => {
1175
+ if (!latest.current) return;
1176
+ takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
1177
+ };
931
1178
  }
932
1179
 
933
1180
  function takeImages(clipboard: DataTransfer | null, stop: () => void, onImages: (images: File[]) => void) {