@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.
Files changed (320) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +19 -14
  3. package/dist/components/account-chips.d.ts +10 -3
  4. package/dist/components/account-chips.js +5 -2
  5. package/dist/components/account-chips.js.map +1 -1
  6. package/dist/components/account-settings.d.ts +33 -2
  7. package/dist/components/account-settings.js +43 -21
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +53 -14
  10. package/dist/components/amount-input.js +17 -28
  11. package/dist/components/amount-input.js.map +1 -1
  12. package/dist/components/autocomplete.d.ts +29 -0
  13. package/dist/components/autocomplete.js +134 -104
  14. package/dist/components/autocomplete.js.map +1 -1
  15. package/dist/components/button-group.d.ts +19 -14
  16. package/dist/components/calculator.d.ts +19 -14
  17. package/dist/components/checkbox-group.d.ts +104 -0
  18. package/dist/components/checkbox-group.js +111 -0
  19. package/dist/components/checkbox-group.js.map +1 -0
  20. package/dist/components/checkbox.d.ts +19 -0
  21. package/dist/components/checkbox.js +30 -7
  22. package/dist/components/checkbox.js.map +1 -1
  23. package/dist/components/chip.d.ts +18 -0
  24. package/dist/components/chip.js +47 -10
  25. package/dist/components/chip.js.map +1 -1
  26. package/dist/components/column-mapper.d.ts +182 -0
  27. package/dist/components/column-mapper.js +375 -0
  28. package/dist/components/column-mapper.js.map +1 -0
  29. package/dist/components/combobox-core.d.ts +39 -2
  30. package/dist/components/combobox-core.js +29 -6
  31. package/dist/components/combobox-core.js.map +1 -1
  32. package/dist/components/combobox.d.ts +91 -2
  33. package/dist/components/combobox.js +454 -308
  34. package/dist/components/combobox.js.map +1 -1
  35. package/dist/components/confirm-dialog.d.ts +69 -161
  36. package/dist/components/confirm-dialog.js +7 -8
  37. package/dist/components/confirm-dialog.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +16 -11
  39. package/dist/components/country-select.d.ts +167 -0
  40. package/dist/components/country-select.js +293 -0
  41. package/dist/components/country-select.js.map +1 -0
  42. package/dist/components/currency-select.d.ts +17 -1
  43. package/dist/components/currency-select.js +56 -23
  44. package/dist/components/currency-select.js.map +1 -1
  45. package/dist/components/danger-confirm.d.ts +69 -166
  46. package/dist/components/danger-confirm.js +144 -50
  47. package/dist/components/danger-confirm.js.map +1 -1
  48. package/dist/components/data-table-cells.d.ts +1 -2
  49. package/dist/components/data-table-filter-popover.d.ts +1 -2
  50. package/dist/components/data-table-filters.d.ts +1 -2
  51. package/dist/components/data-table-labels.d.ts +4 -97
  52. package/dist/components/data-table-labels.js +9 -1
  53. package/dist/components/data-table-labels.js.map +1 -1
  54. package/dist/components/data-table-pagination.d.ts +3 -1
  55. package/dist/components/data-table.d.ts +1 -2
  56. package/dist/components/data-table.js +98 -3
  57. package/dist/components/data-table.js.map +1 -1
  58. package/dist/components/date-picker.d.ts +66 -5
  59. package/dist/components/date-picker.js +378 -66
  60. package/dist/components/date-picker.js.map +1 -1
  61. package/dist/components/entity-combobox.d.ts +42 -1
  62. package/dist/components/entity-combobox.js +154 -113
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/facing-pair.d.ts +19 -14
  65. package/dist/components/field-parts.d.ts +224 -0
  66. package/dist/components/field-parts.js +156 -0
  67. package/dist/components/field-parts.js.map +1 -0
  68. package/dist/components/field-strip.d.ts +105 -0
  69. package/dist/components/field-strip.js +46 -0
  70. package/dist/components/field-strip.js.map +1 -0
  71. package/dist/components/file-button.d.ts +19 -14
  72. package/dist/components/file-button.js +13 -3
  73. package/dist/components/file-button.js.map +1 -1
  74. package/dist/components/file-dropzone.d.ts +41 -15
  75. package/dist/components/file-dropzone.js +57 -34
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/form-actions.d.ts +18 -13
  78. package/dist/components/form-actions.js +69 -5
  79. package/dist/components/form-actions.js.map +1 -1
  80. package/dist/components/iban-input.d.ts +69 -0
  81. package/dist/components/iban-input.js +125 -0
  82. package/dist/components/iban-input.js.map +1 -0
  83. package/dist/components/icon-picker.d.ts +21 -2
  84. package/dist/components/icon-picker.js +19 -4
  85. package/dist/components/icon-picker.js.map +1 -1
  86. package/dist/components/inline-edit-field.d.ts +167 -0
  87. package/dist/components/inline-edit-field.js +293 -0
  88. package/dist/components/inline-edit-field.js.map +1 -0
  89. package/dist/components/language-select.d.ts +130 -0
  90. package/dist/components/language-select.js +18 -0
  91. package/dist/components/language-select.js.map +1 -0
  92. package/dist/components/line-items.d.ts +25 -3
  93. package/dist/components/line-items.js +55 -46
  94. package/dist/components/line-items.js.map +1 -1
  95. package/dist/components/list.d.ts +25 -0
  96. package/dist/components/list.js +16 -5
  97. package/dist/components/list.js.map +1 -1
  98. package/dist/components/money-field.d.ts +32 -14
  99. package/dist/components/money-field.js.map +1 -1
  100. package/dist/components/month-picker.d.ts +52 -4
  101. package/dist/components/month-picker.js +291 -86
  102. package/dist/components/month-picker.js.map +1 -1
  103. package/dist/components/multi-entity-combobox.d.ts +26 -1
  104. package/dist/components/multi-entity-combobox.js +143 -102
  105. package/dist/components/multi-entity-combobox.js.map +1 -1
  106. package/dist/components/multi-select.d.ts +8 -1
  107. package/dist/components/multi-select.js +9 -4
  108. package/dist/components/multi-select.js.map +1 -1
  109. package/dist/components/number-field.d.ts +34 -19
  110. package/dist/components/number-field.js +6 -20
  111. package/dist/components/number-field.js.map +1 -1
  112. package/dist/components/number-input.d.ts +79 -19
  113. package/dist/components/number-input.js +90 -38
  114. package/dist/components/number-input.js.map +1 -1
  115. package/dist/components/numpad-sheet.d.ts +19 -14
  116. package/dist/components/one-time-code-input.d.ts +100 -0
  117. package/dist/components/one-time-code-input.js +137 -0
  118. package/dist/components/one-time-code-input.js.map +1 -0
  119. package/dist/components/phone-input.d.ts +69 -0
  120. package/dist/components/phone-input.js +171 -0
  121. package/dist/components/phone-input.js.map +1 -0
  122. package/dist/components/reauth-dialog.d.ts +2 -2
  123. package/dist/components/reauth-dialog.js +6 -8
  124. package/dist/components/reauth-dialog.js.map +1 -1
  125. package/dist/components/series-chart.d.ts +19 -14
  126. package/dist/components/settings-fields.d.ts +19 -14
  127. package/dist/components/share-card.d.ts +16 -11
  128. package/dist/components/sign-chip.d.ts +66 -0
  129. package/dist/components/sign-chip.js +39 -0
  130. package/dist/components/sign-chip.js.map +1 -0
  131. package/dist/components/swatch-picker.d.ts +43 -2
  132. package/dist/components/swatch-picker.js +80 -32
  133. package/dist/components/swatch-picker.js.map +1 -1
  134. package/dist/components/switch.d.ts +19 -0
  135. package/dist/components/switch.js +24 -6
  136. package/dist/components/switch.js.map +1 -1
  137. package/dist/components/text-link.d.ts +18 -13
  138. package/dist/components/tile-radio.d.ts +88 -13
  139. package/dist/components/tile-radio.js +28 -11
  140. package/dist/components/tile-radio.js.map +1 -1
  141. package/dist/components/time-input.d.ts +24 -15
  142. package/dist/components/time-input.js.map +1 -1
  143. package/dist/components/toggle-group.d.ts +47 -1
  144. package/dist/components/toggle-group.js +110 -69
  145. package/dist/components/toggle-group.js.map +1 -1
  146. package/dist/components/trigger-aria.d.ts +3 -0
  147. package/dist/components/trigger-aria.js +3 -1
  148. package/dist/components/trigger-aria.js.map +1 -1
  149. package/dist/components/ui.d.ts +16 -11
  150. package/dist/components/ui.js +284 -88
  151. package/dist/components/ui.js.map +1 -1
  152. package/dist/components/use-table-state.d.ts +1 -2
  153. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  154. package/dist/data-table.d.ts +1 -2
  155. package/dist/data-table.js +2 -1
  156. package/dist/data-table.js.map +1 -1
  157. package/dist/feedback/feedback-attachment.d.ts +1 -1
  158. package/dist/feedback/feedback-attachment.js +348 -41
  159. package/dist/feedback/feedback-attachment.js.map +1 -1
  160. package/dist/feedback/feedback-dialog.d.ts +1 -1
  161. package/dist/feedback/feedback-dialog.js.map +1 -1
  162. package/dist/feedback/feedback-inbox.d.ts +3 -2
  163. package/dist/feedback/feedback-inbox.js.map +1 -1
  164. package/dist/feedback/feedback-thread.d.ts +68 -253
  165. package/dist/feedback/feedback-thread.js +46 -10
  166. package/dist/feedback/feedback-thread.js.map +1 -1
  167. package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
  168. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
  169. package/dist/feedback.d.ts +66 -2
  170. package/dist/hooks/use-file-drop.d.ts +19 -14
  171. package/dist/i18n/defaults.d.ts +19 -14
  172. package/dist/i18n/defaults.js +19 -2
  173. package/dist/i18n/defaults.js.map +1 -1
  174. package/dist/i18n/german.d.ts +19 -14
  175. package/dist/i18n/german.js +84 -5
  176. package/dist/i18n/german.js.map +1 -1
  177. package/dist/i18n/kit-labels.d.ts +15 -10
  178. package/dist/i18n/kit-labels.js +8 -2
  179. package/dist/i18n/kit-labels.js.map +1 -1
  180. package/dist/i18n/languages.d.ts +19 -14
  181. package/dist/i18n/locales/de-CH.d.ts +19 -14
  182. package/dist/i18n/locales/en.d.ts +19 -14
  183. package/dist/i18n/locales/en.js +16 -0
  184. package/dist/i18n/locales/en.js.map +1 -1
  185. package/dist/i18n/locales/es.d.ts +19 -14
  186. package/dist/i18n/locales/es.js +83 -5
  187. package/dist/i18n/locales/es.js.map +1 -1
  188. package/dist/i18n/locales/fr.d.ts +19 -14
  189. package/dist/i18n/locales/fr.js +82 -5
  190. package/dist/i18n/locales/fr.js.map +1 -1
  191. package/dist/i18n/locales/hu.d.ts +19 -14
  192. package/dist/i18n/locales/hu.js +85 -5
  193. package/dist/i18n/locales/hu.js.map +1 -1
  194. package/dist/i18n/locales/it.d.ts +19 -14
  195. package/dist/i18n/locales/it.js +84 -5
  196. package/dist/i18n/locales/it.js.map +1 -1
  197. package/dist/i18n/locales/zh.d.ts +19 -14
  198. package/dist/i18n/locales/zh.js +80 -5
  199. package/dist/i18n/locales/zh.js.map +1 -1
  200. package/dist/i18n/review.d.ts +19 -14
  201. package/dist/i18n/review.js +27 -1
  202. package/dist/i18n/review.js.map +1 -1
  203. package/dist/index.d.ts +21 -10
  204. package/dist/index.js +73 -2
  205. package/dist/index.js.map +1 -1
  206. package/dist/lib/column-mapping.d.ts +81 -0
  207. package/dist/lib/column-mapping.js +108 -0
  208. package/dist/lib/column-mapping.js.map +1 -0
  209. package/dist/lib/countries.d.ts +43 -0
  210. package/dist/lib/countries.js +39 -0
  211. package/dist/lib/countries.js.map +1 -0
  212. package/dist/lib/decimal-marks.d.ts +46 -0
  213. package/dist/lib/decimal-marks.js +40 -0
  214. package/dist/lib/decimal-marks.js.map +1 -0
  215. package/dist/lib/iban.d.ts +98 -0
  216. package/dist/lib/iban.js +154 -0
  217. package/dist/lib/iban.js.map +1 -0
  218. package/dist/lib/isin.d.ts +30 -0
  219. package/dist/lib/isin.js +36 -0
  220. package/dist/lib/isin.js.map +1 -0
  221. package/dist/lib/phone.d.ts +69 -0
  222. package/dist/lib/phone.js +121 -0
  223. package/dist/lib/phone.js.map +1 -0
  224. package/dist/lib/table-text.d.ts +109 -1
  225. package/dist/lib/table-text.js +122 -1
  226. package/dist/lib/table-text.js.map +1 -1
  227. package/dist/rhf/fields.d.ts +233 -18
  228. package/dist/rhf/fields.js +456 -3
  229. package/dist/rhf/fields.js.map +1 -1
  230. package/dist/rhf/form.d.ts +19 -14
  231. package/dist/rhf.d.ts +22 -15
  232. package/dist/rhf.js.map +1 -1
  233. package/dist/shell/app-shell.d.ts +18 -13
  234. package/dist/shell/top-bar-brand.d.ts +19 -14
  235. package/dist/shell/topbar-action-menu.d.ts +36 -3
  236. package/dist/shell/topbar-action-menu.js +74 -33
  237. package/dist/shell/topbar-action-menu.js.map +1 -1
  238. package/dist/shell.d.ts +18 -13
  239. package/dist/table-text.d.ts +1 -1
  240. package/dist/tour/tour.js +20 -24
  241. package/dist/tour/tour.js.map +1 -1
  242. package/dist/wizard/stepper-nav.d.ts +49 -16
  243. package/dist/wizard/stepper-nav.js +4 -0
  244. package/dist/wizard/stepper-nav.js.map +1 -1
  245. package/dist/wizard.d.ts +19 -14
  246. package/package.json +1 -1
  247. package/src/components/account-chips.tsx +17 -3
  248. package/src/components/account-settings.tsx +92 -26
  249. package/src/components/amount-input.tsx +68 -56
  250. package/src/components/autocomplete.tsx +198 -114
  251. package/src/components/checkbox-group.tsx +229 -0
  252. package/src/components/checkbox.tsx +72 -8
  253. package/src/components/chip.tsx +80 -10
  254. package/src/components/column-mapper.tsx +666 -0
  255. package/src/components/combobox-core.tsx +82 -9
  256. package/src/components/combobox.tsx +661 -340
  257. package/src/components/confirm-dialog.tsx +12 -8
  258. package/src/components/country-select.tsx +556 -0
  259. package/src/components/currency-select.tsx +109 -33
  260. package/src/components/danger-confirm.tsx +429 -82
  261. package/src/components/data-table-labels.ts +16 -1
  262. package/src/components/data-table.tsx +175 -6
  263. package/src/components/date-picker.tsx +595 -71
  264. package/src/components/entity-combobox.tsx +235 -124
  265. package/src/components/field-parts.tsx +403 -0
  266. package/src/components/field-strip.tsx +149 -0
  267. package/src/components/file-button.tsx +36 -3
  268. package/src/components/file-dropzone.tsx +106 -38
  269. package/src/components/form-actions.tsx +147 -6
  270. package/src/components/iban-input.tsx +231 -0
  271. package/src/components/icon-picker.tsx +47 -6
  272. package/src/components/inline-edit-field.tsx +533 -0
  273. package/src/components/language-select.tsx +78 -0
  274. package/src/components/line-items.tsx +35 -2
  275. package/src/components/list.tsx +55 -6
  276. package/src/components/money-field.tsx +13 -0
  277. package/src/components/month-picker.tsx +400 -63
  278. package/src/components/multi-entity-combobox.tsx +207 -120
  279. package/src/components/multi-select.tsx +80 -58
  280. package/src/components/number-field.tsx +28 -46
  281. package/src/components/number-input.tsx +210 -43
  282. package/src/components/one-time-code-input.tsx +288 -0
  283. package/src/components/phone-input.tsx +299 -0
  284. package/src/components/reauth-dialog.tsx +17 -18
  285. package/src/components/sign-chip.tsx +103 -0
  286. package/src/components/swatch-picker.tsx +143 -31
  287. package/src/components/switch.tsx +58 -7
  288. package/src/components/tile-radio.tsx +145 -29
  289. package/src/components/time-input.tsx +8 -1
  290. package/src/components/toggle-group.tsx +206 -73
  291. package/src/components/trigger-aria.ts +5 -0
  292. package/src/components/ui.tsx +479 -138
  293. package/src/data-table.ts +2 -1
  294. package/src/feedback/feedback-attachment.tsx +597 -46
  295. package/src/feedback/feedback-dialog.tsx +9 -3
  296. package/src/feedback/feedback-inbox.tsx +3 -2
  297. package/src/feedback/feedback-thread.tsx +113 -9
  298. package/src/i18n/defaults.ts +18 -1
  299. package/src/i18n/german.ts +89 -0
  300. package/src/i18n/kit-labels.tsx +82 -2
  301. package/src/i18n/locales/en.ts +36 -4
  302. package/src/i18n/locales/es.ts +87 -0
  303. package/src/i18n/locales/fr.ts +87 -0
  304. package/src/i18n/locales/hu.ts +83 -0
  305. package/src/i18n/locales/it.ts +88 -0
  306. package/src/i18n/locales/zh.ts +76 -0
  307. package/src/i18n/review.ts +26 -0
  308. package/src/index.ts +71 -3
  309. package/src/lib/column-mapping.ts +234 -0
  310. package/src/lib/countries.ts +91 -0
  311. package/src/lib/decimal-marks.ts +99 -0
  312. package/src/lib/iban.ts +171 -0
  313. package/src/lib/isin.ts +62 -0
  314. package/src/lib/phone.ts +211 -0
  315. package/src/lib/table-text.ts +271 -0
  316. package/src/rhf/fields.tsx +860 -9
  317. package/src/rhf.ts +3 -1
  318. package/src/shell/topbar-action-menu.tsx +134 -38
  319. package/src/tour/tour.tsx +66 -28
  320. package/src/wizard/stepper-nav.tsx +35 -1
@@ -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";
3
- import { Camera, FileText, Paperclip, X } from "lucide-react";
4
- import { Button } from "../components/ui";
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` mode: more
61
- * files arrived at once than `max` left room for, and the surplus was dropped. */
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
- 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;
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
- 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;
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
- // Two components rather than one with branches: the modes hold different state
158
- // (one preview vs. one per chip), and a hook order that depends on a prop is a
159
- // crash the day a caller flips it.
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
- <button
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 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
+ >
262
446
  <Paperclip className="size-4" /> {text.attachmentAdd}
263
447
  </Button>
264
448
  {onCaptureScreenshot && (
265
- <CaptureButton capture={onCaptureScreenshot} onFile={pick} label={text.attachmentCapture} />
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
- <button
424
- type="button"
425
- data-attachment-remove=""
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
- aria-label={removeLabel(chip.name)}
431
- className="shrink-0 rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
432
- >
433
- <X className="size-4" />
434
- </button>
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
- 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);
604
1151
  useEffect(() => {
605
- latest.current = onImages;
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) => takeImages(event.clipboardData, () => event.preventDefault(), latest.current);
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) {