@eifi1/ui-kit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +3 -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 +36 -7
  9. package/dist/components/autocomplete.js +68 -22
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +3 -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/checkbox.d.ts +9 -0
  20. package/dist/components/checkbox.js +7 -2
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +34 -14
  23. package/dist/components/chip.js +63 -28
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/choice-card.d.ts +18 -1
  26. package/dist/components/choice-card.js +21 -8
  27. package/dist/components/choice-card.js.map +1 -1
  28. package/dist/components/combobox-core.d.ts +61 -2
  29. package/dist/components/combobox-core.js +68 -17
  30. package/dist/components/combobox-core.js.map +1 -1
  31. package/dist/components/combobox.d.ts +11 -7
  32. package/dist/components/combobox.js +51 -28
  33. package/dist/components/combobox.js.map +1 -1
  34. package/dist/components/currency-select.js +3 -3
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +30 -6
  37. package/dist/components/danger-confirm.js +51 -32
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-filter-popover.d.ts +1 -1
  40. package/dist/components/data-table-filters.d.ts +1 -1
  41. package/dist/components/data-table-pagination.d.ts +6 -3
  42. package/dist/components/data-table-pagination.js +6 -5
  43. package/dist/components/data-table-pagination.js.map +1 -1
  44. package/dist/components/data-table.d.ts +1 -1
  45. package/dist/components/data-table.js +332 -248
  46. package/dist/components/data-table.js.map +1 -1
  47. package/dist/components/date-picker.js +17 -7
  48. package/dist/components/date-picker.js.map +1 -1
  49. package/dist/components/dialog-frame.d.ts +28 -3
  50. package/dist/components/dialog-frame.js +47 -11
  51. package/dist/components/dialog-frame.js.map +1 -1
  52. package/dist/components/disclosure.d.ts +22 -2
  53. package/dist/components/disclosure.js +41 -8
  54. package/dist/components/disclosure.js.map +1 -1
  55. package/dist/components/dropdown.d.ts +7 -3
  56. package/dist/components/dropdown.js +6 -2
  57. package/dist/components/dropdown.js.map +1 -1
  58. package/dist/components/entity-combobox.d.ts +17 -8
  59. package/dist/components/entity-combobox.js +7 -5
  60. package/dist/components/entity-combobox.js.map +1 -1
  61. package/dist/components/file-button.d.ts +36 -5
  62. package/dist/components/file-button.js +42 -3
  63. package/dist/components/file-button.js.map +1 -1
  64. package/dist/components/file-dropzone.d.ts +9 -5
  65. package/dist/components/file-dropzone.js +14 -19
  66. package/dist/components/file-dropzone.js.map +1 -1
  67. package/dist/components/grouped-picker.js +2 -2
  68. package/dist/components/grouped-picker.js.map +1 -1
  69. package/dist/components/hover-menu.d.ts +8 -3
  70. package/dist/components/hover-menu.js +88 -4
  71. package/dist/components/hover-menu.js.map +1 -1
  72. package/dist/components/icon-picker.d.ts +10 -1
  73. package/dist/components/icon-picker.js +5 -1
  74. package/dist/components/icon-picker.js.map +1 -1
  75. package/dist/components/measured-grid.d.ts +165 -0
  76. package/dist/components/measured-grid.js +559 -0
  77. package/dist/components/measured-grid.js.map +1 -0
  78. package/dist/components/mini-calendar.js +7 -8
  79. package/dist/components/mini-calendar.js.map +1 -1
  80. package/dist/components/month-picker.js +13 -9
  81. package/dist/components/month-picker.js.map +1 -1
  82. package/dist/components/multi-entity-combobox.js +2 -2
  83. package/dist/components/multi-entity-combobox.js.map +1 -1
  84. package/dist/components/multi-select.d.ts +9 -1
  85. package/dist/components/multi-select.js +22 -8
  86. package/dist/components/multi-select.js.map +1 -1
  87. package/dist/components/number-field.d.ts +3 -0
  88. package/dist/components/number-input.d.ts +3 -0
  89. package/dist/components/number-input.js +3 -3
  90. package/dist/components/number-input.js.map +1 -1
  91. package/dist/components/numpad-sheet.d.ts +3 -0
  92. package/dist/components/numpad-sheet.js +3 -3
  93. package/dist/components/numpad-sheet.js.map +1 -1
  94. package/dist/components/picker-sheet.js +1 -1
  95. package/dist/components/picker-sheet.js.map +1 -1
  96. package/dist/components/popover.js +11 -2
  97. package/dist/components/popover.js.map +1 -1
  98. package/dist/components/series-chart-ticks.d.ts +13 -0
  99. package/dist/components/series-chart-ticks.js +29 -0
  100. package/dist/components/series-chart-ticks.js.map +1 -0
  101. package/dist/components/series-chart.js +33 -27
  102. package/dist/components/series-chart.js.map +1 -1
  103. package/dist/components/swatch-picker.d.ts +10 -1
  104. package/dist/components/swatch-picker.js +2 -0
  105. package/dist/components/swatch-picker.js.map +1 -1
  106. package/dist/components/swipeable-row.d.ts +11 -2
  107. package/dist/components/swipeable-row.js +5 -3
  108. package/dist/components/swipeable-row.js.map +1 -1
  109. package/dist/components/switch.d.ts +9 -0
  110. package/dist/components/switch.js +7 -2
  111. package/dist/components/switch.js.map +1 -1
  112. package/dist/components/tile-radio.js +5 -7
  113. package/dist/components/tile-radio.js.map +1 -1
  114. package/dist/components/toggle-group.d.ts +13 -3
  115. package/dist/components/toggle-group.js +21 -1
  116. package/dist/components/toggle-group.js.map +1 -1
  117. package/dist/components/toggle-legend.js +1 -2
  118. package/dist/components/toggle-legend.js.map +1 -1
  119. package/dist/components/treemap.d.ts +6 -1
  120. package/dist/components/treemap.js +16 -6
  121. package/dist/components/treemap.js.map +1 -1
  122. package/dist/components/ui.d.ts +31 -7
  123. package/dist/components/ui.js +26 -18
  124. package/dist/components/ui.js.map +1 -1
  125. package/dist/components/use-anchor-dir.d.ts +15 -0
  126. package/dist/components/use-anchor-dir.js +14 -0
  127. package/dist/components/use-anchor-dir.js.map +1 -0
  128. package/dist/components/use-mobile-reveal.d.ts +8 -1
  129. package/dist/components/use-mobile-reveal.js +3 -2
  130. package/dist/components/use-mobile-reveal.js.map +1 -1
  131. package/dist/components/use-table-state.d.ts +4 -4
  132. package/dist/components/use-table-state.js +12 -5
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  135. package/dist/data-table.d.ts +1 -1
  136. package/dist/data-table.js.map +1 -1
  137. package/dist/feedback/feedback-attachment.d.ts +22 -3
  138. package/dist/feedback/feedback-attachment.js +15 -7
  139. package/dist/feedback/feedback-attachment.js.map +1 -1
  140. package/dist/feedback/feedback-dialog.d.ts +3 -2
  141. package/dist/feedback/feedback-dialog.js.map +1 -1
  142. package/dist/feedback.d.ts +1 -1
  143. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  144. package/dist/hooks/use-windowed-rows.js +31 -0
  145. package/dist/hooks/use-windowed-rows.js.map +1 -0
  146. package/dist/i18n/defaults.d.ts +3 -0
  147. package/dist/i18n/defaults.js +5 -1
  148. package/dist/i18n/defaults.js.map +1 -1
  149. package/dist/i18n/kit-labels.d.ts +5 -0
  150. package/dist/i18n/kit-labels.js.map +1 -1
  151. package/dist/i18n/locales/de-CH.d.ts +43 -0
  152. package/dist/i18n/locales/de-CH.js +21 -0
  153. package/dist/i18n/locales/de-CH.js.map +1 -0
  154. package/dist/i18n/locales/de.d.ts +43 -0
  155. package/dist/i18n/locales/de.js +344 -0
  156. package/dist/i18n/locales/de.js.map +1 -0
  157. package/dist/i18n/locales/es.d.ts +43 -0
  158. package/dist/i18n/locales/es.js +339 -0
  159. package/dist/i18n/locales/es.js.map +1 -0
  160. package/dist/i18n/locales/fr.d.ts +43 -0
  161. package/dist/i18n/locales/fr.js +344 -0
  162. package/dist/i18n/locales/fr.js.map +1 -0
  163. package/dist/i18n/locales/hu.d.ts +43 -0
  164. package/dist/i18n/locales/hu.js +343 -0
  165. package/dist/i18n/locales/hu.js.map +1 -0
  166. package/dist/i18n/locales/it.d.ts +43 -0
  167. package/dist/i18n/locales/it.js +342 -0
  168. package/dist/i18n/locales/it.js.map +1 -0
  169. package/dist/i18n/locales/zh.d.ts +43 -0
  170. package/dist/i18n/locales/zh.js +338 -0
  171. package/dist/i18n/locales/zh.js.map +1 -0
  172. package/dist/index.d.ts +6 -3
  173. package/dist/index.js +4 -1
  174. package/dist/index.js.map +1 -1
  175. package/dist/lib/direction.d.ts +24 -0
  176. package/dist/lib/direction.js +19 -0
  177. package/dist/lib/direction.js.map +1 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/search/command-palette.d.ts +5 -0
  180. package/dist/search/command-palette.js +80 -50
  181. package/dist/search/command-palette.js.map +1 -1
  182. package/dist/shell/app-shell.d.ts +13 -1
  183. package/dist/shell/app-shell.js +133 -84
  184. package/dist/shell/app-shell.js.map +1 -1
  185. package/dist/shell/topbar-action-menu.js +1 -1
  186. package/dist/shell/topbar-action-menu.js.map +1 -1
  187. package/dist/shell/topbar-controls.d.ts +7 -3
  188. package/dist/shell/topbar-controls.js +38 -31
  189. package/dist/shell/topbar-controls.js.map +1 -1
  190. package/dist/theme/chart-palette.d.ts +7 -1
  191. package/dist/theme/chart-palette.js +2 -1
  192. package/dist/theme/chart-palette.js.map +1 -1
  193. package/dist/tour/tour.d.ts +11 -5
  194. package/dist/tour/tour.js +124 -58
  195. package/dist/tour/tour.js.map +1 -1
  196. package/dist/wizard/use-wizard.js +30 -13
  197. package/dist/wizard/use-wizard.js.map +1 -1
  198. package/dist/wizard/wizard-summary.js +1 -1
  199. package/dist/wizard/wizard-summary.js.map +1 -1
  200. package/package.json +12 -2
  201. package/src/components/account-settings.tsx +6 -2
  202. package/src/components/amount-input.tsx +10 -8
  203. package/src/components/autocomplete.tsx +123 -33
  204. package/src/components/calculator.tsx +5 -1
  205. package/src/components/chart-zoom.tsx +8 -1
  206. package/src/components/chart.tsx +68 -7
  207. package/src/components/checkbox.tsx +16 -0
  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 +102 -6
  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/switch.tsx +16 -0
  240. package/src/components/tile-radio.tsx +5 -7
  241. package/src/components/toggle-group.tsx +39 -3
  242. package/src/components/toggle-legend.tsx +5 -2
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +74 -30
  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 +200 -129
  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
@@ -36,8 +36,14 @@ import { Button, Spinner } from "./ui";
36
36
  /** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.
37
37
  * Messages are functions of the file name so a translation can put it anywhere. */
38
38
  export interface FilePickerLabels {
39
- /** The file's type is not in `accept`. */
39
+ /** The file's type is not in `accept`, when no `accept` is known to name (see
40
+ * `rejectedTypeOnly`). */
40
41
  rejectedType: (name: string) => string;
42
+ /** The file's type is not in `accept`, naming what IS accepted: `accept`'s tokens,
43
+ * lower-cased and joined with ", " (`".csv"` => "Only .csv files"). Used whenever
44
+ * `accept` is set — kastlan kept a custom `isValid` + `invalidMessage` per form only
45
+ * to say this, and `accept` already knows it. */
46
+ rejectedTypeOnly: (accept: string, name: string) => string;
41
47
  /** The file is larger than `maxSize`; `maxSize` arrives formatted ("5 MB"). */
42
48
  rejectedSize: (name: string, maxSize: string) => string;
43
49
  /** The file was one too many for `maxFiles`. */
@@ -46,6 +52,9 @@ export interface FilePickerLabels {
46
52
  rejectedInvalid: (name: string) => string;
47
53
  /** Spoken instead of the per-file message when more than one file was refused. */
48
54
  rejectedMany: (count: number) => string;
55
+ /** Spoken (and shown, on a dropzone) when `onPick` refused the WHOLE pick.
56
+ * Receives the number of files in it. */
57
+ rejectedPick: (count: number) => string;
49
58
  /** Spoken after a pick the component itself echoes (the dropzone). */
50
59
  selected: (count: number, firstName: string) => string;
51
60
  /** The dropzone's remove button for one file. */
@@ -59,11 +68,14 @@ export interface FilePickerLabels {
59
68
 
60
69
  export const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {
61
70
  rejectedType: (name) => `“${name}” is not a supported file type`,
71
+ rejectedTypeOnly: (accept) => `Only ${accept} files`,
62
72
  rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,
63
73
  rejectedCount: (name, maxFiles) =>
64
74
  `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? "file" : "files"}`,
65
75
  rejectedInvalid: (name) => `“${name}” cannot be used here`,
66
76
  rejectedMany: (count) => `${count} files were not added`,
77
+ rejectedPick: (count) =>
78
+ count === 1 ? "The file was not added" : `None of the ${count} files were added`,
67
79
  selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),
68
80
  remove: (name) => `Remove “${name}”`,
69
81
  clearAll: "Remove all files",
@@ -100,7 +112,14 @@ export function matchesAccept(file: File, accept: string | undefined): boolean {
100
112
  .filter(Boolean);
101
113
  if (tokens.length === 0) return true;
102
114
  const name = file.name.toLowerCase();
103
- const type = (file.type || "").toLowerCase();
115
+ // An EMPTY type is the browser saying "I don't know", not "it's something else": HEIC
116
+ // photos on Windows without the codec arrive with `type === ""`. Refusing them
117
+ // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a
118
+ // missing type is inferred from the extension where that is unambiguous, and a file
119
+ // whose type cannot be known at all is given the benefit of the doubt — nothing here
120
+ // proves it does not match, and the server validates what it receives anyway.
121
+ const type = (file.type || typeFromExtension(name) || "").toLowerCase();
122
+ if (!type && !tokens.every((t) => t.startsWith("."))) return true;
104
123
  return tokens.some((token) => {
105
124
  if (token.startsWith(".")) return name.endsWith(token);
106
125
  if (!type) return false;
@@ -109,6 +128,35 @@ export function matchesAccept(file: File, accept: string | undefined): boolean {
109
128
  });
110
129
  }
111
130
 
131
+ /** MIME types for the extensions a browser most often leaves untyped. */
132
+ const EXTENSION_TYPES: Record<string, string> = {
133
+ heic: "image/heic",
134
+ heif: "image/heif",
135
+ avif: "image/avif",
136
+ webp: "image/webp",
137
+ jpg: "image/jpeg",
138
+ jpeg: "image/jpeg",
139
+ png: "image/png",
140
+ gif: "image/gif",
141
+ pdf: "application/pdf",
142
+ csv: "text/csv",
143
+ txt: "text/plain",
144
+ };
145
+
146
+ function typeFromExtension(name: string): string | undefined {
147
+ const dot = name.lastIndexOf(".");
148
+ return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];
149
+ }
150
+
151
+ /** `" .PDF, image/png ,"` => `".pdf, image/png"` — `accept` as a person reads it. */
152
+ function formatAccept(accept: string): string {
153
+ return accept
154
+ .split(",")
155
+ .map((t) => t.trim().toLowerCase())
156
+ .filter(Boolean)
157
+ .join(", ");
158
+ }
159
+
112
160
  /** What the pickers screen a pick with. All optional; nothing set accepts everything. */
113
161
  export interface FileScreenOptions {
114
162
  accept?: string;
@@ -133,6 +181,15 @@ export function screenFiles(
133
181
  ): { accepted: File[]; rejected: FileRejection[] } {
134
182
  const accepted: File[] = [];
135
183
  const rejected: FileRejection[] = [];
184
+ // Name what `accept` takes ("Only .csv files") — unless the host translated
185
+ // `rejectedType` but not yet `rejectedTypeOnly`: its own sentence beats an English one.
186
+ const acceptText = opts.accept ? formatAccept(opts.accept) : "";
187
+ const useOnly =
188
+ acceptText !== "" &&
189
+ (labels.rejectedTypeOnly !== DEFAULT_FILE_PICKER_LABELS.rejectedTypeOnly ||
190
+ labels.rejectedType === DEFAULT_FILE_PICKER_LABELS.rejectedType);
191
+ const typeMessage = (name: string) =>
192
+ useOnly ? labels.rejectedTypeOnly(acceptText, name) : labels.rejectedType(name);
136
193
  for (const file of files) {
137
194
  let reason: FileRejectionReason | null = null;
138
195
  if (!matchesAccept(file, opts.accept)) reason = "type";
@@ -147,7 +204,7 @@ export function screenFiles(
147
204
  }
148
205
  const message =
149
206
  reason === "type"
150
- ? labels.rejectedType(file.name)
207
+ ? typeMessage(file.name)
151
208
  : reason === "size"
152
209
  ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))
153
210
  : reason === "count"
@@ -158,6 +215,34 @@ export function screenFiles(
158
215
  return { accepted, rejected };
159
216
  }
160
217
 
218
+ /**
219
+ * Judges a pick as a whole — keksdose's "refuse the pick if any file is bad", which
220
+ * with `onFiles` + `onReject` alone had to be rebuilt from two calls in a microtask.
221
+ *
222
+ * Called ONCE per pick, after screening and before `onFiles` / `onReject`, with both
223
+ * halves (either may be empty). Return `false` to refuse the whole pick: the accepted
224
+ * files are not delivered, `onReject` still receives what screening refused, and one
225
+ * sentence (`labels.rejectedPick`) is spoken for the pick instead of a per-file one.
226
+ * Return nothing to let the pick through as usual.
227
+ *
228
+ * ```tsx
229
+ * onPick={(ok, bad) => bad.length === 0 || false} // all or nothing
230
+ * ```
231
+ */
232
+ export type FilePickHandler = (accepted: File[], rejected: FileRejection[]) => boolean | void;
233
+
234
+ /** @internal The shared tail of both pickers: run `onPick`, and say whether the pick
235
+ * stands. A pick with nothing accepted has nothing to refuse, so `false` there is
236
+ * treated as no verdict and the usual per-file messages are kept. */
237
+ export function judgePick(
238
+ onPick: FilePickHandler | undefined,
239
+ accepted: File[],
240
+ rejected: FileRejection[],
241
+ ): boolean {
242
+ if (!onPick) return true;
243
+ return onPick(accepted, rejected) !== false || accepted.length === 0;
244
+ }
245
+
161
246
  /** One sentence for a whole batch of refusals: the file's own message for one, a
162
247
  * count for several — reading out five sentences in a row helps nobody. */
163
248
  export function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {
@@ -178,8 +263,11 @@ export interface UseFilePickerOptions extends FileScreenOptions {
178
263
  * keksdose's camera input has no `multiple`. Pass one or the other.
179
264
  */
180
265
  capture?: boolean | "user" | "environment";
181
- /** The files that passed, in pick order. Never called with an empty array. */
182
- onFiles: (files: File[]) => void;
266
+ /** The files that passed, in pick order. Never called with an empty array.
267
+ * Optional since 0.7 for a caller that takes the pick through `onPick` alone. */
268
+ onFiles?: (files: File[]) => void;
269
+ /** See {@link FilePickHandler}: the whole pick at once, with the power to refuse it. */
270
+ onPick?: FilePickHandler;
183
271
  /** The files that did not, with a translated message each. The refusals are also
184
272
  * spoken through a live region, so this is for SHOWING them, not for a11y. */
185
273
  onReject?: (rejections: FileRejection[]) => void;
@@ -219,6 +307,7 @@ export function useFilePicker({
219
307
  isValid,
220
308
  invalidMessage,
221
309
  onFiles,
310
+ onPick,
222
311
  onReject,
223
312
  disabled,
224
313
  labels: labelsProp,
@@ -242,7 +331,12 @@ export function useFilePicker({
242
331
  labels,
243
332
  fileText.size,
244
333
  );
245
- if (accepted.length > 0) onFiles(accepted);
334
+ if (!judgePick(onPick, accepted, rejected)) {
335
+ if (rejected.length > 0) onReject?.(rejected);
336
+ announce(labels.rejectedPick(files.length));
337
+ return;
338
+ }
339
+ if (accepted.length > 0) onFiles?.(accepted);
246
340
  if (rejected.length > 0) {
247
341
  onReject?.(rejected);
248
342
  announce(summariseRejections(rejected, labels));
@@ -328,6 +422,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
328
422
  isValid,
329
423
  invalidMessage,
330
424
  onFiles,
425
+ onPick,
331
426
  onReject,
332
427
  labels,
333
428
  pending,
@@ -354,6 +449,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
354
449
  isValid,
355
450
  invalidMessage,
356
451
  onFiles,
452
+ onPick,
357
453
  onReject,
358
454
  labels,
359
455
  disabled: inert,
@@ -5,8 +5,8 @@ import { Button, IconButton } from "./ui";
5
5
  import { cn } from "../lib/cn";
6
6
  import { useAnnounce } from "../hooks/use-announce";
7
7
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
8
- import { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from "./file-button";
9
- import type { FilePickerLabels, FileRejection } from "./file-button";
8
+ import { DEFAULT_FILE_PICKER_LABELS, judgePick, screenFiles, summariseRejections } from "./file-button";
9
+ import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-button";
10
10
 
11
11
  /**
12
12
  * Where a refused file's message goes.
@@ -26,8 +26,8 @@ export type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
26
26
  * The DOM event of that name belongs to form validation and takes a `FormEvent`; this
27
27
  * one takes the rejected `File`, and the kit's meaning is the one every caller already
28
28
  * writes. Everything else a `<div>` takes reaches the root — which carries
29
- * `role="button"`, so that is also where an `aria-label` or an `aria-describedby`
30
- * belongs.
29
+ * `role="group"` named by `dropLabel`, so that is also where an `aria-label` or an
30
+ * `aria-describedby` belongs.
31
31
  *
32
32
  * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE
33
33
  * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so
@@ -49,7 +49,7 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
49
49
  * Show a remove button inside the zone once something is chosen (kastlan asked for
50
50
  * it: the only way to un-choose a file was to reload). Single mode: removes the file.
51
51
  * Multiple mode: a "remove all" button. The host clears its own state here — the
52
- * zone is controlled. Focus returns to the zone, and the removal is announced.
52
+ * zone is controlled. Focus moves to the Browse button, and the removal is announced.
53
53
  */
54
54
  onClear?: () => void;
55
55
  /** Multiple mode: a remove button per listed file. */
@@ -81,6 +81,10 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
81
81
  onInvalid?: (file: File) => void;
82
82
  /** Every refused file of one pick, with its reason and message. */
83
83
  onReject?: (rejections: FileRejection[]) => void;
84
+ /** The whole pick at once, before any of the above — return `false` to refuse all
85
+ * of it (see {@link FilePickHandler}). A refused pick selects nothing; its one
86
+ * message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */
87
+ onPick?: FilePickHandler;
84
88
  /** See {@link FileDropzoneRejectionFeedback}. Defaults to `"toast"` when neither
85
89
  * `onInvalid` nor `onReject` is given, `"none"` when one is. */
86
90
  rejectionFeedback?: FileDropzoneRejectionFeedback;
@@ -116,6 +120,7 @@ export function FileDropzone({
116
120
  maxFiles,
117
121
  onInvalid,
118
122
  onReject,
123
+ onPick,
119
124
  rejectionFeedback,
120
125
  labels: labelsProp,
121
126
  dropLabel,
@@ -130,7 +135,7 @@ export function FileDropzone({
130
135
  const [dragOver, setDragOver] = useState(false);
131
136
  const [error, setError] = useState<string | null>(null);
132
137
  const fileInputRef = useRef<HTMLInputElement>(null);
133
- const rootRef = useRef<HTMLDivElement>(null);
138
+ const browseRef = useRef<HTMLButtonElement>(null);
134
139
  const errorId = useId();
135
140
  // The size through the kit's `file.size` label (the provider's locale by default).
136
141
  // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte
@@ -138,9 +143,8 @@ export function FileDropzone({
138
143
  const fileText = useKitFileLabels();
139
144
  const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
140
145
  // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.
141
- // Both are needed because nothing inside a `role="button"` is read out — its
142
- // children are presentational — so the echoed file name never reaches a screen
143
- // reader on its own.
146
+ // The echoed file name is only text in the zone, and text changing somewhere on the
147
+ // page is not announced by itself.
144
148
  const status = useAnnounce();
145
149
  const alert = useAnnounce({ politeness: "assertive" });
146
150
  const feedback: FileDropzoneRejectionFeedback =
@@ -149,8 +153,9 @@ export function FileDropzone({
149
153
  const acceptFiles = (list: ArrayLike<File> | null | undefined) => {
150
154
  if (!list || list.length === 0) return;
151
155
  const all = Array.from(list);
156
+ const picked = multiple ? all : all.slice(0, 1);
152
157
  const { accepted, rejected } = screenFiles(
153
- multiple ? all : all.slice(0, 1),
158
+ picked,
154
159
  {
155
160
  accept: isValid ? undefined : accept,
156
161
  maxSize,
@@ -161,7 +166,8 @@ export function FileDropzone({
161
166
  labels,
162
167
  fileText.size,
163
168
  );
164
- if (accepted.length > 0) {
169
+ const stands = judgePick(onPick, accepted, rejected);
170
+ if (stands && accepted.length > 0) {
165
171
  setError(null);
166
172
  if (multiple) {
167
173
  onFilesSelected?.(accepted);
@@ -171,10 +177,12 @@ export function FileDropzone({
171
177
  }
172
178
  status.announce(labels.selected(accepted.length, accepted[0].name));
173
179
  }
174
- if (rejected.length > 0) {
180
+ if (!stands || rejected.length > 0) {
175
181
  for (const r of rejected) onInvalid?.(r.file);
176
- onReject?.(rejected);
177
- const message = summariseRejections(rejected, labels);
182
+ if (rejected.length > 0) onReject?.(rejected);
183
+ const message = stands
184
+ ? summariseRejections(rejected, labels)
185
+ : labels.rejectedPick(picked.length);
178
186
  if (feedback === "toast") {
179
187
  // sonner is an optional peer: imported here, on the failure path only, so an
180
188
  // app that never trips this never has to install it. Not awaited — nothing
@@ -190,9 +198,9 @@ export function FileDropzone({
190
198
  };
191
199
 
192
200
  // The remove buttons unmount with the file they removed, and focus would fall to
193
- // <body>. The zone is where the next action (pick again) starts.
201
+ // <body>. Browse is where the next action (pick again) starts.
194
202
  const afterRemoval = (message: string) => {
195
- rootRef.current?.focus();
203
+ browseRef.current?.focus();
196
204
  status.announce(message);
197
205
  };
198
206
 
@@ -202,11 +210,17 @@ export function FileDropzone({
202
210
 
203
211
  return (
204
212
  <>
213
+ {/* A named group, NOT a `role="button"`. It was one, with real buttons (Browse,
214
+ the remove buttons) inside it: nested interactive controls, which ARIA forbids
215
+ because a button's children are presentational — a screen reader either read
216
+ the zone as one opaque button or skipped the inner ones, and the zone was an
217
+ extra Tab stop that did what Browse does. Now the keyboard path is Browse, a
218
+ real button; the zone keeps its drop target and its click-anywhere for a
219
+ pointer, which is a convenience on top of Browse rather than the only way in. */}
220
+ {/* 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 */}
205
221
  <div
206
- // `...rest` first: every handler below is the drop gesture itself, and the
207
- // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.
222
+ // `...rest` first: every handler below is the drop gesture itself.
208
223
  {...rest}
209
- ref={rootRef}
210
224
  onDragEnter={(e) => {
211
225
  e.preventDefault();
212
226
  e.stopPropagation();
@@ -230,24 +244,16 @@ export function FileDropzone({
230
244
  setDragOver(false);
231
245
  acceptFiles(e.dataTransfer.files);
232
246
  }}
247
+ // Pointer only; every control inside stops its own click from reaching here. The
248
+ // keyboard equivalent is the Browse button, so no key handler belongs on a group.
233
249
  onClick={() => fileInputRef.current?.click()}
234
- onKeyDown={(e) => {
235
- // Only the zone's OWN keys. A remove button inside it is a real button, and
236
- // Enter on it bubbling up here would open the picker instead of removing.
237
- if (e.target !== e.currentTarget) return;
238
- if (e.key === "Enter" || e.key === " ") {
239
- e.preventDefault();
240
- fileInputRef.current?.click();
241
- }
242
- }}
243
- role="button"
244
- tabIndex={0}
250
+ role="group"
245
251
  // The DOM spelling wins over `dropLabel`, which stays the name for every caller
246
252
  // that passes no `aria-label` — i.e. all of them today. Standardising on
247
253
  // `aria-label` (audit §api-design) does not get to silently rename an existing
248
254
  // required prop.
249
255
  aria-label={ariaLabel ?? dropLabel}
250
- // No `aria-invalid`: ARIA does not allow it on `role="button"`. The message is
256
+ // No `aria-invalid`: ARIA does not allow it on `role="group"`. The message is
251
257
  // reached through the description instead, and spoken when it appears.
252
258
  aria-describedby={describedBy}
253
259
  data-invalid={showError || undefined}
@@ -307,7 +313,9 @@ export function FileDropzone({
307
313
  ) : (
308
314
  <>
309
315
  <div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
310
- {file ? <span className="font-medium">{file.name}</span> : emptyLabel}
316
+ {/* `!multiple`: in multiple mode an empty `files` list is "nothing chosen",
317
+ whatever a stray `file` prop says — it used to show that file's name. */}
318
+ {!multiple && file ? <span className="font-medium">{file.name}</span> : emptyLabel}
311
319
  {!multiple && file && onClear && (
312
320
  <IconButton
313
321
  type="button"
@@ -330,6 +338,7 @@ export function FileDropzone({
330
338
  )}
331
339
  <div className="flex flex-wrap items-center justify-center gap-2">
332
340
  <Button
341
+ ref={browseRef}
333
342
  type="button"
334
343
  variant="secondary"
335
344
  onClick={(e) => {
@@ -377,9 +386,9 @@ export function FileDropzone({
377
386
  className="sr-only"
378
387
  />
379
388
  </div>
380
- {/* Outside the zone: a live region inside a `role="button"` is inside
381
- presentational content, and screen readers are not consistent about
382
- reading those. `sr-only-fixed`, so they need no positioned ancestor. */}
389
+ {/* Outside the zone, where they have always been (the zone used to be a
390
+ `role="button"`, whose content is presentational). `sr-only-fixed`, so they
391
+ need no positioned ancestor. */}
383
392
  <span {...status.regionProps} />
384
393
  <span {...alert.regionProps} />
385
394
  </>
@@ -100,7 +100,7 @@ export function GroupedPicker({
100
100
  <ChevronDown className="size-4 shrink-0 text-[var(--text-placeholder)]" />
101
101
  </button>
102
102
  {open && (
103
- <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">
103
+ <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">
104
104
  <DropdownSearchHeader
105
105
  query={query}
106
106
  onQueryChange={setQuery}
@@ -132,7 +132,7 @@ export function GroupedPicker({
132
132
  setOpen(false);
133
133
  }}
134
134
  className={cn(
135
- "flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm",
135
+ "flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-start text-sm",
136
136
  isSelected
137
137
  ? "bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]"
138
138
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
- import { useOutsideClick } from "../hooks/use-dismiss";
4
+ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
5
5
 
6
6
  /**
7
7
  * Exported and `<div>`-shaped: the top bar composes several of these side by side, and
@@ -14,13 +14,18 @@ import { useOutsideClick } from "../hooks/use-dismiss";
14
14
  export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
15
15
  trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;
16
16
  children: (close: () => void) => ReactNode;
17
- align?: "left" | "right";
17
+ /**
18
+ * Which edge of the trigger the panel lines up with. `start`/`end` follow the
19
+ * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`
20
+ * are physical and kept for existing callers. Default `end` — the same right-aligned
21
+ * panel as before in LTR, mirrored in RTL.
22
+ */
23
+ align?: "start" | "end" | "left" | "right";
18
24
  panelClassName?: string;
19
25
  /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */
20
26
  className?: string;
21
27
  /**
22
- * @deprecated Use the DOM spelling `aria-label`, which now reaches the wrapper like
23
- * any other attribute. Kept working because keksdose, kastlan and lenkbank all pass
28
+ * @deprecated Use the DOM spelling `aria-label`, which names the `role="menu"` panel. Kept working because keksdose, kastlan and lenkbank all pass
24
29
  * this today and a rename to save one line of resolution is not a patch release; it
25
30
  * loses to `aria-label` wherever both are given. Removal is a later minor.
26
31
  */
@@ -35,6 +40,42 @@ const CLOSE_DELAY_MS = 120;
35
40
 
36
41
  type Phase = "closed" | "opening" | "open" | "closing";
37
42
 
43
+ const ALIGN_CLASS = { start: "start-0", end: "end-0", left: "left-0", right: "right-0" } as const;
44
+
45
+ /** Explicit menu-item roles a caller may already have written. */
46
+ const ITEM_ROLES = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
47
+ /** What the panel promotes to `menuitem` when the caller wrote plain controls. */
48
+ const PLAIN_ITEMS = "button:not([role]),a[href]:not([role])";
49
+ /** The trigger: the first focusable in the wrapper that is not inside the panel. */
50
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])';
51
+
52
+ /**
53
+ * Give the caller's markup menu semantics.
54
+ *
55
+ * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding
56
+ * `<button>`s and router `<Link>`s — so the panel was a `role="menu"` whose children
57
+ * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu
58
+ * with nothing in it as far as a screen reader is concerned. Rather than make every
59
+ * caller rewrite its rows, the panel upgrades them after render: controls become
60
+ * `menuitem`s out of the Tab order (the arrow keys move between them), and the list
61
+ * markup in between becomes `role="none"` so the menu owns its items directly. An
62
+ * element that already carries a role is the caller's decision and is left alone.
63
+ */
64
+ function applyMenuSemantics(menu: HTMLElement): HTMLElement[] {
65
+ for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute("role", "menuitem");
66
+ const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));
67
+ for (const item of items) {
68
+ item.setAttribute("tabindex", "-1");
69
+ for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {
70
+ if (!el.hasAttribute("role")) el.setAttribute("role", "none");
71
+ }
72
+ }
73
+ for (const el of menu.querySelectorAll<HTMLElement>("ul:not([role]),ol:not([role]),li:not([role])")) {
74
+ el.setAttribute("role", "none");
75
+ }
76
+ return items.filter((el) => !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true");
77
+ }
78
+
38
79
  // Only one hover menu may show its panel at a time across the app — opening
39
80
  // one closes whichever was open, so adjacent top-bar menus can't overlap
40
81
  // (feedback #251). Module-level is fine: there is one cursor per document.
@@ -43,7 +84,7 @@ let activeClose: (() => void) | null = null;
43
84
  export function HoverMenu({
44
85
  trigger,
45
86
  children,
46
- align = "right",
87
+ align = "end",
47
88
  panelClassName,
48
89
  className,
49
90
  ariaLabel,
@@ -118,6 +159,87 @@ export function HoverMenu({
118
159
 
119
160
  useOutsideClick(wrapperRef, close, open);
120
161
 
162
+ const menuRef = useRef<HTMLDivElement>(null);
163
+ /** Set by a keyboard press on the trigger, so the open that follows moves focus into
164
+ * the menu. A hover or a mouse click opens it without taking focus off the page. */
165
+ const focusOnOpenRef = useRef<"first" | "last" | null>(null);
166
+
167
+ const triggerEl = () =>
168
+ Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(
169
+ (el) => !menuRef.current?.contains(el),
170
+ ) ?? null;
171
+ const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);
172
+
173
+ // Escape closes, from anywhere — it did nothing at all before. Focus goes back to the
174
+ // trigger only when it was inside the menu: a panel opened by hover while the user is
175
+ // typing elsewhere must not yank the caret over here on the way out.
176
+ useEscapeKey(() => {
177
+ const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;
178
+ close();
179
+ if (hadFocus) triggerEl()?.focus();
180
+ }, open);
181
+
182
+ // Upgrade the markup every render while open (the caller's rows can change), then
183
+ // honour a pending keyboard open.
184
+ useLayoutEffect(() => {
185
+ if (!open) return;
186
+ const list = items();
187
+ const want = focusOnOpenRef.current;
188
+ focusOnOpenRef.current = null;
189
+ if (want) (want === "last" ? list[list.length - 1] : list[0])?.focus();
190
+ });
191
+
192
+ // Trigger state for assistive technology, on whatever element the caller rendered.
193
+ // Only where the caller has not said something of its own.
194
+ useLayoutEffect(() => {
195
+ const el = triggerEl();
196
+ if (!el) return;
197
+ if (!el.hasAttribute("aria-haspopup")) el.setAttribute("aria-haspopup", "menu");
198
+ if (el.getAttribute("aria-haspopup") === "menu") el.setAttribute("aria-expanded", String(open));
199
+ });
200
+
201
+ /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,
202
+ * Enter/Space/↓ open onto the first item and ↑ onto the last. */
203
+ const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
204
+ const target = e.target as HTMLElement;
205
+ const inMenu = menuRef.current?.contains(target) ?? false;
206
+ if (!inMenu) {
207
+ if (target !== triggerEl()) return;
208
+ if (e.key === "Enter" || e.key === " ") {
209
+ // The trigger's own click handler does the toggling; this only records that
210
+ // the press came from a keyboard.
211
+ if (!open) focusOnOpenRef.current = "first";
212
+ } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
213
+ e.preventDefault();
214
+ const edge = e.key === "ArrowDown" ? "first" : "last";
215
+ if (open) {
216
+ const list = items();
217
+ (edge === "last" ? list[list.length - 1] : list[0])?.focus();
218
+ } else {
219
+ focusOnOpenRef.current = edge;
220
+ openedByHoverRef.current = false;
221
+ setPhase("open");
222
+ }
223
+ }
224
+ return;
225
+ }
226
+ if (e.key === "Tab") {
227
+ close();
228
+ return;
229
+ }
230
+ const list = items();
231
+ if (list.length === 0) return;
232
+ const at = list.indexOf(document.activeElement as HTMLElement);
233
+ let next: number | null = null;
234
+ if (e.key === "ArrowDown") next = at < 0 ? 0 : (at + 1) % list.length;
235
+ else if (e.key === "ArrowUp") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;
236
+ else if (e.key === "Home") next = 0;
237
+ else if (e.key === "End") next = list.length - 1;
238
+ if (next === null) return;
239
+ e.preventDefault();
240
+ list[next].focus();
241
+ };
242
+
121
243
  // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a
122
244
  // trigger near a screen edge would otherwise spill off it (on mobile the guided-
123
245
  // tours menu ran off the left). Once open, measure and translate it horizontally
@@ -153,9 +275,10 @@ export function HoverMenu({
153
275
  className={cn("relative", className)}
154
276
  onMouseEnter={handleMouseEnter}
155
277
  onMouseLeave={handleMouseLeave}
156
- // The DOM spelling wins; {@link HoverMenuProps.ariaLabel} is the deprecated alias
157
- // three apps still pass, so it stays as the fallback rather than as the answer.
158
- aria-label={ariaLabelAttr ?? ariaLabel}
278
+ onKeyDown={(e) => {
279
+ rest.onKeyDown?.(e);
280
+ onKeyDown(e);
281
+ }}
159
282
  >
160
283
  {trigger({ open, toggle })}
161
284
  {open && (
@@ -163,12 +286,18 @@ export function HoverMenu({
163
286
  ref={panelRef}
164
287
  className={cn(
165
288
  "absolute top-full z-40 pt-2",
166
- align === "right" ? "right-0" : "left-0",
289
+ ALIGN_CLASS[align],
167
290
  )}
168
291
  style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}
169
292
  >
170
293
  <div
294
+ ref={menuRef}
171
295
  role="menu"
296
+ // On the element with the role. It used to sit on the role-less wrapper,
297
+ // where no screen reader reads a name. The DOM spelling wins;
298
+ // {@link HoverMenuProps.ariaLabel} is the deprecated alias three apps still
299
+ // pass, so it stays as the fallback rather than as the answer.
300
+ aria-label={ariaLabelAttr ?? ariaLabel}
172
301
  className={cn(
173
302
  "min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
174
303
  panelClassName,
@@ -65,6 +65,15 @@ export interface IconPickerProps<T extends string>
65
65
  searchable?: boolean;
66
66
  /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
67
67
  size?: TileSize;
68
+ /**
69
+ * Classes for every tile's frame, merged after the kit's own — so a caller's
70
+ * `size-*` beats `size`'s. For a tile that has to follow a container query or a
71
+ * breakpoint (keksdose grows its tiles to 40px in a narrow card:
72
+ * `tileClassName="@max-md:size-10"`) without reaching into the kit's markup with an
73
+ * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s
74
+ * dimensions; the tile grows round it.
75
+ */
76
+ tileClassName?: string;
68
77
  disabled?: boolean;
69
78
  labels?: Partial<IconPickerLabels>;
70
79
  }
@@ -93,6 +102,7 @@ export function IconPicker<T extends string>({
93
102
  activation = "automatic",
94
103
  searchable = false,
95
104
  size = "md",
105
+ tileClassName,
96
106
  disabled = false,
97
107
  labels,
98
108
  className,
@@ -156,7 +166,10 @@ export function IconPicker<T extends string>({
156
166
  return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;
157
167
  }}
158
168
  tileClassName={(_item, selected) =>
159
- selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
169
+ cn(
170
+ selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]",
171
+ tileClassName,
172
+ )
160
173
  }
161
174
  />
162
175
  </div>