@eifi1/ui-kit 0.6.1 → 0.7.1

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 (271) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +5 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +33 -6
  9. package/dist/components/autocomplete.js +62 -16
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +5 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/chip.d.ts +34 -14
  20. package/dist/components/chip.js +63 -28
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +18 -1
  23. package/dist/components/choice-card.js +21 -8
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/combobox-core.d.ts +61 -2
  26. package/dist/components/combobox-core.js +68 -17
  27. package/dist/components/combobox-core.js.map +1 -1
  28. package/dist/components/combobox.d.ts +11 -7
  29. package/dist/components/combobox.js +51 -28
  30. package/dist/components/combobox.js.map +1 -1
  31. package/dist/components/currency-select.js +3 -3
  32. package/dist/components/currency-select.js.map +1 -1
  33. package/dist/components/danger-confirm.d.ts +30 -6
  34. package/dist/components/danger-confirm.js +51 -32
  35. package/dist/components/danger-confirm.js.map +1 -1
  36. package/dist/components/data-table-filter-popover.d.ts +1 -1
  37. package/dist/components/data-table-filters.d.ts +1 -1
  38. package/dist/components/data-table-pagination.d.ts +6 -3
  39. package/dist/components/data-table-pagination.js +6 -5
  40. package/dist/components/data-table-pagination.js.map +1 -1
  41. package/dist/components/data-table.d.ts +1 -1
  42. package/dist/components/data-table.js +332 -248
  43. package/dist/components/data-table.js.map +1 -1
  44. package/dist/components/date-picker.js +17 -7
  45. package/dist/components/date-picker.js.map +1 -1
  46. package/dist/components/dialog-frame.d.ts +28 -3
  47. package/dist/components/dialog-frame.js +47 -11
  48. package/dist/components/dialog-frame.js.map +1 -1
  49. package/dist/components/disclosure.d.ts +22 -2
  50. package/dist/components/disclosure.js +41 -8
  51. package/dist/components/disclosure.js.map +1 -1
  52. package/dist/components/dropdown.d.ts +7 -3
  53. package/dist/components/dropdown.js +6 -2
  54. package/dist/components/dropdown.js.map +1 -1
  55. package/dist/components/entity-combobox.d.ts +17 -8
  56. package/dist/components/entity-combobox.js +7 -5
  57. package/dist/components/entity-combobox.js.map +1 -1
  58. package/dist/components/file-button.d.ts +38 -5
  59. package/dist/components/file-button.js +23 -2
  60. package/dist/components/file-button.js.map +1 -1
  61. package/dist/components/file-dropzone.d.ts +11 -5
  62. package/dist/components/file-dropzone.js +14 -19
  63. package/dist/components/file-dropzone.js.map +1 -1
  64. package/dist/components/grouped-picker.js +2 -2
  65. package/dist/components/grouped-picker.js.map +1 -1
  66. package/dist/components/hover-menu.d.ts +8 -3
  67. package/dist/components/hover-menu.js +88 -4
  68. package/dist/components/hover-menu.js.map +1 -1
  69. package/dist/components/icon-picker.d.ts +10 -1
  70. package/dist/components/icon-picker.js +5 -1
  71. package/dist/components/icon-picker.js.map +1 -1
  72. package/dist/components/measured-grid.d.ts +165 -0
  73. package/dist/components/measured-grid.js +559 -0
  74. package/dist/components/measured-grid.js.map +1 -0
  75. package/dist/components/mini-calendar.js +7 -8
  76. package/dist/components/mini-calendar.js.map +1 -1
  77. package/dist/components/month-picker.js +13 -9
  78. package/dist/components/month-picker.js.map +1 -1
  79. package/dist/components/multi-entity-combobox.js +2 -2
  80. package/dist/components/multi-entity-combobox.js.map +1 -1
  81. package/dist/components/multi-select.d.ts +9 -1
  82. package/dist/components/multi-select.js +22 -8
  83. package/dist/components/multi-select.js.map +1 -1
  84. package/dist/components/number-field.d.ts +5 -0
  85. package/dist/components/number-input.d.ts +5 -0
  86. package/dist/components/number-input.js +3 -3
  87. package/dist/components/number-input.js.map +1 -1
  88. package/dist/components/numpad-sheet.d.ts +5 -0
  89. package/dist/components/numpad-sheet.js +3 -3
  90. package/dist/components/numpad-sheet.js.map +1 -1
  91. package/dist/components/picker-sheet.js +1 -1
  92. package/dist/components/picker-sheet.js.map +1 -1
  93. package/dist/components/popover.js +11 -2
  94. package/dist/components/popover.js.map +1 -1
  95. package/dist/components/series-chart-ticks.d.ts +13 -0
  96. package/dist/components/series-chart-ticks.js +29 -0
  97. package/dist/components/series-chart-ticks.js.map +1 -0
  98. package/dist/components/series-chart.js +33 -27
  99. package/dist/components/series-chart.js.map +1 -1
  100. package/dist/components/settings-fields.d.ts +2 -0
  101. package/dist/components/swatch-picker.d.ts +10 -1
  102. package/dist/components/swatch-picker.js +2 -0
  103. package/dist/components/swatch-picker.js.map +1 -1
  104. package/dist/components/swipeable-row.d.ts +11 -2
  105. package/dist/components/swipeable-row.js +5 -3
  106. package/dist/components/swipeable-row.js.map +1 -1
  107. package/dist/components/tile-radio.js +5 -7
  108. package/dist/components/tile-radio.js.map +1 -1
  109. package/dist/components/time-input.d.ts +2 -0
  110. package/dist/components/toggle-group.d.ts +13 -3
  111. package/dist/components/toggle-group.js +21 -1
  112. package/dist/components/toggle-group.js.map +1 -1
  113. package/dist/components/toggle-legend.js +1 -2
  114. package/dist/components/toggle-legend.js.map +1 -1
  115. package/dist/components/tooltip.d.ts +10 -4
  116. package/dist/components/tooltip.js +23 -6
  117. package/dist/components/tooltip.js.map +1 -1
  118. package/dist/components/treemap.d.ts +6 -1
  119. package/dist/components/treemap.js +16 -6
  120. package/dist/components/treemap.js.map +1 -1
  121. package/dist/components/ui.d.ts +36 -8
  122. package/dist/components/ui.js +27 -19
  123. package/dist/components/ui.js.map +1 -1
  124. package/dist/components/use-anchor-dir.d.ts +15 -0
  125. package/dist/components/use-anchor-dir.js +14 -0
  126. package/dist/components/use-anchor-dir.js.map +1 -0
  127. package/dist/components/use-mobile-reveal.d.ts +8 -1
  128. package/dist/components/use-mobile-reveal.js +3 -2
  129. package/dist/components/use-mobile-reveal.js.map +1 -1
  130. package/dist/components/use-table-state.d.ts +4 -4
  131. package/dist/components/use-table-state.js +12 -5
  132. package/dist/components/use-table-state.js.map +1 -1
  133. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  134. package/dist/data-table.d.ts +1 -1
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/feedback/feedback-attachment.d.ts +22 -3
  137. package/dist/feedback/feedback-attachment.js +15 -7
  138. package/dist/feedback/feedback-attachment.js.map +1 -1
  139. package/dist/feedback/feedback-dialog.d.ts +3 -2
  140. package/dist/feedback/feedback-dialog.js.map +1 -1
  141. package/dist/feedback.d.ts +1 -1
  142. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  143. package/dist/hooks/use-windowed-rows.js +31 -0
  144. package/dist/hooks/use-windowed-rows.js.map +1 -0
  145. package/dist/i18n/defaults.d.ts +5 -0
  146. package/dist/i18n/defaults.js +5 -1
  147. package/dist/i18n/defaults.js.map +1 -1
  148. package/dist/i18n/kit-labels.d.ts +7 -0
  149. package/dist/i18n/kit-labels.js.map +1 -1
  150. package/dist/i18n/locales/de-CH.d.ts +45 -0
  151. package/dist/i18n/locales/de-CH.js +21 -0
  152. package/dist/i18n/locales/de-CH.js.map +1 -0
  153. package/dist/i18n/locales/de.d.ts +45 -0
  154. package/dist/i18n/locales/de.js +344 -0
  155. package/dist/i18n/locales/de.js.map +1 -0
  156. package/dist/i18n/locales/es.d.ts +45 -0
  157. package/dist/i18n/locales/es.js +339 -0
  158. package/dist/i18n/locales/es.js.map +1 -0
  159. package/dist/i18n/locales/fr.d.ts +45 -0
  160. package/dist/i18n/locales/fr.js +344 -0
  161. package/dist/i18n/locales/fr.js.map +1 -0
  162. package/dist/i18n/locales/hu.d.ts +45 -0
  163. package/dist/i18n/locales/hu.js +343 -0
  164. package/dist/i18n/locales/hu.js.map +1 -0
  165. package/dist/i18n/locales/it.d.ts +45 -0
  166. package/dist/i18n/locales/it.js +342 -0
  167. package/dist/i18n/locales/it.js.map +1 -0
  168. package/dist/i18n/locales/zh.d.ts +45 -0
  169. package/dist/i18n/locales/zh.js +338 -0
  170. package/dist/i18n/locales/zh.js.map +1 -0
  171. package/dist/index.d.ts +7 -4
  172. package/dist/index.js +4 -1
  173. package/dist/index.js.map +1 -1
  174. package/dist/lib/direction.d.ts +24 -0
  175. package/dist/lib/direction.js +19 -0
  176. package/dist/lib/direction.js.map +1 -0
  177. package/dist/rhf/form.d.ts +2 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/rhf.d.ts +2 -0
  180. package/dist/search/command-palette.d.ts +5 -0
  181. package/dist/search/command-palette.js +80 -50
  182. package/dist/search/command-palette.js.map +1 -1
  183. package/dist/shell/app-shell.d.ts +13 -1
  184. package/dist/shell/app-shell.js +134 -85
  185. package/dist/shell/app-shell.js.map +1 -1
  186. package/dist/shell/topbar-action-menu.js +1 -1
  187. package/dist/shell/topbar-action-menu.js.map +1 -1
  188. package/dist/shell/topbar-controls.d.ts +7 -3
  189. package/dist/shell/topbar-controls.js +38 -31
  190. package/dist/shell/topbar-controls.js.map +1 -1
  191. package/dist/theme/chart-palette.d.ts +7 -1
  192. package/dist/theme/chart-palette.js +2 -1
  193. package/dist/theme/chart-palette.js.map +1 -1
  194. package/dist/tour/tour.d.ts +11 -5
  195. package/dist/tour/tour.js +124 -58
  196. package/dist/tour/tour.js.map +1 -1
  197. package/dist/wizard/use-wizard.js +30 -13
  198. package/dist/wizard/use-wizard.js.map +1 -1
  199. package/dist/wizard/wizard-summary.js +1 -1
  200. package/dist/wizard/wizard-summary.js.map +1 -1
  201. package/package.json +12 -2
  202. package/src/components/account-settings.tsx +6 -2
  203. package/src/components/amount-input.tsx +10 -8
  204. package/src/components/autocomplete.tsx +110 -24
  205. package/src/components/calculator.tsx +5 -1
  206. package/src/components/chart-zoom.tsx +8 -1
  207. package/src/components/chart.tsx +68 -7
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +74 -5
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/tile-radio.tsx +5 -7
  240. package/src/components/toggle-group.tsx +39 -3
  241. package/src/components/toggle-legend.tsx +5 -2
  242. package/src/components/tooltip.tsx +40 -12
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +79 -33
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +201 -130
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/file-button.tsx"],"sourcesContent":["import { forwardRef, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner } from \"./ui\";\n\n/**\n * A button that opens the file picker — the shape all three apps kept writing by hand\n * as a `<Button>` plus a hidden `<input type=\"file\">` plus a ref between them (seven\n * copies in keksdose, two each in kastlan and lenkbank).\n *\n * Every copy had to remember the same four things, and each one forgot at least one:\n *\n * 1. **Reset the input after every pick.** A file input fires `change` only when its\n * value CHANGES, so picking the same file twice in a row — the retry after a failed\n * upload, the second photo of the same receipt — did nothing at all, which reads as\n * a broken button. `value = \"\"` after each pick; always, not per call site.\n * 2. **`type=\"button\"`.** `<Button>` does not set it, and inside a form a bare button\n * submits the form before the picker opens.\n * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's\n * \"All files\" switch, a drop, and a mobile share sheet all hand over whatever the\n * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and\n * an optional `isValid`.\n * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a\n * translated message per file, and spoken through a live region — the kit does not\n * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).\n *\n * The part that is not a button — the hidden input, the check, the announcement — is\n * {@link useFilePicker}, for the case where the thing that opens the picker is someone\n * else's control (a card's \"Add\" action, a menu item, a second button for the camera).\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`. */\n rejectedType: (name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n}\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n};\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? labels.rejectedType(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array. */\n onFiles: (files: File[]) => void;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (accepted.length > 0) onFiles(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n\n/* ── FileButton ──────────────────────────────────────────────────────────── */\n\ntype ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;\n\nexport interface FileButtonProps\n extends Omit<UseFilePickerOptions, \"disabled\">,\n // `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to\n // \"button\" (see 2. above) — a file trigger never submits a form.\n Omit<ButtonOwnProps, \"type\" | \"children\" | \"accept\" | \"capture\" | \"multiple\"> {\n /** The button's content — usually an icon and a word. It is the accessible name. */\n children: ReactNode;\n /** Busy: disabled, `aria-busy`, and a spinner before the content — for \"uploading\". */\n pending?: boolean;\n /** Also accept files dropped ON the button (lenkbank's \"drop onto the button\").\n * Off by default: a button that silently takes drops is a surprise on a page that\n * has a real drop target elsewhere. */\n droppable?: boolean;\n}\n\n/**\n * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop\n * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the\n * picker from elsewhere too).\n *\n * ```tsx\n * <FileButton accept=\"image/*,application/pdf\" capture=\"environment\" variant=\"secondary\"\n * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>\n * <Camera aria-hidden /> Photograph receipt\n * </FileButton>\n * ```\n */\nexport const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(\n {\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onReject,\n labels,\n pending,\n droppable,\n disabled,\n children,\n className,\n onClick,\n onDragEnter,\n onDragOver,\n onDragLeave,\n onDrop,\n ...rest\n },\n ref,\n) {\n const inert = Boolean(disabled || pending);\n const picker = useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onReject,\n labels,\n disabled: inert,\n });\n const [dragOver, setDragOver] = useState(false);\n\n const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n // Only when `droppable`: otherwise the caller's own handlers are passed untouched.\n const dropHandlers = droppable\n ? {\n onDragEnter: (e: DragEvent<HTMLButtonElement>) => {\n onDragEnter?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n if (!inert) setDragOver(true);\n },\n onDragOver: (e: DragEvent<HTMLButtonElement>) => {\n onDragOver?.(e);\n if (!hasFiles(e)) return;\n // Always cancel for a file drag, busy or not: an uncancelled drop makes the\n // browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = inert ? \"none\" : \"copy\";\n },\n onDragLeave: (e: DragEvent<HTMLButtonElement>) => {\n onDragLeave?.(e);\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n setDragOver(false);\n },\n onDrop: (e: DragEvent<HTMLButtonElement>) => {\n onDrop?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n setDragOver(false);\n picker.take(e.dataTransfer.files);\n },\n }\n : { onDragEnter, onDragOver, onDragLeave, onDrop };\n\n // `Button` is a plain function component; under React 19 `ref` is an ordinary prop\n // and reaches the `<button>` through its `...rest`. Its props type just does not\n // declare it, hence the widening here rather than a second button implementation.\n const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };\n\n return (\n <>\n <Button\n {...rest}\n {...refProp}\n {...dropHandlers}\n type=\"button\"\n disabled={inert}\n aria-busy={pending || undefined}\n data-drag-over={dragOver || undefined}\n className={cn(\n // A live drag gets the focus ring's colour as a ring: \"let go here\", in the\n // same vocabulary the button already uses for \"you are here\".\n dragOver && \"ring-2 ring-[var(--brand)]\",\n className,\n )}\n onClick={(e) => {\n onClick?.(e);\n if (!e.defaultPrevented) picker.open();\n }}\n >\n {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same\n thing on the button, and a spoken \"Loading\" would run into its name. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {children}\n </Button>\n {picker.element}\n </>\n );\n});\nFileButton.displayName = \"FileButton\";\n"],"mappings":"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","names":["FileButton"]}
1
+ {"version":3,"sources":["../../src/components/file-button.tsx"],"sourcesContent":["import { forwardRef, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner } from \"./ui\";\n\n/**\n * A button that opens the file picker — the shape all three apps kept writing by hand\n * as a `<Button>` plus a hidden `<input type=\"file\">` plus a ref between them (seven\n * copies in keksdose, two each in kastlan and lenkbank).\n *\n * Every copy had to remember the same four things, and each one forgot at least one:\n *\n * 1. **Reset the input after every pick.** A file input fires `change` only when its\n * value CHANGES, so picking the same file twice in a row — the retry after a failed\n * upload, the second photo of the same receipt — did nothing at all, which reads as\n * a broken button. `value = \"\"` after each pick; always, not per call site.\n * 2. **`type=\"button\"`.** `<Button>` does not set it, and inside a form a bare button\n * submits the form before the picker opens.\n * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's\n * \"All files\" switch, a drop, and a mobile share sheet all hand over whatever the\n * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and\n * an optional `isValid`.\n * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a\n * translated message per file, and spoken through a live region — the kit does not\n * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).\n *\n * The part that is not a button — the hidden input, the check, the announcement — is\n * {@link useFilePicker}, for the case where the thing that opens the picker is someone\n * else's control (a card's \"Add\" action, a menu item, a second button for the camera).\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`, when no `accept` is known to name (see\n * `rejectedTypeOnly`). */\n rejectedType: (name: string) => string;\n /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,\n * lower-cased and joined with \", \" (`\".csv\"` => \"Only .csv files\"). Used whenever\n * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only\n * to say this, and `accept` already knows it. */\n rejectedTypeOnly: (accept: string, name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.\n * Receives the number of files in it. */\n rejectedPick: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n}\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedTypeOnly: (accept) => `Only ${accept} files`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n rejectedPick: (count) =>\n count === 1 ? \"The file was not added\" : `None of the ${count} files were added`,\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n};\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** `\" .PDF, image/png ,\"` => `\".pdf, image/png\"` — `accept` as a person reads it. */\nfunction formatAccept(accept: string): string {\n return accept\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean)\n .join(\", \");\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n // Name what `accept` takes (\"Only .csv files\") — unless the host translated\n // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.\n const acceptText = opts.accept ? formatAccept(opts.accept) : \"\";\n const useOnly =\n acceptText !== \"\" &&\n (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||\n labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);\n const typeMessage = (name: string) =>\n useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? typeMessage(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/**\n * Judges a pick as a whole — keksdose's \"refuse the pick if any file is bad\", which\n * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.\n *\n * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both\n * halves (either may be empty). Return `false` to refuse the whole pick: the accepted\n * files are not delivered, `onReject` still receives what screening refused, and one\n * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.\n * Return nothing to let the pick through as usual.\n *\n * ```tsx\n * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing\n * ```\n */\nexport type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;\n\n/** @internal The shared tail of both pickers: run `onPick`, and say whether the pick\n * stands. A pick with nothing accepted has nothing to refuse, so `false` there is\n * treated as no verdict and the usual per-file messages are kept. */\nexport function judgePick(\n onPick: FilePickHandler | undefined,\n accepted: File[],\n rejected: FileRejection[],\n): boolean {\n if (!onPick) return true;\n return onPick(accepted, rejected) !== false || accepted.length === 0;\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array.\n * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */\n onFiles?: (files: File[]) => void;\n /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */\n onPick?: FilePickHandler;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (!judgePick(onPick, accepted, rejected)) {\n if (rejected.length > 0) onReject?.(rejected);\n announce(labels.rejectedPick(files.length));\n return;\n }\n if (accepted.length > 0) onFiles?.(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n\n/* ── FileButton ──────────────────────────────────────────────────────────── */\n\ntype ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;\n\nexport interface FileButtonProps\n extends Omit<UseFilePickerOptions, \"disabled\">,\n // `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to\n // \"button\" (see 2. above) — a file trigger never submits a form.\n Omit<ButtonOwnProps, \"type\" | \"children\" | \"accept\" | \"capture\" | \"multiple\"> {\n /** The button's content — usually an icon and a word. It is the accessible name. */\n children: ReactNode;\n /** Busy: disabled, `aria-busy`, and a spinner before the content — for \"uploading\". */\n pending?: boolean;\n /** Also accept files dropped ON the button (lenkbank's \"drop onto the button\").\n * Off by default: a button that silently takes drops is a surprise on a page that\n * has a real drop target elsewhere. */\n droppable?: boolean;\n}\n\n/**\n * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop\n * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the\n * picker from elsewhere too).\n *\n * ```tsx\n * <FileButton accept=\"image/*,application/pdf\" capture=\"environment\" variant=\"secondary\"\n * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>\n * <Camera aria-hidden /> Photograph receipt\n * </FileButton>\n * ```\n */\nexport const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(\n {\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n labels,\n pending,\n droppable,\n disabled,\n children,\n className,\n onClick,\n onDragEnter,\n onDragOver,\n onDragLeave,\n onDrop,\n ...rest\n },\n ref,\n) {\n const inert = Boolean(disabled || pending);\n const picker = useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onPick,\n onReject,\n labels,\n disabled: inert,\n });\n const [dragOver, setDragOver] = useState(false);\n\n const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n // Only when `droppable`: otherwise the caller's own handlers are passed untouched.\n const dropHandlers = droppable\n ? {\n onDragEnter: (e: DragEvent<HTMLButtonElement>) => {\n onDragEnter?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n if (!inert) setDragOver(true);\n },\n onDragOver: (e: DragEvent<HTMLButtonElement>) => {\n onDragOver?.(e);\n if (!hasFiles(e)) return;\n // Always cancel for a file drag, busy or not: an uncancelled drop makes the\n // browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = inert ? \"none\" : \"copy\";\n },\n onDragLeave: (e: DragEvent<HTMLButtonElement>) => {\n onDragLeave?.(e);\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n setDragOver(false);\n },\n onDrop: (e: DragEvent<HTMLButtonElement>) => {\n onDrop?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n setDragOver(false);\n picker.take(e.dataTransfer.files);\n },\n }\n : { onDragEnter, onDragOver, onDragLeave, onDrop };\n\n // `Button` is a plain function component; under React 19 `ref` is an ordinary prop\n // and reaches the `<button>` through its `...rest`. Its props type just does not\n // declare it, hence the widening here rather than a second button implementation.\n const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };\n\n return (\n <>\n <Button\n {...rest}\n {...refProp}\n {...dropHandlers}\n type=\"button\"\n disabled={inert}\n aria-busy={pending || undefined}\n data-drag-over={dragOver || undefined}\n className={cn(\n // A live drag gets the focus ring's colour as a ring: \"let go here\", in the\n // same vocabulary the button already uses for \"you are here\".\n dragOver && \"ring-2 ring-[var(--brand)]\",\n className,\n )}\n onClick={(e) => {\n onClick?.(e);\n if (!e.defaultPrevented) picker.open();\n }}\n >\n {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same\n thing on the button, and a spoken \"Loading\" would run into its name. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {children}\n </Button>\n {picker.element}\n </>\n );\n});\nFileButton.displayName = \"FileButton\";\n"],"mappings":"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","names":["FileButton"]}
@@ -1,7 +1,9 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentPropsWithoutRef } from 'react';
3
- import { FileRejection, FilePickerLabels } from './file-button.js';
3
+ import { FileRejection, FilePickHandler, FilePickerLabels } from './file-button.js';
4
4
  import './ui.js';
5
+ import './tooltip.js';
6
+ import '../hooks/use-anchored-rect.js';
5
7
 
6
8
  /**
7
9
  * Where a refused file's message goes.
@@ -20,8 +22,8 @@ type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
20
22
  * The DOM event of that name belongs to form validation and takes a `FormEvent`; this
21
23
  * one takes the rejected `File`, and the kit's meaning is the one every caller already
22
24
  * writes. Everything else a `<div>` takes reaches the root — which carries
23
- * `role="button"`, so that is also where an `aria-label` or an `aria-describedby`
24
- * belongs.
25
+ * `role="group"` named by `dropLabel`, so that is also where an `aria-label` or an
26
+ * `aria-describedby` belongs.
25
27
  *
26
28
  * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE
27
29
  * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so
@@ -43,7 +45,7 @@ interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInv
43
45
  * Show a remove button inside the zone once something is chosen (kastlan asked for
44
46
  * it: the only way to un-choose a file was to reload). Single mode: removes the file.
45
47
  * Multiple mode: a "remove all" button. The host clears its own state here — the
46
- * zone is controlled. Focus returns to the zone, and the removal is announced.
48
+ * zone is controlled. Focus moves to the Browse button, and the removal is announced.
47
49
  */
48
50
  onClear?: () => void;
49
51
  /** Multiple mode: a remove button per listed file. */
@@ -75,6 +77,10 @@ interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInv
75
77
  onInvalid?: (file: File) => void;
76
78
  /** Every refused file of one pick, with its reason and message. */
77
79
  onReject?: (rejections: FileRejection[]) => void;
80
+ /** The whole pick at once, before any of the above — return `false` to refuse all
81
+ * of it (see {@link FilePickHandler}). A refused pick selects nothing; its one
82
+ * message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */
83
+ onPick?: FilePickHandler;
78
84
  /** See {@link FileDropzoneRejectionFeedback}. Defaults to `"toast"` when neither
79
85
  * `onInvalid` nor `onReject` is given, `"none"` when one is. */
80
86
  rejectionFeedback?: FileDropzoneRejectionFeedback;
@@ -94,6 +100,6 @@ interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInv
94
100
  * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise
95
101
  * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see
96
102
  * `FileButton`. */
97
- declare function FileDropzone({ file, onFileSelected, multiple, files, onFilesSelected, onClear, onRemove, accept, isValid, invalidMessage, maxSize, maxFiles, onInvalid, onReject, rejectionFeedback, labels: labelsProp, dropLabel, browseLabel, emptyLabel, hint, className, "aria-label": ariaLabel, "aria-describedby": describedByProp, ...rest }: FileDropzoneProps): react.JSX.Element;
103
+ declare function FileDropzone({ file, onFileSelected, multiple, files, onFilesSelected, onClear, onRemove, accept, isValid, invalidMessage, maxSize, maxFiles, onInvalid, onReject, onPick, rejectionFeedback, labels: labelsProp, dropLabel, browseLabel, emptyLabel, hint, className, "aria-label": ariaLabel, "aria-describedby": describedByProp, ...rest }: FileDropzoneProps): react.JSX.Element;
98
104
 
99
105
  export { FileDropzone, type FileDropzoneProps, type FileDropzoneRejectionFeedback };
@@ -6,7 +6,7 @@ import { Button, IconButton } from "./ui.js";
6
6
  import { cn } from "../lib/cn.js";
7
7
  import { useAnnounce } from "../hooks/use-announce.js";
8
8
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
9
- import { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from "./file-button.js";
9
+ import { DEFAULT_FILE_PICKER_LABELS, judgePick, screenFiles, summariseRejections } from "./file-button.js";
10
10
  function FileDropzone({
11
11
  file,
12
12
  onFileSelected,
@@ -22,6 +22,7 @@ function FileDropzone({
22
22
  maxFiles,
23
23
  onInvalid,
24
24
  onReject,
25
+ onPick,
25
26
  rejectionFeedback,
26
27
  labels: labelsProp,
27
28
  dropLabel,
@@ -36,7 +37,7 @@ function FileDropzone({
36
37
  const [dragOver, setDragOver] = useState(false);
37
38
  const [error, setError] = useState(null);
38
39
  const fileInputRef = useRef(null);
39
- const rootRef = useRef(null);
40
+ const browseRef = useRef(null);
40
41
  const errorId = useId();
41
42
  const fileText = useKitFileLabels();
42
43
  const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
@@ -46,8 +47,9 @@ function FileDropzone({
46
47
  const acceptFiles = (list) => {
47
48
  if (!list || list.length === 0) return;
48
49
  const all = Array.from(list);
50
+ const picked = multiple ? all : all.slice(0, 1);
49
51
  const { accepted, rejected } = screenFiles(
50
- multiple ? all : all.slice(0, 1),
52
+ picked,
51
53
  {
52
54
  accept: isValid ? void 0 : accept,
53
55
  maxSize,
@@ -58,7 +60,8 @@ function FileDropzone({
58
60
  labels,
59
61
  fileText.size
60
62
  );
61
- if (accepted.length > 0) {
63
+ const stands = judgePick(onPick, accepted, rejected);
64
+ if (stands && accepted.length > 0) {
62
65
  setError(null);
63
66
  if (multiple) {
64
67
  onFilesSelected?.(accepted);
@@ -68,10 +71,10 @@ function FileDropzone({
68
71
  }
69
72
  status.announce(labels.selected(accepted.length, accepted[0].name));
70
73
  }
71
- if (rejected.length > 0) {
74
+ if (!stands || rejected.length > 0) {
72
75
  for (const r of rejected) onInvalid?.(r.file);
73
- onReject?.(rejected);
74
- const message = summariseRejections(rejected, labels);
76
+ if (rejected.length > 0) onReject?.(rejected);
77
+ const message = stands ? summariseRejections(rejected, labels) : labels.rejectedPick(picked.length);
75
78
  if (feedback === "toast") {
76
79
  void import("sonner").then(({ toast }) => toast.error(message));
77
80
  } else {
@@ -81,7 +84,7 @@ function FileDropzone({
81
84
  }
82
85
  };
83
86
  const afterRemoval = (message) => {
84
- rootRef.current?.focus();
87
+ browseRef.current?.focus();
85
88
  status.announce(message);
86
89
  };
87
90
  const chosen = multiple ? files ?? [] : file ? [file] : [];
@@ -92,7 +95,6 @@ function FileDropzone({
92
95
  "div",
93
96
  {
94
97
  ...rest,
95
- ref: rootRef,
96
98
  onDragEnter: (e) => {
97
99
  e.preventDefault();
98
100
  e.stopPropagation();
@@ -117,15 +119,7 @@ function FileDropzone({
117
119
  acceptFiles(e.dataTransfer.files);
118
120
  },
119
121
  onClick: () => fileInputRef.current?.click(),
120
- onKeyDown: (e) => {
121
- if (e.target !== e.currentTarget) return;
122
- if (e.key === "Enter" || e.key === " ") {
123
- e.preventDefault();
124
- fileInputRef.current?.click();
125
- }
126
- },
127
- role: "button",
128
- tabIndex: 0,
122
+ role: "group",
129
123
  "aria-label": ariaLabel ?? dropLabel,
130
124
  "aria-describedby": describedBy,
131
125
  "data-invalid": showError || void 0,
@@ -176,7 +170,7 @@ function FileDropzone({
176
170
  `${f.name}-${f.size}-${i}`
177
171
  )) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
178
172
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 text-sm text-[var(--text-secondary)]", children: [
179
- file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel,
173
+ !multiple && file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel,
180
174
  !multiple && file && onClear && /* @__PURE__ */ jsx(
181
175
  IconButton,
182
176
  {
@@ -198,6 +192,7 @@ function FileDropzone({
198
192
  /* @__PURE__ */ jsx(
199
193
  Button,
200
194
  {
195
+ ref: browseRef,
201
196
  type: "button",
202
197
  variant: "secondary",
203
198
  onClick: (e) => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { Button, IconButton } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from \"./file-button\";\nimport type { FilePickerLabels, FileRejection } from \"./file-button\";\n\n/**\n * Where a refused file's message goes.\n *\n * - `\"toast\"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller\n * that handles nothing itself so no existing dropzone goes quiet on upgrade).\n * - `\"inline\"` — under the drop target, in the danger colour, tied to it with\n * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live\n * region. Cleared by the next accepted pick.\n * - `\"none\"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.\n * Still spoken, so a host rendering it should not ALSO make it `role=\"alert\"`.\n */\nexport type FileDropzoneRejectionFeedback = \"toast\" | \"inline\" | \"none\";\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"button\"`, so that is also where an `aria-label` or an `aria-describedby`\n * belongs.\n *\n * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE\n * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so\n * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also\n * reaches `onFilesSelected` as a one-element array, so one handler can serve both.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n /** Single mode: the chosen file, echoed with its size. */\n file?: File | null;\n /** Single mode: called with the accepted file. */\n onFileSelected?: (file: File) => void;\n /** Take several files per pick or drop. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /** Multiple mode: the chosen files, listed in the zone. */\n files?: readonly File[];\n /** Every accepted file of one pick, in order. Never called with an empty array. */\n onFilesSelected?: (files: File[]) => void;\n /**\n * Show a remove button inside the zone once something is chosen (kastlan asked for\n * it: the only way to un-choose a file was to reload). Single mode: removes the file.\n * Multiple mode: a \"remove all\" button. The host clears its own state here — the\n * zone is controlled. Focus returns to the zone, and the removal is announced.\n */\n onClear?: () => void;\n /** Multiple mode: a remove button per listed file. */\n onRemove?: (file: File, index: number) => void;\n accept: string;\n /**\n * The caller's check. Optional since 0.6: without it the zone checks `accept` itself\n * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every\n * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file\n * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).\n */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */\n invalidMessage?: string;\n /** Bytes; larger files are refused. */\n maxSize?: number;\n /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */\n maxFiles?: number;\n /**\n * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}\n * carries the reason and the translated message as well.\n *\n * Its existence used to be what switched the toast off, and it still does (see\n * {@link rejectionFeedback}). The toast is a dynamic `import(\"sonner\")` on the\n * failure path only: `sonner` is an OPTIONAL peer, and a static import here once\n * broke `import { Button }` for every app without it (see\n * `optional-peer-imports.test.tsx`).\n */\n onInvalid?: (file: File) => void;\n /** Every refused file of one pick, with its reason and message. */\n onReject?: (rejections: FileRejection[]) => void;\n /** See {@link FileDropzoneRejectionFeedback}. Defaults to `\"toast\"` when neither\n * `onInvalid` nor `onReject` is given, `\"none\"` when one is. */\n rejectionFeedback?: FileDropzoneRejectionFeedback;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n /** The dropzone's accessible name, and the instruction shown on it. A caller's own\n * `aria-label` wins over it — see the root element below. */\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and\n * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to\n * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise\n * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see\n * `FileButton`. */\nexport function FileDropzone({\n file,\n onFileSelected,\n multiple,\n files,\n onFilesSelected,\n onClear,\n onRemove,\n accept,\n isValid,\n invalidMessage,\n maxSize,\n maxFiles,\n onInvalid,\n onReject,\n rejectionFeedback,\n labels: labelsProp,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n className,\n \"aria-label\": ariaLabel,\n \"aria-describedby\": describedByProp,\n ...rest\n}: FileDropzoneProps) {\n const [dragOver, setDragOver] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n const rootRef = useRef<HTMLDivElement>(null);\n const errorId = useId();\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.\n // Both are needed because nothing inside a `role=\"button\"` is read out — its\n // children are presentational — so the echoed file name never reaches a screen\n // reader on its own.\n const status = useAnnounce();\n const alert = useAnnounce({ politeness: \"assertive\" });\n const feedback: FileDropzoneRejectionFeedback =\n rejectionFeedback ?? (onInvalid || onReject ? \"none\" : \"toast\");\n\n const acceptFiles = (list: ArrayLike<File> | null | undefined) => {\n if (!list || list.length === 0) return;\n const all = Array.from(list);\n const { accepted, rejected } = screenFiles(\n multiple ? all : all.slice(0, 1),\n {\n accept: isValid ? undefined : accept,\n maxSize,\n maxFiles: multiple ? maxFiles : undefined,\n isValid,\n invalidMessage,\n },\n labels,\n fileText.size,\n );\n if (accepted.length > 0) {\n setError(null);\n if (multiple) {\n onFilesSelected?.(accepted);\n } else {\n onFileSelected?.(accepted[0]);\n onFilesSelected?.(accepted);\n }\n status.announce(labels.selected(accepted.length, accepted[0].name));\n }\n if (rejected.length > 0) {\n for (const r of rejected) onInvalid?.(r.file);\n onReject?.(rejected);\n const message = summariseRejections(rejected, labels);\n if (feedback === \"toast\") {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events. sonner's own toaster is a live region, so this\n // path does not announce a second time.\n void import(\"sonner\").then(({ toast }) => toast.error(message));\n } else {\n if (feedback === \"inline\") setError(message);\n alert.announce(message);\n }\n }\n };\n\n // The remove buttons unmount with the file they removed, and focus would fall to\n // <body>. The zone is where the next action (pick again) starts.\n const afterRemoval = (message: string) => {\n rootRef.current?.focus();\n status.announce(message);\n };\n\n const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];\n const showError = feedback === \"inline\" && error !== null;\n const describedBy = cn(describedByProp, showError && errorId) || undefined;\n\n return (\n <>\n <div\n // `...rest` first: every handler below is the drop gesture itself, and the\n // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.\n {...rest}\n ref={rootRef}\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFiles(e.dataTransfer.files);\n }}\n onClick={() => fileInputRef.current?.click()}\n onKeyDown={(e) => {\n // Only the zone's OWN keys. A remove button inside it is a real button, and\n // Enter on it bubbling up here would open the picker instead of removing.\n if (e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n fileInputRef.current?.click();\n }\n }}\n role=\"button\"\n tabIndex={0}\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n // No `aria-invalid`: ARIA does not allow it on `role=\"button\"`. The message is\n // reached through the description instead, and spoken when it appears.\n aria-describedby={describedBy}\n data-invalid={showError || undefined}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\". A refusal shown inline takes the\n // danger border until the next good pick.\n dragOver\n ? \"border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : showError\n ? \"border-[var(--danger-border)]\"\n : \"border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n <Upload aria-hidden className=\"size-5 text-[var(--text-muted)]\" />\n {multiple && chosen.length > 0 ? (\n <ul className=\"flex w-full max-w-sm flex-col gap-1 text-start\">\n {chosen.map((f, i) => (\n <li\n // Name + size + position: two files may share a name, and the index\n // alone would re-key every row below a removal.\n key={`${f.name}-${f.size}-${i}`}\n className=\"flex items-center gap-2 text-sm text-[var(--text-secondary)]\"\n >\n <span className=\"min-w-0 flex-1 truncate font-medium\">{f.name}</span>\n <span className=\"shrink-0 text-xs text-[var(--text-muted)]\">{fileText.size(f.size)}</span>\n {onRemove && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(f.name)}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(f, i);\n afterRemoval(labels.removed(f.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <>\n <div className=\"flex items-center gap-1 text-sm text-[var(--text-secondary)]\">\n {file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n {!multiple && file && onClear && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(file.name)}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.removed(file.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file && !multiple ? fileText.size(file.size) : hint}\n </div>\n </>\n )}\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n {multiple && chosen.length > 0 && onClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.cleared);\n }}\n >\n {labels.clearAll}\n </Button>\n )}\n </div>\n {showError && (\n <p id={errorId} className=\"text-xs text-[var(--danger)]\">\n {error}\n </p>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n onChange={(e) => {\n // Copy, THEN reset: `files` is a live view of the value, and without the\n // reset picking the same file a second time fires no `change` at all —\n // the retry after a failed upload silently did nothing (kastlan).\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n acceptFiles(picked);\n }}\n // Clicks are forwarded here programmatically; one reaching the zone's own\n // handler again would open the dialog a second time.\n onClick={(e) => e.stopPropagation()}\n className=\"sr-only\"\n />\n </div>\n {/* Outside the zone: a live region inside a `role=\"button\"` is inside\n presentational content, and screen readers are not consistent about\n reading those. `sr-only-fixed`, so they need no positioned ancestor. */}\n <span {...status.regionProps} />\n <span {...alert.regionProps} />\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { Button, IconButton } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_FILE_PICKER_LABELS, judgePick, screenFiles, summariseRejections } from \"./file-button\";\nimport type { FilePickHandler, FilePickerLabels, FileRejection } from \"./file-button\";\n\n/**\n * Where a refused file's message goes.\n *\n * - `\"toast\"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller\n * that handles nothing itself so no existing dropzone goes quiet on upgrade).\n * - `\"inline\"` — under the drop target, in the danger colour, tied to it with\n * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live\n * region. Cleared by the next accepted pick.\n * - `\"none\"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.\n * Still spoken, so a host rendering it should not ALSO make it `role=\"alert\"`.\n */\nexport type FileDropzoneRejectionFeedback = \"toast\" | \"inline\" | \"none\";\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"group\"` named by `dropLabel`, so that is also where an `aria-label` or an\n * `aria-describedby` belongs.\n *\n * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE\n * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so\n * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also\n * reaches `onFilesSelected` as a one-element array, so one handler can serve both.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n /** Single mode: the chosen file, echoed with its size. */\n file?: File | null;\n /** Single mode: called with the accepted file. */\n onFileSelected?: (file: File) => void;\n /** Take several files per pick or drop. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /** Multiple mode: the chosen files, listed in the zone. */\n files?: readonly File[];\n /** Every accepted file of one pick, in order. Never called with an empty array. */\n onFilesSelected?: (files: File[]) => void;\n /**\n * Show a remove button inside the zone once something is chosen (kastlan asked for\n * it: the only way to un-choose a file was to reload). Single mode: removes the file.\n * Multiple mode: a \"remove all\" button. The host clears its own state here — the\n * zone is controlled. Focus moves to the Browse button, and the removal is announced.\n */\n onClear?: () => void;\n /** Multiple mode: a remove button per listed file. */\n onRemove?: (file: File, index: number) => void;\n accept: string;\n /**\n * The caller's check. Optional since 0.6: without it the zone checks `accept` itself\n * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every\n * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file\n * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).\n */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */\n invalidMessage?: string;\n /** Bytes; larger files are refused. */\n maxSize?: number;\n /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */\n maxFiles?: number;\n /**\n * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}\n * carries the reason and the translated message as well.\n *\n * Its existence used to be what switched the toast off, and it still does (see\n * {@link rejectionFeedback}). The toast is a dynamic `import(\"sonner\")` on the\n * failure path only: `sonner` is an OPTIONAL peer, and a static import here once\n * broke `import { Button }` for every app without it (see\n * `optional-peer-imports.test.tsx`).\n */\n onInvalid?: (file: File) => void;\n /** Every refused file of one pick, with its reason and message. */\n onReject?: (rejections: FileRejection[]) => void;\n /** The whole pick at once, before any of the above — return `false` to refuse all\n * of it (see {@link FilePickHandler}). A refused pick selects nothing; its one\n * message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */\n onPick?: FilePickHandler;\n /** See {@link FileDropzoneRejectionFeedback}. Defaults to `\"toast\"` when neither\n * `onInvalid` nor `onReject` is given, `\"none\"` when one is. */\n rejectionFeedback?: FileDropzoneRejectionFeedback;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n /** The dropzone's accessible name, and the instruction shown on it. A caller's own\n * `aria-label` wins over it — see the root element below. */\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and\n * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to\n * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise\n * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see\n * `FileButton`. */\nexport function FileDropzone({\n file,\n onFileSelected,\n multiple,\n files,\n onFilesSelected,\n onClear,\n onRemove,\n accept,\n isValid,\n invalidMessage,\n maxSize,\n maxFiles,\n onInvalid,\n onReject,\n onPick,\n rejectionFeedback,\n labels: labelsProp,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n className,\n \"aria-label\": ariaLabel,\n \"aria-describedby\": describedByProp,\n ...rest\n}: FileDropzoneProps) {\n const [dragOver, setDragOver] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n const browseRef = useRef<HTMLButtonElement>(null);\n const errorId = useId();\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.\n // The echoed file name is only text in the zone, and text changing somewhere on the\n // page is not announced by itself.\n const status = useAnnounce();\n const alert = useAnnounce({ politeness: \"assertive\" });\n const feedback: FileDropzoneRejectionFeedback =\n rejectionFeedback ?? (onInvalid || onReject ? \"none\" : \"toast\");\n\n const acceptFiles = (list: ArrayLike<File> | null | undefined) => {\n if (!list || list.length === 0) return;\n const all = Array.from(list);\n const picked = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n picked,\n {\n accept: isValid ? undefined : accept,\n maxSize,\n maxFiles: multiple ? maxFiles : undefined,\n isValid,\n invalidMessage,\n },\n labels,\n fileText.size,\n );\n const stands = judgePick(onPick, accepted, rejected);\n if (stands && accepted.length > 0) {\n setError(null);\n if (multiple) {\n onFilesSelected?.(accepted);\n } else {\n onFileSelected?.(accepted[0]);\n onFilesSelected?.(accepted);\n }\n status.announce(labels.selected(accepted.length, accepted[0].name));\n }\n if (!stands || rejected.length > 0) {\n for (const r of rejected) onInvalid?.(r.file);\n if (rejected.length > 0) onReject?.(rejected);\n const message = stands\n ? summariseRejections(rejected, labels)\n : labels.rejectedPick(picked.length);\n if (feedback === \"toast\") {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events. sonner's own toaster is a live region, so this\n // path does not announce a second time.\n void import(\"sonner\").then(({ toast }) => toast.error(message));\n } else {\n if (feedback === \"inline\") setError(message);\n alert.announce(message);\n }\n }\n };\n\n // The remove buttons unmount with the file they removed, and focus would fall to\n // <body>. Browse is where the next action (pick again) starts.\n const afterRemoval = (message: string) => {\n browseRef.current?.focus();\n status.announce(message);\n };\n\n const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];\n const showError = feedback === \"inline\" && error !== null;\n const describedBy = cn(describedByProp, showError && errorId) || undefined;\n\n return (\n <>\n {/* A named group, NOT a `role=\"button\"`. It was one, with real buttons (Browse,\n the remove buttons) inside it: nested interactive controls, which ARIA forbids\n because a button's children are presentational — a screen reader either read\n the zone as one opaque button or skipped the inner ones, and the zone was an\n extra Tab stop that did what Browse does. Now the keyboard path is Browse, a\n real button; the zone keeps its drop target and its click-anywhere for a\n pointer, which is a convenience on top of Browse rather than the only way in. */}\n {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- the click is a pointer shortcut for Browse, see above */}\n <div\n // `...rest` first: every handler below is the drop gesture itself.\n {...rest}\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFiles(e.dataTransfer.files);\n }}\n // Pointer only; every control inside stops its own click from reaching here. The\n // keyboard equivalent is the Browse button, so no key handler belongs on a group.\n onClick={() => fileInputRef.current?.click()}\n role=\"group\"\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n // No `aria-invalid`: ARIA does not allow it on `role=\"group\"`. The message is\n // reached through the description instead, and spoken when it appears.\n aria-describedby={describedBy}\n data-invalid={showError || undefined}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\". A refusal shown inline takes the\n // danger border until the next good pick.\n dragOver\n ? \"border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : showError\n ? \"border-[var(--danger-border)]\"\n : \"border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n <Upload aria-hidden className=\"size-5 text-[var(--text-muted)]\" />\n {multiple && chosen.length > 0 ? (\n <ul className=\"flex w-full max-w-sm flex-col gap-1 text-start\">\n {chosen.map((f, i) => (\n <li\n // Name + size + position: two files may share a name, and the index\n // alone would re-key every row below a removal.\n key={`${f.name}-${f.size}-${i}`}\n className=\"flex items-center gap-2 text-sm text-[var(--text-secondary)]\"\n >\n <span className=\"min-w-0 flex-1 truncate font-medium\">{f.name}</span>\n <span className=\"shrink-0 text-xs text-[var(--text-muted)]\">{fileText.size(f.size)}</span>\n {onRemove && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(f.name)}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(f, i);\n afterRemoval(labels.removed(f.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <>\n <div className=\"flex items-center gap-1 text-sm text-[var(--text-secondary)]\">\n {/* `!multiple`: in multiple mode an empty `files` list is \"nothing chosen\",\n whatever a stray `file` prop says — it used to show that file's name. */}\n {!multiple && file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n {!multiple && file && onClear && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(file.name)}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.removed(file.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file && !multiple ? fileText.size(file.size) : hint}\n </div>\n </>\n )}\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button\n ref={browseRef}\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n {multiple && chosen.length > 0 && onClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.cleared);\n }}\n >\n {labels.clearAll}\n </Button>\n )}\n </div>\n {showError && (\n <p id={errorId} className=\"text-xs text-[var(--danger)]\">\n {error}\n </p>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n onChange={(e) => {\n // Copy, THEN reset: `files` is a live view of the value, and without the\n // reset picking the same file a second time fires no `change` at all —\n // the retry after a failed upload silently did nothing (kastlan).\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n acceptFiles(picked);\n }}\n // Clicks are forwarded here programmatically; one reaching the zone's own\n // handler again would open the dialog a second time.\n onClick={(e) => e.stopPropagation()}\n className=\"sr-only\"\n />\n </div>\n {/* Outside the zone, where they have always been (the zone used to be a\n `role=\"button\"`, whose content is presentational). `sr-only-fixed`, so they\n need no positioned ancestor. */}\n <span {...status.regionProps} />\n <span {...alert.regionProps} />\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -57,7 +57,7 @@ function GroupedPicker({
57
57
  ]
58
58
  }
59
59
  ),
60
- open && /* @__PURE__ */ jsxs("div", { className: "absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg", children: [
60
+ open && /* @__PURE__ */ jsxs("div", { className: "absolute start-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg", children: [
61
61
  /* @__PURE__ */ jsx(
62
62
  DropdownSearchHeader,
63
63
  {
@@ -80,7 +80,7 @@ function GroupedPicker({
80
80
  setOpen(false);
81
81
  },
82
82
  className: cn(
83
- "flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm",
83
+ "flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-start text-sm",
84
84
  isSelected ? "bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
85
85
  ),
86
86
  children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/grouped-picker.tsx"],"sourcesContent":["import { useEffect, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\nexport interface PickerGroup {\n key: string;\n label: string;\n /** `icon` is an optional leading glyph, drawn before the label — the same slot\n * `ComboOption` has always had, so the two pickers can present one option set\n * the same way. Omitted, a row is exactly what it was: label only, with no\n * reserved space, so a list where nothing has an icon does not indent. */\n items: { key: string; label: string; icon?: ReactNode }[];\n}\n\n/**\n * `onSelect` is omitted from the div's own attributes because this component's\n * means \"an item was chosen\", not the DOM's \"text was selected inside me\" — two\n * different signatures under one name, and the kit's meaning is the one three apps\n * are written against.\n */\nexport interface GroupedPickerProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSelect\"> {\n groups: PickerGroup[];\n /** Face of the closed picker button. */\n buttonLabel: ReactNode;\n /** Keys of the current selection, if any. */\n selected: { group: string; item: string } | null;\n onSelect: (groupKey: string, itemKey: string) => void;\n /**\n * @deprecated Use `aria-label`, the DOM spelling every control in the kit now\n * takes. Kept working — three apps pass it — and it applies only when\n * `aria-label` is absent; it goes in a later minor.\n */\n ariaLabel?: string;\n filterPlaceholder?: string;\n}\n\n/** Panel picker for large grouped option sets: instead of one long flat\n * dropdown, the groups sit side by side as columns with their items listed\n * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback\n * #267 rework). A query that matches a group's name keeps the whole group;\n * otherwise groups are reduced to their matching items. */\nexport function GroupedPicker({\n groups,\n buttonLabel,\n selected,\n onSelect,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n filterPlaceholder,\n className,\n ...rest\n}: GroupedPickerProps) {\n const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [open, setOpen]);\n\n // Case folding in the provider's locale — see `rowMatches` for the Turkish \"İ\"\n // that plain `toLowerCase` gets wrong.\n const locale = useKitLocale();\n const filtered = useMemo(() => {\n const fold = (s: string) => s.toLocaleLowerCase(locale);\n const q = fold(query.trim());\n if (!q) return groups;\n return groups\n .map((g) =>\n fold(g.label).includes(q)\n ? g\n : { ...g, items: g.items.filter((i) => fold(i.label).includes(q)) },\n )\n .filter((g) => g.items.length > 0);\n }, [groups, query, locale]);\n\n return (\n // `rest` goes on the wrapper, but the accessible NAME does not: this div is a\n // positioning box with no role, so a label parked on it would be announced by\n // nothing. It belongs to the control — the same split every picker in this wave\n // makes, and the reason `aria-label` is destructured out rather than left in\n // `rest`. Spread FIRST so the attributes below, each of them a fix with an\n // incident behind it, cannot be clobbered from outside.\n <div {...rest} ref={wrapperRef} className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n aria-label={ariaLabelAttr ?? ariaLabel}\n onClick={() => setOpen((o) => !o)}\n className=\"flex w-full items-center justify-between gap-2 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] px-2.5 py-1.5 text-sm text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n >\n <span className=\"truncate\">{buttonLabel}</span>\n <ChevronDown className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n </button>\n {open && (\n <div className=\"absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\">\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={filterPlaceholder}\n />\n <div className=\"overflow-y-auto p-3\">\n {filtered.length === 0 ? (\n <p className=\"text-sm text-[var(--text-muted)]\">—</p>\n ) : (\n <div className=\"columns-2 gap-4 md:columns-3 xl:columns-4\">\n {filtered.map((g) => (\n <div key={g.key} className=\"mb-3 break-inside-avoid\">\n <div className=\"mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {g.label}\n </div>\n <ul className=\"space-y-0.5\">\n {g.items.map((item) => {\n const isSelected =\n selected !== null &&\n g.key === selected.group &&\n item.key === selected.item;\n return (\n <li key={item.key}>\n <button\n type=\"button\"\n onClick={() => {\n onSelect(g.key, item.key);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm\",\n isSelected\n ? \"bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )}\n >\n {item.icon && <span className=\"shrink-0\">{item.icon}</span>}\n <span className=\"min-w-0 truncate\">{item.label}</span>\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/grouped-picker.tsx"],"sourcesContent":["import { useEffect, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\nexport interface PickerGroup {\n key: string;\n label: string;\n /** `icon` is an optional leading glyph, drawn before the label — the same slot\n * `ComboOption` has always had, so the two pickers can present one option set\n * the same way. Omitted, a row is exactly what it was: label only, with no\n * reserved space, so a list where nothing has an icon does not indent. */\n items: { key: string; label: string; icon?: ReactNode }[];\n}\n\n/**\n * `onSelect` is omitted from the div's own attributes because this component's\n * means \"an item was chosen\", not the DOM's \"text was selected inside me\" — two\n * different signatures under one name, and the kit's meaning is the one three apps\n * are written against.\n */\nexport interface GroupedPickerProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSelect\"> {\n groups: PickerGroup[];\n /** Face of the closed picker button. */\n buttonLabel: ReactNode;\n /** Keys of the current selection, if any. */\n selected: { group: string; item: string } | null;\n onSelect: (groupKey: string, itemKey: string) => void;\n /**\n * @deprecated Use `aria-label`, the DOM spelling every control in the kit now\n * takes. Kept working — three apps pass it — and it applies only when\n * `aria-label` is absent; it goes in a later minor.\n */\n ariaLabel?: string;\n filterPlaceholder?: string;\n}\n\n/** Panel picker for large grouped option sets: instead of one long flat\n * dropdown, the groups sit side by side as columns with their items listed\n * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback\n * #267 rework). A query that matches a group's name keeps the whole group;\n * otherwise groups are reduced to their matching items. */\nexport function GroupedPicker({\n groups,\n buttonLabel,\n selected,\n onSelect,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n filterPlaceholder,\n className,\n ...rest\n}: GroupedPickerProps) {\n const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [open, setOpen]);\n\n // Case folding in the provider's locale — see `rowMatches` for the Turkish \"İ\"\n // that plain `toLowerCase` gets wrong.\n const locale = useKitLocale();\n const filtered = useMemo(() => {\n const fold = (s: string) => s.toLocaleLowerCase(locale);\n const q = fold(query.trim());\n if (!q) return groups;\n return groups\n .map((g) =>\n fold(g.label).includes(q)\n ? g\n : { ...g, items: g.items.filter((i) => fold(i.label).includes(q)) },\n )\n .filter((g) => g.items.length > 0);\n }, [groups, query, locale]);\n\n return (\n // `rest` goes on the wrapper, but the accessible NAME does not: this div is a\n // positioning box with no role, so a label parked on it would be announced by\n // nothing. It belongs to the control — the same split every picker in this wave\n // makes, and the reason `aria-label` is destructured out rather than left in\n // `rest`. Spread FIRST so the attributes below, each of them a fix with an\n // incident behind it, cannot be clobbered from outside.\n <div {...rest} ref={wrapperRef} className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n aria-label={ariaLabelAttr ?? ariaLabel}\n onClick={() => setOpen((o) => !o)}\n className=\"flex w-full items-center justify-between gap-2 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] px-2.5 py-1.5 text-sm text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n >\n <span className=\"truncate\">{buttonLabel}</span>\n <ChevronDown className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n </button>\n {open && (\n <div className=\"absolute start-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\">\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={filterPlaceholder}\n />\n <div className=\"overflow-y-auto p-3\">\n {filtered.length === 0 ? (\n <p className=\"text-sm text-[var(--text-muted)]\">—</p>\n ) : (\n <div className=\"columns-2 gap-4 md:columns-3 xl:columns-4\">\n {filtered.map((g) => (\n <div key={g.key} className=\"mb-3 break-inside-avoid\">\n <div className=\"mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {g.label}\n </div>\n <ul className=\"space-y-0.5\">\n {g.items.map((item) => {\n const isSelected =\n selected !== null &&\n g.key === selected.group &&\n item.key === selected.item;\n return (\n <li key={item.key}>\n <button\n type=\"button\"\n onClick={() => {\n onSelect(g.key, item.key);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-start text-sm\",\n isSelected\n ? \"bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )}\n >\n {item.icon && <span className=\"shrink-0\">{item.icon}</span>}\n <span className=\"min-w-0 truncate\">{item.label}</span>\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -15,13 +15,18 @@ interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "children
15
15
  toggle: () => void;
16
16
  }) => ReactNode;
17
17
  children: (close: () => void) => ReactNode;
18
- align?: "left" | "right";
18
+ /**
19
+ * Which edge of the trigger the panel lines up with. `start`/`end` follow the
20
+ * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`
21
+ * are physical and kept for existing callers. Default `end` — the same right-aligned
22
+ * panel as before in LTR, mirrored in RTL.
23
+ */
24
+ align?: "start" | "end" | "left" | "right";
19
25
  panelClassName?: string;
20
26
  /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */
21
27
  className?: string;
22
28
  /**
23
- * @deprecated Use the DOM spelling `aria-label`, which now reaches the wrapper like
24
- * any other attribute. Kept working because keksdose, kastlan and lenkbank all pass
29
+ * @deprecated Use the DOM spelling `aria-label`, which names the `role="menu"` panel. Kept working because keksdose, kastlan and lenkbank all pass
25
30
  * this today and a rename to save one line of resolution is not a patch release; it
26
31
  * loses to `aria-label` wherever both are given. Removal is a later minor.
27
32
  */