@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
@@ -13,13 +13,29 @@ import { cn } from "../lib/cn";
13
13
  import { DropdownSearchHeader } from "./dropdown";
14
14
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
15
15
  import { useMediaQuery } from "../hooks/use-media-query";
16
- import { PHONE_QUERY } from "./ui";
16
+ import { FLOATING_LABEL_STATIC, PHONE_QUERY, Spinner } from "./ui";
17
+ import { useAnchorDir } from "./use-anchor-dir";
17
18
  import { type AnchorRect } from "../hooks/use-anchored-rect";
18
19
  import { useAnchoredPanel, type AnchoredPanel } from "../hooks/use-anchored-panel";
19
20
  import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
20
21
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
21
22
  import { hasMessage, mergeDescribedBy } from "./choice-parts";
22
23
 
24
+ /**
25
+ * What an entity picker hands `onChange` when it is cleared — `clearValue` on
26
+ * {@link EntityCombobox} and {@link InlineEntityCombobox}. `null` by default.
27
+ *
28
+ * A closed set rather than any value, on purpose: every member is something no
29
+ * option can be (an option's value is a `string | number`, and `""` is never a real
30
+ * id), so the picker can also READ it back — a field whose `value` is the clear
31
+ * value shows as empty, with no clear button, exactly as `null` does. An arbitrary
32
+ * sentinel could collide with an id and would make that read ambiguous.
33
+ *
34
+ * No `undefined`: a prop set to `undefined` is a prop not passed, so it would read as
35
+ * the default and emit `null` — the one outcome the caller had asked not to get.
36
+ */
37
+ export type ComboClearValue = null | "";
38
+
23
39
  export interface ComboOption<V extends string | number> {
24
40
  value: V;
25
41
  label: string;
@@ -33,6 +49,76 @@ export interface ComboOption<V extends string | number> {
33
49
  * Searchable like `sublabel`.
34
50
  */
35
51
  group?: string;
52
+ /**
53
+ * Shown but not takeable: rendered dimmed with `aria-disabled`, passed over by the
54
+ * arrow keys, and a click or Enter on it does nothing. For a row whose existence is
55
+ * the information — a write-locked result, an account that is closed — where
56
+ * leaving it out would read as "there is no such thing". Say WHY in `sublabel`: a
57
+ * hover tooltip is the one explanation a phone cannot show.
58
+ *
59
+ * Honoured by {@link Autocomplete}, {@link InlineEntityCombobox} and the panel
60
+ * pickers ({@link EntityCombobox}, {@link MultiEntityCombobox}); on those an already
61
+ * chosen disabled option stays chosen — the row only refuses to be toggled.
62
+ */
63
+ disabled?: boolean;
64
+ }
65
+
66
+ /** A row the user may take — present and not {@link ComboOption.disabled}. */
67
+ export const isOptionEnabled = (o: { disabled?: boolean } | undefined): boolean =>
68
+ o !== undefined && !o.disabled;
69
+
70
+ /**
71
+ * The next takeable row from `from`, stepping `dir` — what the arrow keys use so a
72
+ * disabled row is passed over, as the APG allows. Stays on `from` at the end of the
73
+ * list (the family's arrows clamp rather than wrap) and answers `-1` when `from` is
74
+ * not a row and there is nothing to step to.
75
+ */
76
+ export function stepEnabled(
77
+ rows: readonly { disabled?: boolean }[],
78
+ from: number,
79
+ dir: 1 | -1,
80
+ ): number {
81
+ for (let i = from + dir; i >= 0 && i < rows.length; i += dir) {
82
+ if (!rows[i].disabled) return i;
83
+ }
84
+ return from >= 0 && from < rows.length ? from : -1;
85
+ }
86
+
87
+ /** `i` when that row is takeable, else the nearest takeable one after it, else
88
+ * before it, else `-1`. For a highlight that starts on a fixed index (the panel
89
+ * pickers open on row 0) and must never rest on a disabled row. */
90
+ export function settleEnabled(rows: readonly { disabled?: boolean }[], i: number): number {
91
+ if (i < 0 || i >= rows.length) return -1;
92
+ if (!rows[i].disabled) return i;
93
+ const after = stepEnabled(rows, i, 1);
94
+ if (after !== i) return after;
95
+ const before = stepEnabled(rows, i, -1);
96
+ return before !== i ? before : -1;
97
+ }
98
+
99
+ /** Classes a disabled row adds over its normal ones — dimmed, no hover, the
100
+ * kit's soft-disabled look (`DangerConfirm`, `SignaturePad`). */
101
+ export const DISABLED_ROW_CLASS = "cursor-not-allowed opacity-50 hover:bg-transparent";
102
+
103
+ /**
104
+ * The family's floating label, as a real `<label for>` (lenkbank): the input is then
105
+ * named the way {@link Input}'s is, so `getByLabelText` and any helper that walks
106
+ * `<label htmlFor>` finds it. Same placement and type as {@link FieldLabel}, which
107
+ * stays a `<span>` for the TRIGGER pickers — a `<button>` is labelable, but those
108
+ * compose their own "label: value" name. `pointer-events-none` (from the class)
109
+ * keeps a press on it landing on the field under it, as before.
110
+ */
111
+ export function ComboboxFieldLabel({
112
+ htmlFor,
113
+ className,
114
+ children,
115
+ ...rest
116
+ }: ComponentPropsWithoutRef<"label"> & { htmlFor: string }) {
117
+ return (
118
+ <label {...rest} htmlFor={htmlFor} className={cn(FLOATING_LABEL_STATIC, "z-10", className)}>
119
+ {children}
120
+ </label>
121
+ );
36
122
  }
37
123
 
38
124
  /**
@@ -323,7 +409,7 @@ export const SUGGESTION_LIST_CLASS =
323
409
 
324
410
  export const suggestionRowClass = (isActive: boolean) =>
325
411
  cn(
326
- "block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
412
+ "block w-full truncate px-3 py-1.5 text-start text-sm text-[var(--text-primary)]",
327
413
  isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
328
414
  );
329
415
 
@@ -373,7 +459,7 @@ export interface ComboboxPanelProps<V extends string | number>
373
459
 
374
460
  /**
375
461
  * The portalled dropdown (search header + result rows + optional create row)
376
- * shared by the single- and multi-value comboboxes. Left-aligned to the trigger
462
+ * shared by the single- and multi-value comboboxes. Start-aligned to the trigger
377
463
  * and sized to its width; `multi` swaps the trailing check for a leading
378
464
  * checkbox. `onChoose` decides whether to close (single) or stay open (multi).
379
465
  *
@@ -434,34 +520,47 @@ export function ComboboxPanel<V extends string | number>({
434
520
  // Same core, same results, same handlers — only the container differs, so the
435
521
  // two presentations cannot drift in what they offer.
436
522
  const isPhone = useMediaQuery(PHONE_QUERY, false);
437
- const rowCount = results.length + (showCreate ? 1 : 0);
523
+ // Every row the keyboard can land on, the create row included (never disabled).
524
+ const rows: readonly { disabled?: boolean }[] = showCreate ? [...results, {}] : results;
525
+ const rowCount = rows.length;
438
526
  const optionId = (index: number) => `${listboxId}-option-${index}`;
439
- const activeId = active >= 0 && active < rowCount ? optionId(active) : undefined;
527
+ // The core opens on row 0 and knows nothing of `disabled`; the highlight is settled
528
+ // HERE, derived, so it can never rest on a row that cannot be taken — whatever the
529
+ // async list turned out to hold.
530
+ const current = settleEnabled(rows, active);
531
+ const activeId = current >= 0 ? optionId(current) : undefined;
440
532
  useActiveOptionScroll(activeId);
441
533
  // The consumers resolve their own props against the provider already; this is
442
534
  // for the loading row, which no consumer names, and for a caller that renders the
443
535
  // panel directly and leaves the two optional strings out.
444
536
  const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS);
537
+ // Portalled to <body>, the panel leaves the subtree its `dir` came from — so the
538
+ // direction is read off the trigger and put back on the panel below.
539
+ const dir = useAnchorDir(core.triggerRef, core.open);
445
540
 
446
541
  // The WAI-ARIA APG's listbox keyboard, in full: Up/Down step, Home/End jump, Enter
447
542
  // commits, Tab leaves. Escape is the one key handled elsewhere — `useComboboxCore`
448
543
  // registers it on the document, so it answers wherever focus has ended up.
449
544
  const onKeyDown = (e: ReactKeyboardEvent) => {
545
+ // Disabled rows are passed over (`stepEnabled`), and each step starts from the
546
+ // SETTLED highlight, not the raw index, so the first Down from a disabled row 0
547
+ // goes to the row after the one already shown as highlighted.
450
548
  if (e.key === "ArrowDown") {
451
549
  e.preventDefault();
452
- setActive((i) => Math.min(i + 1, rowCount - 1));
550
+ setActive(stepEnabled(rows, current, 1));
453
551
  } else if (e.key === "ArrowUp") {
454
552
  e.preventDefault();
455
- setActive((i) => Math.max(i - 1, 0));
553
+ setActive(current < 0 ? -1 : stepEnabled(rows, current, -1));
456
554
  } else if (e.key === "Home") {
457
555
  e.preventDefault();
458
- setActive(0);
556
+ setActive(stepEnabled(rows, -1, 1));
459
557
  } else if (e.key === "End") {
460
558
  e.preventDefault();
461
- setActive(rowCount - 1);
559
+ setActive(stepEnabled(rows, rowCount, -1));
462
560
  } else if (e.key === "Enter") {
463
561
  e.preventDefault();
464
- if (active < results.length) onChoose(results[active]);
562
+ if (current < 0) return;
563
+ if (current < results.length) onChoose(results[current]);
465
564
  else if (showCreate) onCreate();
466
565
  } else if (e.key === "Tab") {
467
566
  // The panel is PORTALLED to <body>, so the browser's own Tab would move from
@@ -490,7 +589,16 @@ export function ComboboxPanel<V extends string | number>({
490
589
  <span aria-hidden>…</span>
491
590
  <span className="sr-only">{labels.loading}</span>
492
591
  </>
493
- ) : !busy && results.length === 0 && !showCreate ? (
592
+ ) : busy ? (
593
+ // Loading with the LAST query's rows still up. This was `null`, so a refetch —
594
+ // a new query, or a caller's `loading` while it reloads — looked exactly like a
595
+ // finished list: rows that were about to be replaced, with nothing saying so.
596
+ // A line of its own under the rows, in words, so it reads without the spinner.
597
+ <span className="flex items-center gap-2">
598
+ <Spinner label={null} className="size-3.5 border" />
599
+ {labels.loading}
600
+ </span>
601
+ ) : results.length === 0 && !showCreate ? (
494
602
  failed ? (
495
603
  (loadErrorLabel ?? labels.loadError)
496
604
  ) : tooShort ? (
@@ -525,6 +633,9 @@ export function ComboboxPanel<V extends string | number>({
525
633
  id={listboxId}
526
634
  role="listbox"
527
635
  aria-multiselectable={multi}
636
+ // The rows are provisional while a lookup is in flight — whether or not the
637
+ // previous query's are still showing.
638
+ aria-busy={busy || undefined}
528
639
  className={cn("min-h-0 flex-1 overflow-y-auto py-1", isPhone && "flex-none")}
529
640
  >
530
641
  {results.map((o, i) => {
@@ -556,19 +667,26 @@ export function ComboboxPanel<V extends string | number>({
556
667
  id={optionId(i)}
557
668
  role="option"
558
669
  aria-selected={selected}
670
+ aria-disabled={o.disabled || undefined}
559
671
  tabIndex={-1}
560
672
  onMouseDown={(e) => {
561
673
  e.preventDefault();
562
- onChoose(o);
674
+ if (!o.disabled) onChoose(o);
675
+ }}
676
+ onMouseEnter={() => {
677
+ if (!o.disabled) setActive(i);
563
678
  }}
564
- onMouseEnter={() => setActive(i)}
565
679
  className={cn(
566
680
  isPhone
567
681
  ? SHEET_ROW_CLASS
568
- : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
569
- i === active && !isPhone
570
- ? "bg-[var(--bg-active)]"
571
- : !isPhone && "hover:bg-[var(--bg-hover)]",
682
+ : "flex w-full items-center gap-2 px-3 py-1.5 text-start text-sm",
683
+ o.disabled
684
+ ? DISABLED_ROW_CLASS
685
+ : i === current && !isPhone
686
+ ? "bg-[var(--bg-active)]"
687
+ : !isPhone && "hover:bg-[var(--bg-hover)]",
688
+ // After SHEET_ROW_CLASS, so its hover is the one cancelled.
689
+ o.disabled && isPhone && DISABLED_ROW_CLASS,
572
690
  )}
573
691
  >
574
692
  {multi && (
@@ -620,9 +738,9 @@ export function ComboboxPanel<V extends string | number>({
620
738
  className={cn(
621
739
  isPhone
622
740
  ? SHEET_ROW_CLASS
623
- : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
741
+ : "flex w-full items-center gap-2 px-3 py-1.5 text-start text-sm",
624
742
  "text-[var(--text-secondary)]",
625
- active === results.length && !isPhone
743
+ current === results.length && !isPhone
626
744
  ? "bg-[var(--bg-active)]"
627
745
  : !isPhone && "hover:bg-[var(--bg-hover)]",
628
746
  )}
@@ -663,6 +781,14 @@ export function ComboboxPanel<V extends string | number>({
663
781
  }
664
782
 
665
783
  if (!rect) return null;
784
+ // The panel hangs from the trigger's START edge: its left in a left-to-right form,
785
+ // its right in a right-to-left one (it may be wider than the trigger, `minWidth`).
786
+ // `clientWidth` rather than `innerWidth`, because a fixed box's `right` is measured
787
+ // from the edge of the viewport inside any scrollbar.
788
+ const inline =
789
+ dir === "rtl"
790
+ ? { right: (document.documentElement.clientWidth || window.innerWidth) - rect.right }
791
+ : { left: rect.left };
666
792
 
667
793
  return createPortal(
668
794
  <div
@@ -672,6 +798,7 @@ export function ComboboxPanel<V extends string | number>({
672
798
  // `aria-describedby` has nothing to collide with.
673
799
  {...rest}
674
800
  ref={panelRef}
801
+ dir={dir}
675
802
  onKeyDown={onKeyDown}
676
803
  // `placement` keeps the panel inside the region actually on screen: on a phone
677
804
  // the search box below pulls up the keyboard, and a panel pinned under a
@@ -680,7 +807,7 @@ export function ComboboxPanel<V extends string | number>({
680
807
  ...style,
681
808
  position: "fixed",
682
809
  top: placement.top,
683
- left: rect.left,
810
+ ...inline,
684
811
  width: rect.width,
685
812
  minWidth: 220,
686
813
  maxHeight: placement.maxHeight,
@@ -2,17 +2,23 @@ import { Fragment, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { ChevronDown, X } from "lucide-react";
5
- import { FieldLabel, FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
5
+ import { FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
6
6
  import { cn } from "../lib/cn";
7
7
  import { useDropdown } from "./dropdown";
8
8
  import { useAnchoredPanel } from "../hooks/use-anchored-panel";
9
+ import { useAnchorDir } from "./use-anchor-dir";
9
10
  import { useMediaQuery } from "../hooks/use-media-query";
10
11
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
11
12
  import {
13
+ ComboboxFieldLabel,
14
+ DISABLED_ROW_CLASS,
15
+ isOptionEnabled,
16
+ stepEnabled,
12
17
  SUGGESTION_LIST_CLASS,
13
18
  suggestionRowClass,
14
19
  useActiveOptionScroll,
15
20
  useComboboxFieldError,
21
+ type ComboClearValue,
16
22
  type ComboOption,
17
23
  } from "./combobox-core";
18
24
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
@@ -94,6 +100,7 @@ function SuggestionList({
94
100
  children: ReactNode;
95
101
  }) {
96
102
  const { rect, top, maxHeight } = useAnchoredPanel(anchorRef, true, { preferredHeight: 256 });
103
+ const dir = useAnchorDir(anchorRef, true);
97
104
  if (!rect) return null;
98
105
  return createPortal(
99
106
  <div
@@ -101,6 +108,9 @@ function SuggestionList({
101
108
  // z-50, not the old z-30: the list is a child of <body> now, so it is
102
109
  // competing with the app's own overlays rather than with its own siblings.
103
110
  className="fixed z-50"
111
+ // Portalled out of the form's `dir`; the field's is put back. The list is
112
+ // exactly the field's width, so `left` places it in either direction.
113
+ dir={dir}
104
114
  style={{ top, left: rect.left, width: rect.width }}
105
115
  >
106
116
  <ul id={id} role="listbox" className={LIST_CLASS} style={{ maxHeight }}>
@@ -361,22 +371,31 @@ export function Combobox({
361
371
  // the combobox a reader meets. Spread FIRST, so the field's ARIA and the
362
372
  // handlers carrying live #309 and dev#549 cannot be replaced from outside.
363
373
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
364
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
374
+ {/* A real <label for> since lenkbank's tests met a field `getByLabelText` could
375
+ not find: it used to be a <span>, with the text copied onto `aria-label`. The
376
+ name a reader hears is the same words either way. */}
377
+ {label !== undefined && (
378
+ <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
379
+ {label}
380
+ </ComboboxFieldLabel>
381
+ )}
365
382
  {/* The chevron centers against this inner wrapper, which hugs the input.
366
383
  The outer div can be taller than the input (as a grid item it
367
384
  stretches to the row height, e.g. next to the editor's category cell
368
385
  with its split button), which used to drag a top-1/2 chevron down to
369
386
  the input's bottom edge (feedback #248). */}
370
- <div ref={fieldRef} className="relative">
387
+ {/* Dimmed as a whole when disabled, the way EntityCombobox dims its trigger.
388
+ FIELD_BASE's grey alone left a disabled picker looking like a filled-in
389
+ one beside the pickers that do dim (the label is not the input's `peer`,
390
+ so it is dimmed by hand above). */}
391
+ <div ref={fieldRef} className={cn("relative", disabled && "cursor-not-allowed opacity-50")}>
371
392
  <input
372
393
  id={fieldId}
373
394
  value={value}
374
395
  placeholder={placeholder}
375
- // The visual label is a floating <span>, not a <label for>, so fall back to
376
- // it for the accessible name — otherwise the field announces only what is
377
- // typed in it (dev#477, applied to all three entity fields of a transaction
378
- // form at once).
379
- aria-label={ariaLabel ?? label}
396
+ // The caller's name, over the <label for> above — which is what names the
397
+ // field otherwise (dev#477: a field with neither announces only its text).
398
+ aria-label={ariaLabel}
380
399
  role="combobox"
381
400
  aria-expanded={open}
382
401
  // The list this field is the mouth of. Required by the role, and the half
@@ -462,7 +481,7 @@ export function Combobox({
462
481
  className={cn(
463
482
  FIELD_BASE,
464
483
  label !== undefined && FIELD_FLOATING_PAD,
465
- "pr-9",
484
+ "pe-9",
466
485
  field.isInvalid && FIELD_INVALID,
467
486
  )}
468
487
  />
@@ -474,8 +493,9 @@ export function Combobox({
474
493
  if (!disabled) setOpen((o) => !o);
475
494
  }}
476
495
  className={cn(
477
- "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
478
- disabled ? "opacity-50" : "cursor-pointer",
496
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
497
+ // The box above does the dimming; dimming here too would halve it again.
498
+ !disabled && "cursor-pointer",
479
499
  )}
480
500
  />
481
501
  </div>
@@ -605,7 +625,7 @@ export function Combobox({
605
625
  onMouseEnter={() => setActive(i)}
606
626
  className={cn(
607
627
  rowClass(i === active),
608
- group != null && "pl-6",
628
+ group != null && "ps-6",
609
629
  // A row with a badge is a flex row so the label truncates and the
610
630
  // badge keeps its width; without one it stays the plain block the
611
631
  // other lists render, so nothing shifts for callers that pass none.
@@ -627,16 +647,20 @@ export function Combobox({
627
647
 
628
648
  /** `id` is the INPUT's and `onChange` is the field's — see {@link ComboboxProps}
629
649
  * for why the div's spellings of them are omitted. */
630
- export interface InlineEntityComboboxProps<V extends string | number>
650
+ export interface InlineEntityComboboxProps<V extends string | number, C extends ComboClearValue = null>
631
651
  extends Omit<ComponentPropsWithoutRef<"div">, "id" | "onChange"> {
632
- /** Selected option id, or null when nothing is selected. */
633
- value: V | null;
652
+ /** Selected option id; `null` or `clearValue` when nothing is selected. */
653
+ value: V | C | null;
634
654
  /** Required and unanswered — see {@link Combobox}'s `invalid`. */
635
655
  invalid?: boolean;
636
656
  /** What is wrong with the value — see {@link Combobox}'s `error`. */
637
657
  error?: ReactNode;
638
- /** A picked/typed option emits its id; emptying the field emits `null`. */
639
- onChange: (v: V | null) => void;
658
+ /** A picked/typed option emits its id; emptying the field, or the "×", emits
659
+ * `clearValue`. */
660
+ onChange: (v: V | C) => void;
661
+ /** What a clear emits — `null` by default, `""` for a schema that wants an empty
662
+ * string. See {@link EntityCombobox}'s `clearValue`. */
663
+ clearValue?: C;
640
664
  options: ComboOption<V>[];
641
665
  label?: string;
642
666
  id?: string;
@@ -686,9 +710,10 @@ export interface InlineEntityComboboxProps<V extends string | number>
686
710
  * sheet that emptied it to show the full list would read as "the user cleared the
687
711
  * field" the moment it closed.
688
712
  */
689
- export function InlineEntityCombobox<V extends string | number>({
690
- value,
713
+ export function InlineEntityCombobox<V extends string | number, C extends ComboClearValue = null>({
714
+ value: rawValue,
691
715
  onChange,
716
+ clearValue = null as C,
692
717
  options,
693
718
  label,
694
719
  id,
@@ -705,7 +730,10 @@ export function InlineEntityCombobox<V extends string | number>({
705
730
  error,
706
731
  "aria-label": ariaLabel,
707
732
  ...rest
708
- }: InlineEntityComboboxProps<V>) {
733
+ }: InlineEntityComboboxProps<V, C>) {
734
+ // The clear value reads as "nothing selected", whichever one the caller picked —
735
+ // so from here down `value` is the id or `null`, as it always was.
736
+ const value: V | null = rawValue == null || rawValue === clearValue ? null : (rawValue as V);
709
737
  const field = useComboboxFieldError(error, invalid);
710
738
  const generated = useId();
711
739
  const fieldId = id ?? generated;
@@ -779,7 +807,8 @@ export function InlineEntityCombobox<V extends string | number>({
779
807
  // Pointer-device only: the phone's rows live in a {@link PickerSheet} whose own
780
808
  // search box holds focus, so the field behind it must not claim to be pointing at
781
809
  // one of them.
782
- const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;
810
+ // A disabled row is never the keyboard's, even if the list changed under it.
811
+ const activeId = !isPhone && isOptionEnabled(matches[active]) ? optionId(active) : undefined;
783
812
  useActiveOptionScroll(activeId);
784
813
 
785
814
  const close = () => {
@@ -788,6 +817,8 @@ export function InlineEntityCombobox<V extends string | number>({
788
817
  setSheetQuery("");
789
818
  };
790
819
  const commit = (o: ComboOption<V>) => {
820
+ // A `disabled` option is listed, never taken — by any path.
821
+ if (o.disabled) return;
791
822
  if (o.value !== value) onChange(o.value);
792
823
  setText(null);
793
824
  close();
@@ -806,9 +837,13 @@ export function InlineEntityCombobox<V extends string | number>({
806
837
  if (text !== null) {
807
838
  const q = text.trim();
808
839
  if (!q) {
809
- if (value != null) onChange(null);
840
+ if (value != null) onChange(clearValue);
810
841
  } else {
811
- const hits = options.filter((o) => o.label.toLowerCase() === q.toLowerCase());
842
+ // A disabled option's label typed out in full is not a way round `disabled`:
843
+ // it is not a hit, so the text reverts like any other non-answer.
844
+ const hits = options.filter(
845
+ (o) => !o.disabled && o.label.toLowerCase() === q.toLowerCase(),
846
+ );
812
847
  const ids = new Set(hits.map((h) => h.value));
813
848
  if (ids.size === 1 && hits[0].value !== value) onChange(hits[0].value);
814
849
  }
@@ -823,19 +858,27 @@ export function InlineEntityCombobox<V extends string | number>({
823
858
  // the combobox a reader meets. Spread FIRST, so the field's ARIA and the
824
859
  // handlers carrying live #309 and dev#549 cannot be replaced from outside.
825
860
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
826
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
861
+ {/* A real <label for> — see {@link Combobox}. */}
862
+ {label !== undefined && (
863
+ <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
864
+ {label}
865
+ </ComboboxFieldLabel>
866
+ )}
827
867
  {/* Inner wrapper for chevron centering — same reasoning as Combobox above.
828
868
  It is also what the portalled list anchors to. */}
829
- <div ref={fieldRef} className="relative">
869
+ {/* Dimmed as a whole when disabled, the way EntityCombobox dims its trigger.
870
+ FIELD_BASE's grey alone left a disabled picker looking like a filled-in
871
+ one beside the pickers that do dim (the label is not the input's `peer`,
872
+ so it is dimmed by hand above). */}
873
+ <div ref={fieldRef} className={cn("relative", disabled && "cursor-not-allowed opacity-50")}>
830
874
  <input
831
875
  id={fieldId}
832
876
  value={shown}
833
877
  placeholder={placeholder}
834
- // The visual label is a floating <span>, not a <label for>, so without this
835
- // the field has NO accessible name — it announces its value and nothing
836
- // else. Just the label, never "label: value" the way a trigger button has
837
- // to compose it: an input already exposes its value separately.
838
- aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)}
878
+ // The caller's name, over the <label for> above. Just the label, never
879
+ // "label: value" the way a trigger button has to compose it: an input
880
+ // already exposes its value separately.
881
+ aria-label={ariaLabel}
839
882
  role="combobox"
840
883
  aria-expanded={open}
841
884
  // Required by the role, and the half that was missing: the field said it
@@ -877,15 +920,17 @@ export function InlineEntityCombobox<V extends string | number>({
877
920
  setActive(-1);
878
921
  }}
879
922
  onKeyDown={(e) => {
923
+ // Disabled rows are passed over; Up from "nothing highlighted" lands on
924
+ // the first takeable row, as it always landed on row 0.
880
925
  if (e.key === "ArrowDown") {
881
926
  e.preventDefault();
882
927
  setOpen(true);
883
- setActive((i) => Math.min(i + 1, matches.length - 1));
928
+ setActive((i) => stepEnabled(matches, i, 1));
884
929
  } else if (e.key === "ArrowUp") {
885
930
  e.preventDefault();
886
- setActive((i) => Math.max(i - 1, 0));
931
+ setActive((i) => (i < 0 ? stepEnabled(matches, -1, 1) : stepEnabled(matches, i, -1)));
887
932
  } else if (e.key === "Enter") {
888
- if (open && active >= 0 && active < matches.length) {
933
+ if (open && isOptionEnabled(matches[active])) {
889
934
  e.preventDefault();
890
935
  commit(matches[active]);
891
936
  } else {
@@ -907,7 +952,7 @@ export function InlineEntityCombobox<V extends string | number>({
907
952
  className={cn(
908
953
  FIELD_BASE,
909
954
  label !== undefined && FIELD_FLOATING_PAD,
910
- "pr-9",
955
+ "pe-9",
911
956
  field.isInvalid && FIELD_INVALID,
912
957
  )}
913
958
  />
@@ -927,10 +972,10 @@ export function InlineEntityCombobox<V extends string | number>({
927
972
  onClick={() => {
928
973
  setText(null);
929
974
  close();
930
- onChange(null);
975
+ onChange(clearValue);
931
976
  }}
932
977
  className={cn(
933
- "absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
978
+ "absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
934
979
  "hover:text-[var(--text-secondary)]",
935
980
  )}
936
981
  >
@@ -947,8 +992,9 @@ export function InlineEntityCombobox<V extends string | number>({
947
992
  if (!disabled) setOpen((o) => !o);
948
993
  }}
949
994
  className={cn(
950
- "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
951
- disabled ? "opacity-50" : "cursor-pointer",
995
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
996
+ // The box above does the dimming; dimming here too would halve it again.
997
+ !disabled && "cursor-pointer",
952
998
  )}
953
999
  />
954
1000
  )}
@@ -989,8 +1035,13 @@ export function InlineEntityCombobox<V extends string | number>({
989
1035
  type="button"
990
1036
  role="option"
991
1037
  aria-selected={o.value === value}
1038
+ aria-disabled={o.disabled || undefined}
992
1039
  onClick={() => commit(o)}
993
- className={cn(SHEET_ROW_CLASS, o.value === value && "font-medium")}
1040
+ className={cn(
1041
+ SHEET_ROW_CLASS,
1042
+ o.value === value && "font-medium",
1043
+ o.disabled && DISABLED_ROW_CLASS,
1044
+ )}
994
1045
  >
995
1046
  {o.label}
996
1047
  </button>
@@ -1030,25 +1081,30 @@ export function InlineEntityCombobox<V extends string | number>({
1030
1081
  // `aria-activedescendant`.
1031
1082
  role="option"
1032
1083
  aria-selected={o.value === value}
1084
+ aria-disabled={o.disabled || undefined}
1033
1085
  tabIndex={-1}
1034
1086
  onMouseDown={(e) => {
1035
1087
  // mousedown (not click) so the input's blur can't close the
1036
- // list before the selection registers.
1088
+ // list before the selection registers. Prevented on a disabled
1089
+ // row too, so pressing one leaves the field focused and open.
1037
1090
  e.preventDefault();
1038
1091
  commit(o);
1039
1092
  }}
1040
- onMouseEnter={() => setActive(i)}
1093
+ onMouseEnter={() => {
1094
+ if (!o.disabled) setActive(i);
1095
+ }}
1041
1096
  className={cn(
1042
- rowClass(i === active),
1097
+ rowClass(i === active && !o.disabled),
1098
+ o.disabled && DISABLED_ROW_CLASS,
1043
1099
  // Indented under its heading, so the hierarchy is readable at a
1044
1100
  // glance instead of inferred from grey trailing text.
1045
- o.group && "pl-6",
1101
+ o.group && "ps-6",
1046
1102
  o.value === value && "font-medium",
1047
1103
  )}
1048
1104
  >
1049
1105
  {o.label}
1050
1106
  {o.sublabel && (
1051
- <span className="ml-2 text-xs text-[var(--text-placeholder)]">
1107
+ <span className="ms-2 text-xs text-[var(--text-placeholder)]">
1052
1108
  {o.sublabel}
1053
1109
  </span>
1054
1110
  )}
@@ -215,7 +215,7 @@ export function CurrencySelect({
215
215
  aria-controls={listboxId}
216
216
  aria-expanded={open}
217
217
  aria-invalid={invalid || undefined}
218
- className={cn(FIELD_TRIGGER, "pr-9", label !== undefined && FIELD_FLOATING_PAD, invalid && FIELD_INVALID)}
218
+ className={cn(FIELD_TRIGGER, "pe-9", label !== undefined && FIELD_FLOATING_PAD, invalid && FIELD_INVALID)}
219
219
  >
220
220
  {/* The trigger stays COMPACT — flag + code only — so it never clips in a
221
221
  narrow field; the full names live in the (wider) popup (feedback
@@ -235,10 +235,10 @@ export function CurrencySelect({
235
235
  {open && (
236
236
  <DropdownPanel
237
237
  // At least as wide as the trigger, but grows to fit the full currency
238
- // names and caps so it never runs off-screen. RIGHT-aligned so it grows
239
- // leftward instead of pushing the page width when there's content to the
240
- // right of the field (feedback #308).
241
- className="right-0 min-w-full w-max max-w-[min(20rem,calc(100vw-2rem))]"
238
+ // names and caps so it never runs off-screen. END-aligned so it grows
239
+ // toward the start instead of pushing the page width when there's content
240
+ // after the field (feedback #308).
241
+ className="end-0 min-w-full w-max max-w-[min(20rem,calc(100vw-2rem))]"
242
242
  empty={filtered.length === 0}
243
243
  listProps={{ id: listboxId }}
244
244
  header={
@@ -261,7 +261,7 @@ export function CurrencySelect({
261
261
  setOpen(false);
262
262
  }}
263
263
  className={cn(
264
- "flex w-full items-center justify-between gap-2 px-3 py-2 text-sm text-left hover:bg-[var(--bg-hover)]",
264
+ "flex w-full items-center justify-between gap-2 px-3 py-2 text-sm text-start hover:bg-[var(--bg-hover)]",
265
265
  active && "bg-[var(--bg-active)]",
266
266
  )}
267
267
  >