@eifi1/ui-kit 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +5 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +33 -6
  9. package/dist/components/autocomplete.js +62 -16
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +5 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/chip.d.ts +34 -14
  20. package/dist/components/chip.js +63 -28
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +18 -1
  23. package/dist/components/choice-card.js +21 -8
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/combobox-core.d.ts +61 -2
  26. package/dist/components/combobox-core.js +68 -17
  27. package/dist/components/combobox-core.js.map +1 -1
  28. package/dist/components/combobox.d.ts +11 -7
  29. package/dist/components/combobox.js +51 -28
  30. package/dist/components/combobox.js.map +1 -1
  31. package/dist/components/currency-select.js +3 -3
  32. package/dist/components/currency-select.js.map +1 -1
  33. package/dist/components/danger-confirm.d.ts +30 -6
  34. package/dist/components/danger-confirm.js +51 -32
  35. package/dist/components/danger-confirm.js.map +1 -1
  36. package/dist/components/data-table-filter-popover.d.ts +1 -1
  37. package/dist/components/data-table-filters.d.ts +1 -1
  38. package/dist/components/data-table-pagination.d.ts +6 -3
  39. package/dist/components/data-table-pagination.js +6 -5
  40. package/dist/components/data-table-pagination.js.map +1 -1
  41. package/dist/components/data-table.d.ts +1 -1
  42. package/dist/components/data-table.js +332 -248
  43. package/dist/components/data-table.js.map +1 -1
  44. package/dist/components/date-picker.js +17 -7
  45. package/dist/components/date-picker.js.map +1 -1
  46. package/dist/components/dialog-frame.d.ts +28 -3
  47. package/dist/components/dialog-frame.js +47 -11
  48. package/dist/components/dialog-frame.js.map +1 -1
  49. package/dist/components/disclosure.d.ts +22 -2
  50. package/dist/components/disclosure.js +41 -8
  51. package/dist/components/disclosure.js.map +1 -1
  52. package/dist/components/dropdown.d.ts +7 -3
  53. package/dist/components/dropdown.js +6 -2
  54. package/dist/components/dropdown.js.map +1 -1
  55. package/dist/components/entity-combobox.d.ts +17 -8
  56. package/dist/components/entity-combobox.js +7 -5
  57. package/dist/components/entity-combobox.js.map +1 -1
  58. package/dist/components/file-button.d.ts +38 -5
  59. package/dist/components/file-button.js +23 -2
  60. package/dist/components/file-button.js.map +1 -1
  61. package/dist/components/file-dropzone.d.ts +11 -5
  62. package/dist/components/file-dropzone.js +14 -19
  63. package/dist/components/file-dropzone.js.map +1 -1
  64. package/dist/components/grouped-picker.js +2 -2
  65. package/dist/components/grouped-picker.js.map +1 -1
  66. package/dist/components/hover-menu.d.ts +8 -3
  67. package/dist/components/hover-menu.js +88 -4
  68. package/dist/components/hover-menu.js.map +1 -1
  69. package/dist/components/icon-picker.d.ts +10 -1
  70. package/dist/components/icon-picker.js +5 -1
  71. package/dist/components/icon-picker.js.map +1 -1
  72. package/dist/components/measured-grid.d.ts +165 -0
  73. package/dist/components/measured-grid.js +559 -0
  74. package/dist/components/measured-grid.js.map +1 -0
  75. package/dist/components/mini-calendar.js +7 -8
  76. package/dist/components/mini-calendar.js.map +1 -1
  77. package/dist/components/month-picker.js +13 -9
  78. package/dist/components/month-picker.js.map +1 -1
  79. package/dist/components/multi-entity-combobox.js +2 -2
  80. package/dist/components/multi-entity-combobox.js.map +1 -1
  81. package/dist/components/multi-select.d.ts +9 -1
  82. package/dist/components/multi-select.js +22 -8
  83. package/dist/components/multi-select.js.map +1 -1
  84. package/dist/components/number-field.d.ts +5 -0
  85. package/dist/components/number-input.d.ts +5 -0
  86. package/dist/components/number-input.js +3 -3
  87. package/dist/components/number-input.js.map +1 -1
  88. package/dist/components/numpad-sheet.d.ts +5 -0
  89. package/dist/components/numpad-sheet.js +3 -3
  90. package/dist/components/numpad-sheet.js.map +1 -1
  91. package/dist/components/picker-sheet.js +1 -1
  92. package/dist/components/picker-sheet.js.map +1 -1
  93. package/dist/components/popover.js +11 -2
  94. package/dist/components/popover.js.map +1 -1
  95. package/dist/components/series-chart-ticks.d.ts +13 -0
  96. package/dist/components/series-chart-ticks.js +29 -0
  97. package/dist/components/series-chart-ticks.js.map +1 -0
  98. package/dist/components/series-chart.js +33 -27
  99. package/dist/components/series-chart.js.map +1 -1
  100. package/dist/components/settings-fields.d.ts +2 -0
  101. package/dist/components/swatch-picker.d.ts +10 -1
  102. package/dist/components/swatch-picker.js +2 -0
  103. package/dist/components/swatch-picker.js.map +1 -1
  104. package/dist/components/swipeable-row.d.ts +11 -2
  105. package/dist/components/swipeable-row.js +5 -3
  106. package/dist/components/swipeable-row.js.map +1 -1
  107. package/dist/components/tile-radio.js +5 -7
  108. package/dist/components/tile-radio.js.map +1 -1
  109. package/dist/components/time-input.d.ts +2 -0
  110. package/dist/components/toggle-group.d.ts +13 -3
  111. package/dist/components/toggle-group.js +21 -1
  112. package/dist/components/toggle-group.js.map +1 -1
  113. package/dist/components/toggle-legend.js +1 -2
  114. package/dist/components/toggle-legend.js.map +1 -1
  115. package/dist/components/tooltip.d.ts +10 -4
  116. package/dist/components/tooltip.js +23 -6
  117. package/dist/components/tooltip.js.map +1 -1
  118. package/dist/components/treemap.d.ts +6 -1
  119. package/dist/components/treemap.js +16 -6
  120. package/dist/components/treemap.js.map +1 -1
  121. package/dist/components/ui.d.ts +36 -8
  122. package/dist/components/ui.js +27 -19
  123. package/dist/components/ui.js.map +1 -1
  124. package/dist/components/use-anchor-dir.d.ts +15 -0
  125. package/dist/components/use-anchor-dir.js +14 -0
  126. package/dist/components/use-anchor-dir.js.map +1 -0
  127. package/dist/components/use-mobile-reveal.d.ts +8 -1
  128. package/dist/components/use-mobile-reveal.js +3 -2
  129. package/dist/components/use-mobile-reveal.js.map +1 -1
  130. package/dist/components/use-table-state.d.ts +4 -4
  131. package/dist/components/use-table-state.js +12 -5
  132. package/dist/components/use-table-state.js.map +1 -1
  133. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  134. package/dist/data-table.d.ts +1 -1
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/feedback/feedback-attachment.d.ts +22 -3
  137. package/dist/feedback/feedback-attachment.js +15 -7
  138. package/dist/feedback/feedback-attachment.js.map +1 -1
  139. package/dist/feedback/feedback-dialog.d.ts +3 -2
  140. package/dist/feedback/feedback-dialog.js.map +1 -1
  141. package/dist/feedback.d.ts +1 -1
  142. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  143. package/dist/hooks/use-windowed-rows.js +31 -0
  144. package/dist/hooks/use-windowed-rows.js.map +1 -0
  145. package/dist/i18n/defaults.d.ts +5 -0
  146. package/dist/i18n/defaults.js +5 -1
  147. package/dist/i18n/defaults.js.map +1 -1
  148. package/dist/i18n/kit-labels.d.ts +7 -0
  149. package/dist/i18n/kit-labels.js.map +1 -1
  150. package/dist/i18n/locales/de-CH.d.ts +45 -0
  151. package/dist/i18n/locales/de-CH.js +21 -0
  152. package/dist/i18n/locales/de-CH.js.map +1 -0
  153. package/dist/i18n/locales/de.d.ts +45 -0
  154. package/dist/i18n/locales/de.js +344 -0
  155. package/dist/i18n/locales/de.js.map +1 -0
  156. package/dist/i18n/locales/es.d.ts +45 -0
  157. package/dist/i18n/locales/es.js +339 -0
  158. package/dist/i18n/locales/es.js.map +1 -0
  159. package/dist/i18n/locales/fr.d.ts +45 -0
  160. package/dist/i18n/locales/fr.js +344 -0
  161. package/dist/i18n/locales/fr.js.map +1 -0
  162. package/dist/i18n/locales/hu.d.ts +45 -0
  163. package/dist/i18n/locales/hu.js +343 -0
  164. package/dist/i18n/locales/hu.js.map +1 -0
  165. package/dist/i18n/locales/it.d.ts +45 -0
  166. package/dist/i18n/locales/it.js +342 -0
  167. package/dist/i18n/locales/it.js.map +1 -0
  168. package/dist/i18n/locales/zh.d.ts +45 -0
  169. package/dist/i18n/locales/zh.js +338 -0
  170. package/dist/i18n/locales/zh.js.map +1 -0
  171. package/dist/index.d.ts +7 -4
  172. package/dist/index.js +4 -1
  173. package/dist/index.js.map +1 -1
  174. package/dist/lib/direction.d.ts +24 -0
  175. package/dist/lib/direction.js +19 -0
  176. package/dist/lib/direction.js.map +1 -0
  177. package/dist/rhf/form.d.ts +2 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/rhf.d.ts +2 -0
  180. package/dist/search/command-palette.d.ts +5 -0
  181. package/dist/search/command-palette.js +80 -50
  182. package/dist/search/command-palette.js.map +1 -1
  183. package/dist/shell/app-shell.d.ts +13 -1
  184. package/dist/shell/app-shell.js +134 -85
  185. package/dist/shell/app-shell.js.map +1 -1
  186. package/dist/shell/topbar-action-menu.js +1 -1
  187. package/dist/shell/topbar-action-menu.js.map +1 -1
  188. package/dist/shell/topbar-controls.d.ts +7 -3
  189. package/dist/shell/topbar-controls.js +38 -31
  190. package/dist/shell/topbar-controls.js.map +1 -1
  191. package/dist/theme/chart-palette.d.ts +7 -1
  192. package/dist/theme/chart-palette.js +2 -1
  193. package/dist/theme/chart-palette.js.map +1 -1
  194. package/dist/tour/tour.d.ts +11 -5
  195. package/dist/tour/tour.js +124 -58
  196. package/dist/tour/tour.js.map +1 -1
  197. package/dist/wizard/use-wizard.js +30 -13
  198. package/dist/wizard/use-wizard.js.map +1 -1
  199. package/dist/wizard/wizard-summary.js +1 -1
  200. package/dist/wizard/wizard-summary.js.map +1 -1
  201. package/package.json +12 -2
  202. package/src/components/account-settings.tsx +6 -2
  203. package/src/components/amount-input.tsx +10 -8
  204. package/src/components/autocomplete.tsx +110 -24
  205. package/src/components/calculator.tsx +5 -1
  206. package/src/components/chart-zoom.tsx +8 -1
  207. package/src/components/chart.tsx +68 -7
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +74 -5
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/tile-radio.tsx +5 -7
  240. package/src/components/toggle-group.tsx +39 -3
  241. package/src/components/toggle-legend.tsx +5 -2
  242. package/src/components/tooltip.tsx +40 -12
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +79 -33
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +201 -130
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
@@ -1,5 +1,6 @@
1
- import type { ComponentPropsWithoutRef } from "react";
1
+ import type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement } from "react";
2
2
  import { cn } from "../lib/cn";
3
+ import { horizontalStep } from "../lib/direction";
3
4
 
4
5
  export interface ToggleOption<T extends string> {
5
6
  value: T;
@@ -74,7 +75,19 @@ export type ToggleGroupProps<T extends string> =
74
75
  | ToggleGroupRequiredProps<T>
75
76
  | ToggleGroupClearableProps<T>;
76
77
 
77
- export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
78
+ /**
79
+ * Overloaded rather than typed by the union alone (keksdose, "Gaps found adopting
80
+ * 0.6.0" #7). Inferring `T` through a union of prop shapes let TypeScript settle on
81
+ * `string`, so `<ToggleGroup allowEmpty value={filter} onChange={setFilter} />` over a
82
+ * `useState<Status | null>` did not compile unless the caller spelled
83
+ * `<ToggleGroup<Status>>`. One signature per mode lets each infer `T` from its own
84
+ * `value`/`onChange`/`options`; the third keeps a caller that forwards a
85
+ * {@link ToggleGroupProps} union (a wrapper component) compiling.
86
+ */
87
+ export function ToggleGroup<T extends string>(props: ToggleGroupClearableProps<T>): ReactElement;
88
+ export function ToggleGroup<T extends string>(props: ToggleGroupRequiredProps<T>): ReactElement;
89
+ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement;
90
+ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement {
78
91
  const {
79
92
  value,
80
93
  options,
@@ -93,6 +106,27 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
93
106
  else props.onChange(next);
94
107
  };
95
108
  const clearable = props.allowEmpty === true;
109
+ // A radio group is ONE tab stop (the checked radio, else the first) and arrows move
110
+ // the choice — the pattern `role="radiogroup"` promises a screen-reader user. Before
111
+ // 0.7.0 each segment was its own tab stop with no arrow keys. The clearable mode is a
112
+ // row of toggle buttons, where separate tab stops are the pattern.
113
+ const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;
114
+ const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
115
+ const last = options.length - 1;
116
+ let next: number | null = null;
117
+ const step = horizontalStep(e.key, e.currentTarget);
118
+ if (step !== 0) next = index + step;
119
+ else if (e.key === "ArrowDown") next = index + 1;
120
+ else if (e.key === "ArrowUp") next = index - 1;
121
+ else if (e.key === "Home") next = 0;
122
+ else if (e.key === "End") next = last;
123
+ if (next === null) return;
124
+ e.preventDefault();
125
+ next = next < 0 ? last : next > last ? 0 : next;
126
+ const buttons = e.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(":scope > button");
127
+ buttons?.[next]?.focus();
128
+ choose(options[next].value);
129
+ };
96
130
  return (
97
131
  <div
98
132
  // The audit's named example of a closed prop list (§"Public API design"): the
@@ -135,7 +169,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
135
169
  className,
136
170
  )}
137
171
  >
138
- {options.map((opt) => {
172
+ {options.map((opt, index) => {
139
173
  const active = opt.value === value;
140
174
  return (
141
175
  <button
@@ -145,6 +179,8 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {
145
179
  aria-checked={clearable ? undefined : active}
146
180
  aria-pressed={clearable ? active : undefined}
147
181
  disabled={disabled}
182
+ tabIndex={clearable ? undefined : opt.value === tabStop ? 0 : -1}
183
+ onKeyDown={clearable ? undefined : (e) => onRadioKey(e, index)}
148
184
  onClick={() => choose(opt.value)}
149
185
  className={cn(
150
186
  // `truncate` (which carries whitespace-nowrap) rather than letting a
@@ -198,7 +198,11 @@ export function LegendGroup({ title, children }: { title: ReactNode; children: R
198
198
  * front of it.
199
199
  *
200
200
  * A stroke is an SVG line with the chart's own `strokeDasharray`, not a CSS gradient
201
- * approximating it: a gradient can say "solid or dashed" and nothing more. */
201
+ * approximating it: a gradient can say "solid or dashed" and nothing more.
202
+ *
203
+ * A hidden stroke is NOT faded here: the button around it already is (`opacity-35`),
204
+ * and the line used to take its own 0.35 on top — 0.35 × 0.35, about 12 %, a mark
205
+ * gone rather than dimmed, while the square beside it read at the full 35 %. */
202
206
  function LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {
203
207
  if (entry.marker === "stroke") {
204
208
  return (
@@ -211,7 +215,6 @@ function LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {
211
215
  stroke={entry.color}
212
216
  strokeWidth={2}
213
217
  strokeDasharray={strokeDash(entry.dash ?? 0)}
214
- opacity={off ? 0.35 : 1}
215
218
  />
216
219
  </svg>
217
220
  );
@@ -13,8 +13,22 @@ import { createPortal } from "react-dom";
13
13
  import { cn } from "../lib/cn";
14
14
  import { useEscapeKey } from "../hooks/use-dismiss";
15
15
  import { useAnchoredRect, type AnchorRect } from "../hooks/use-anchored-rect";
16
+ import { dirOf, type Direction } from "../lib/direction";
16
17
 
17
- type TooltipSide = "top" | "bottom" | "left" | "right";
18
+ /** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the
19
+ * right in LTR and the left in RTL — and are what a layout that mirrors should ask
20
+ * for. `left` / `right` stay physical, for a bubble tied to something that does not
21
+ * mirror (a chart axis, a map). */
22
+ export type TooltipSide = "top" | "bottom" | "left" | "right" | "start" | "end";
23
+
24
+ /** The placement the maths works in: a logical side resolved against the trigger. */
25
+ type PhysicalSide = "top" | "bottom" | "left" | "right";
26
+
27
+ function physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {
28
+ if (side === "start") return dir === "rtl" ? "right" : "left";
29
+ if (side === "end") return dir === "rtl" ? "left" : "right";
30
+ return side;
31
+ }
18
32
 
19
33
  /** The floating bubble itself. Uses the shared surface/border/text tokens so it
20
34
  * reads as part of the app's chrome (like the top bar and cards) rather than the
@@ -37,6 +51,10 @@ const sidePositionClass: Record<TooltipSide, string> = {
37
51
  bottom: "top-full left-1/2 -translate-x-1/2 mt-1",
38
52
  left: "right-full top-1/2 -translate-y-1/2 mr-1",
39
53
  right: "left-full top-1/2 -translate-y-1/2 ml-1",
54
+ // Logical insets, so CSS resolves the side from the inherited direction with no
55
+ // JavaScript: `end-full` pins the bubble's END edge to the trigger's start.
56
+ start: "end-full top-1/2 -translate-y-1/2 me-1",
57
+ end: "start-full top-1/2 -translate-y-1/2 ms-1",
40
58
  };
41
59
 
42
60
  /**
@@ -248,7 +266,7 @@ const TOOLTIP_GAP = 4;
248
266
  * against the glass reads as clipped even when every character is on screen. */
249
267
  const TOOLTIP_MARGIN = 4;
250
268
 
251
- const portalTransformBySide: Record<TooltipSide, string> = {
269
+ const portalTransformBySide: Record<PhysicalSide, string> = {
252
270
  right: "translate(0, -50%)",
253
271
  left: "translate(-100%, -50%)",
254
272
  top: "translate(-50%, -100%)",
@@ -259,7 +277,7 @@ const portalTransformBySide: Record<TooltipSide, string> = {
259
277
  * with {@link portalTransformBySide}, which shifts the box onto that point. */
260
278
  function tooltipAnchor(
261
279
  r: AnchorRect,
262
- side: TooltipSide,
280
+ side: PhysicalSide,
263
281
  ): { left: number; top: number } {
264
282
  switch (side) {
265
283
  case "right":
@@ -287,10 +305,10 @@ export interface TooltipPlacement {
287
305
  left: number;
288
306
  top: number;
289
307
  /** Which side it ended up on, which need not be the one that was asked for. */
290
- side: TooltipSide;
308
+ side: PhysicalSide;
291
309
  }
292
310
 
293
- const opposite: Record<TooltipSide, TooltipSide> = {
311
+ const opposite: Record<PhysicalSide, PhysicalSide> = {
294
312
  left: "right",
295
313
  right: "left",
296
314
  top: "bottom",
@@ -300,7 +318,7 @@ const opposite: Record<TooltipSide, TooltipSide> = {
300
318
  /** Whether the bubble clears the viewport edge on `side` of the trigger. */
301
319
  function roomOn(
302
320
  r: AnchorRect,
303
- side: TooltipSide,
321
+ side: PhysicalSide,
304
322
  size: TooltipSize,
305
323
  viewport: TooltipViewport,
306
324
  ): boolean {
@@ -359,7 +377,7 @@ function clamp(value: number, low: number, high: number): number {
359
377
  */
360
378
  export function placeTooltip(
361
379
  r: AnchorRect,
362
- side: TooltipSide,
380
+ side: PhysicalSide,
363
381
  size: TooltipSize,
364
382
  viewport: TooltipViewport,
365
383
  ): TooltipPlacement {
@@ -394,6 +412,14 @@ function PortalTooltip({
394
412
  const triggerRef = useRef<HTMLSpanElement | null>(null);
395
413
  const bubbleRef = useRef<HTMLSpanElement | null>(null);
396
414
  const [visible, setVisible] = useState(false);
415
+ // The trigger's reading direction, read when the bubble is asked for (an event, not a
416
+ // render): it resolves `start` / `end`, and the portalled bubble — which has left the
417
+ // subtree it would have inherited `dir` from — carries it too.
418
+ const [dir, setDir] = useState<Direction>("ltr");
419
+ const show = (el: Element) => {
420
+ setDir(dirOf(el));
421
+ setVisible(true);
422
+ };
397
423
  const id = useId();
398
424
  // Escape closes it outright, since this variant's bubble only exists while it is
399
425
  // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG
@@ -424,11 +450,12 @@ function PortalTooltip({
424
450
  });
425
451
  }, [visible, rect, label]);
426
452
 
427
- const point = rect ? tooltipAnchor(rect, side) : null;
453
+ const physical = physicalSide(side, dir);
454
+ const point = rect ? tooltipAnchor(rect, physical) : null;
428
455
  // Unmeasured on the very first pass, where the anchor point plus the side's
429
456
  // own transform is exactly what this always did. One layout effect later the
430
457
  // size is known and the placement is decided properly.
431
- const placed = rect && room ? placeTooltip(rect, side, room.size, room.viewport) : null;
458
+ const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;
432
459
 
433
460
  return (
434
461
  <>
@@ -440,9 +467,9 @@ function PortalTooltip({
440
467
  {...rest}
441
468
  ref={triggerRef}
442
469
  className={cn("relative inline-flex", className)}
443
- onMouseEnter={() => setVisible(true)}
470
+ onMouseEnter={(e) => show(e.currentTarget)}
444
471
  onMouseLeave={() => setVisible(false)}
445
- onFocus={() => setVisible(true)}
472
+ onFocus={(e) => show(e.currentTarget)}
446
473
  onBlur={() => setVisible(false)}
447
474
  >
448
475
  {describedBy(children, visible ? id : undefined)}
@@ -455,6 +482,7 @@ function PortalTooltip({
455
482
  ref={bubbleRef}
456
483
  id={id}
457
484
  role="tooltip"
485
+ dir={dir}
458
486
  data-private={redact ? "" : undefined}
459
487
  style={
460
488
  placed
@@ -463,7 +491,7 @@ function PortalTooltip({
463
491
  position: "fixed",
464
492
  left: point.left,
465
493
  top: point.top,
466
- transform: portalTransformBySide[side],
494
+ transform: portalTransformBySide[physical],
467
495
  }
468
496
  }
469
497
  className={cn(TOOLTIP_SURFACE, "pointer-events-none z-50")}
@@ -8,12 +8,13 @@
8
8
  // chart. The one thing it cannot take from a CSS var is the tile colour: the label ink
9
9
  // is MEASURED against the fill, and a `var(--chart-3)` string measures as nothing — see
10
10
  // `useChartTokenHex` below for how the concrete hex is found instead.
11
- import { useSyncExternalStore } from "react";
11
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
12
12
  import type { ComponentProps, ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from "react";
13
13
  import { Treemap as RechartsTreemap } from "recharts";
14
14
  import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart";
15
15
  import { PALETTE_HEX, textOn } from "../theme/chart-palette";
16
16
  import { parseHex } from "../theme/color";
17
+ import { dirOf, type Direction } from "../lib/direction";
17
18
 
18
19
  /**
19
20
  * One tile.
@@ -174,6 +175,10 @@ export interface TreemapCellProps {
174
175
  onNodeClick?: (id: string, name: string) => void;
175
176
  redactNames?: boolean;
176
177
  nodeNote?: (id: string) => string | undefined;
178
+ /** The page's reading direction, which `Treemap` reads off its own root. In `rtl`
179
+ * the label stands in the tile's top RIGHT corner and is shaped right-to-left, so a
180
+ * clipped name ends in its ellipsis on the reading end. Default `ltr`. */
181
+ dir?: Direction;
177
182
  }
178
183
 
179
184
  /**
@@ -199,6 +204,7 @@ export function TreemapCell({
199
204
  onNodeClick,
200
205
  redactNames,
201
206
  nodeNote,
207
+ dir = "ltr",
202
208
  }: TreemapCellProps) {
203
209
  // Depth 0 is recharts' synthetic root, which spans the whole chart.
204
210
  if (!depth) return null;
@@ -220,6 +226,11 @@ export function TreemapCell({
220
226
  const rawNote = id != null && nodeNote ? nodeNote(id) : undefined;
221
227
  const note =
222
228
  label != null && rawNote != null && ph >= 36 ? fitLabel(rawNote, pw, NOTE_FONT_SIZE) : null;
229
+ // The label's START edge, 6px in from the tile's. The tiles themselves are laid out
230
+ // physically (the plot is not mirrored, see `ChartContainer`) and the chart's SVG is
231
+ // pinned `ltr`, so the direction is set on each <text> explicitly: `start` is then
232
+ // the right end in RTL, and the label grows leftwards from the tile's right edge.
233
+ const textX = dir === "rtl" ? px + pw - 6 : px + 6;
223
234
  const activate = id != null && onNodeClick ? () => onNodeClick(id, name ?? "") : undefined;
224
235
  const onKeyDown = activate
225
236
  ? (e: KeyboardEvent<SVGGElement>) => {
@@ -261,8 +272,9 @@ export function TreemapCell({
261
272
  // A plain attribute, so the host's `[data-private]` rule reaches an SVG
262
273
  // <text> exactly as it reaches a table cell.
263
274
  data-private={redactNames ? "" : undefined}
264
- x={px + 6}
275
+ x={textX}
265
276
  y={py + 15}
277
+ direction={dir}
266
278
  fill={ink}
267
279
  stroke={tileFill}
268
280
  strokeWidth={2.5}
@@ -277,8 +289,9 @@ export function TreemapCell({
277
289
  )}
278
290
  {note != null && (
279
291
  <text
280
- x={px + 6}
292
+ x={textX}
281
293
  y={py + 29}
294
+ direction={dir}
282
295
  fill={ink}
283
296
  fillOpacity={0.85}
284
297
  stroke={tileFill}
@@ -350,6 +363,14 @@ export function Treemap({
350
363
  ...rest
351
364
  }: TreemapProps) {
352
365
  const tokenHex = useChartTokenHex();
366
+ // Read off the DOM after mount: the `dir` a treemap sits under is an ancestor's
367
+ // attribute, and SVG text anchoring cannot follow it by CSS (see TreemapCell). Again
368
+ // on new data, because a map that had nothing to draw rendered no root to read.
369
+ const rootRef = useRef<HTMLDivElement>(null);
370
+ const [dir, setDir] = useState<Direction>("ltr");
371
+ useLayoutEffect(() => {
372
+ setDir(dirOf(rootRef.current));
373
+ }, [data]);
353
374
  const ramp = colors && colors.length > 0 ? colors : tokenHex;
354
375
  // Coloured by index into `data` AS GIVEN, before anything is dropped: a caller
355
376
  // painting a legend or a second map from the same list must land on the same hue
@@ -373,7 +394,7 @@ export function Treemap({
373
394
  };
374
395
  const Content = redactNames ? RedactedTooltipContent : ChartTooltipContent;
375
396
  return (
376
- <ChartContainer {...rest} config={TREEMAP_CONFIG} style={{ ...style, height }}>
397
+ <ChartContainer {...rest} ref={rootRef} config={TREEMAP_CONFIG} style={{ ...style, height }}>
377
398
  {/* No chart-level `stroke`: recharts spreads it onto the <svg> root, from where
378
399
  it inherits into every label (see TreemapCell). Each cell strokes its own rect. */}
379
400
  <RechartsTreemap
@@ -381,7 +402,9 @@ export function Treemap({
381
402
  dataKey="size"
382
403
  fill="var(--chart-1)"
383
404
  isAnimationActive={false}
384
- content={<TreemapCell onNodeClick={onNodeClick} redactNames={redactNames} nodeNote={nodeNote} />}
405
+ content={
406
+ <TreemapCell onNodeClick={onNodeClick} redactNames={redactNames} nodeNote={nodeNote} dir={dir} />
407
+ }
385
408
  >
386
409
  <ChartTooltip content={<Content hideLabel valueFormatter={format} />} />
387
410
  </RechartsTreemap>
@@ -1,9 +1,10 @@
1
1
  import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
- import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
3
+ import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
+ import { horizontalStep } from "../lib/direction";
5
6
  import { useMediaQuery } from "../hooks/use-media-query";
6
- import { Tooltip } from "./tooltip";
7
+ import { Tooltip, type TooltipSide } from "./tooltip";
7
8
  import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
8
9
 
9
10
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand";
@@ -49,6 +50,10 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
49
50
  /** In a flex row next to a taller labelled field, fill the field's height so the
50
51
  * two line up. No effect outside a flex row. */
51
52
  stretch?: boolean;
53
+ /** The `<button>` element. React 19 passes `ref` to a function component as an
54
+ * ordinary prop, so it rides `...rest` onto the element with no `forwardRef` —
55
+ * declared here only because `ButtonHTMLAttributes` does not carry it. */
56
+ ref?: Ref<HTMLButtonElement>;
52
57
  }
53
58
 
54
59
  export function Button({ variant = "primary", stretch, className, ...rest }: ButtonProps) {
@@ -275,7 +280,7 @@ export const FIELD_DISPLAY =
275
280
  // button the way the native Select does.
276
281
  export const FIELD_TRIGGER = cn(
277
282
  FIELD_BASE,
278
- "relative flex items-center justify-between gap-2 text-left hover:bg-[var(--bg-hover)]",
283
+ "relative flex items-center justify-between gap-2 text-start hover:bg-[var(--bg-hover)]",
279
284
  );
280
285
 
281
286
  /** An alias rather than an interface: the chevron adds nothing of its own to an
@@ -290,7 +295,10 @@ export type FieldChevronProps = Omit<ComponentPropsWithoutRef<"svg">, "children"
290
295
  * it centres on the *content* box instead, and `FIELD_FLOATING_PAD` (pt-4 pb-1)
291
296
  * pushes that box's midline down — so a labelled currency/multi-select chevron
292
297
  * sat visibly lower than the native select's right beside it (feedback #400).
293
- * Pair it with `pr-9` on the trigger so the value can't run underneath. */
298
+ * Pair it with `pe-9` on the trigger so the value can't run underneath. At the
299
+ * inline END, not the right: a right-to-left form reads the value from the right,
300
+ * and a chevron parked on top of its first word hid exactly the part that says
301
+ * what was picked. */
294
302
  export function FieldChevron({ className, ...rest }: FieldChevronProps) {
295
303
  return (
296
304
  <ChevronDown
@@ -300,7 +308,7 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
300
308
  // announcement of the same field.
301
309
  aria-hidden
302
310
  className={cn(
303
- "pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
311
+ "pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
304
312
  className,
305
313
  )}
306
314
  />
@@ -311,7 +319,7 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
311
319
  // floats up INSIDE the top strip on focus or once the field has a value. Sits on
312
320
  // the field's own surface, so no background chip and nothing to mismatch the card.
313
321
  export const FLOATING_LABEL_CLASS = cn(
314
- "pointer-events-none absolute left-3 top-2.5 text-sm text-[var(--text-placeholder)] transition-all",
322
+ "pointer-events-none absolute start-3 top-2.5 text-sm text-[var(--text-placeholder)] transition-all",
315
323
  "max-w-[calc(100%-1.5rem)] truncate",
316
324
  "peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
317
325
  "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
@@ -324,13 +332,13 @@ export const FLOATING_LABEL_CLASS = cn(
324
332
  // selector, so the classes have to sit on the element that is actually a sibling
325
333
  // of the input, and a label nested inside a wrapper is not one.
326
334
  //
327
- // Wider right clearance than the label alone takes (`3rem` rather than `1.5rem`),
335
+ // Wider end clearance than the label alone takes (`3rem` rather than `1.5rem`),
328
336
  // because the controls that carry an animated label are the ones with something
329
- // at the right edge of the field — NumberInput's calculator is the case this was
337
+ // at the end edge of the field — NumberInput's calculator is the case this was
330
338
  // written for. The label truncates a little sooner; the alternative was the "?"
331
339
  // sitting on top of a button (steering-design feedback #48).
332
340
  const FLOATING_ROW_CLASS = cn(
333
- "pointer-events-none absolute left-3 top-2.5 flex items-center gap-1 transition-all",
341
+ "pointer-events-none absolute start-3 top-2.5 flex items-center gap-1 transition-all",
334
342
  "max-w-[calc(100%-3rem)] text-sm text-[var(--text-placeholder)]",
335
343
  "peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-[var(--text-secondary)]",
336
344
  "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-[var(--text-secondary)]",
@@ -348,7 +356,7 @@ const STATIC_LABEL_TYPE =
348
356
  // A field that always has a value (select / dropdown trigger) keeps the label
349
357
  // permanently in the floated position — small, in the top strip, value below.
350
358
  export const FLOATING_LABEL_STATIC = cn(
351
- "pointer-events-none absolute left-3 top-1",
359
+ "pointer-events-none absolute start-3 top-1",
352
360
  STATIC_LABEL_TYPE,
353
361
  "max-w-[calc(100%-1.5rem)] truncate",
354
362
  );
@@ -377,7 +385,7 @@ export interface FloatingFieldProps extends ComponentPropsWithoutRef<"div"> {
377
385
  * It rides an ANIMATED label too, and the two cases place it differently on
378
386
  * purpose. A static label has a field-wide strip to itself, so the hint sits
379
387
  * at the far end of it and a long label truncates into the gap. An animated
380
- * one belongs to a control with something at the right edge of the field — a
388
+ * one belongs to a control with something at the end edge of the field — a
381
389
  * calculator, a stepper — so the hint follows the label instead, and it is
382
390
  * the label that gives way (steering-design feedback #48). */
383
391
  hint?: ReactNode;
@@ -422,8 +430,8 @@ export function FloatingField({
422
430
  <div {...rest} className={cn("relative", className)}>
423
431
  {children}
424
432
  {withHint ? (
425
- // Static: `inset-x-3` rather than `left-3`, so a long label truncates at
426
- // the field's own right padding instead of running under the chevron.
433
+ // Static: `inset-x-3` rather than `start-3`, so a long label truncates at
434
+ // the field's own end padding instead of running under the chevron.
427
435
  // Animated: the row floats with the label and is only as wide as it needs
428
436
  // to be. Either way the hint keeps its width (`shrink-0`) and the label
429
437
  // is the one that gives way.
@@ -464,12 +472,14 @@ export interface FieldHintProps extends Omit<ComponentPropsWithoutRef<"button">,
464
472
  /** The explanation. It is both the tooltip's text and, by default, the button's
465
473
  * accessible name, so a screen reader gets it without the bubble ever opening. */
466
474
  label: string;
467
- side?: "left" | "right" | "top" | "bottom";
475
+ /** Default `"start"`: before the hint in the reading direction (the left in LTR, the
476
+ * right in RTL). `left` / `right` stay physical. */
477
+ side?: TooltipSide;
468
478
  }
469
479
 
470
480
  export function FieldHint({
471
481
  label,
472
- side = "left",
482
+ side = "start",
473
483
  className,
474
484
  "aria-label": ariaLabel,
475
485
  ...rest
@@ -765,7 +775,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
765
775
  disabled={rest.disabled}
766
776
  aria-label={revealed ? passwordText.hide : passwordText.show}
767
777
  aria-pressed={revealed}
768
- className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2.5 text-[var(--text-placeholder)] transition-colors hover:text-[var(--text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--brand)] disabled:cursor-default disabled:opacity-50"
778
+ className="absolute inset-y-0 end-0 flex items-center rounded-e-md px-2.5 text-[var(--text-placeholder)] transition-colors hover:text-[var(--text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--brand)] disabled:cursor-default disabled:opacity-50"
769
779
  >
770
780
  {revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
771
781
  </button>
@@ -804,7 +814,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
804
814
  {...rest}
805
815
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
806
816
  aria-describedby={describedBy}
807
- className={cn(FIELD_BASE, "pr-9", inputClassName, isInvalid && FIELD_INVALID)}
817
+ className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
808
818
  />
809
819
  {revealToggle}
810
820
  </div>
@@ -831,7 +841,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
831
841
  asDisplay
832
842
  ? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
833
843
  : FLOATING_INPUT_CLASS,
834
- isPassword && "pr-9",
844
+ isPassword && "pe-9",
835
845
  inputClassName,
836
846
  isInvalid && FIELD_INVALID,
837
847
  )}
@@ -858,7 +868,9 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
858
868
  * the tall box to float in, so a labelled Select ignores `"sm"`.
859
869
  *
860
870
  * A NUMBER is still the native attribute (the rows of a list box) and is passed
861
- * straight through, so the one HTML meaning of `size` keeps working.
871
+ * straight through, so the one HTML meaning of `size` keeps working. Above 1 (as
872
+ * with `multiple`) the browser draws a list box, and the Select drops its chevron
873
+ * and the end padding reserved for it.
862
874
  */
863
875
  size?: "sm" | "md" | number;
864
876
  /** Classes for the `<select>` itself. `className` styles the WRAPPER — the box
@@ -870,7 +882,7 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
870
882
  // The compact select: the field's colours, a toolbar button's height. `py-0` and a
871
883
  // fixed height rather than a smaller padding, so the box is 28px whatever line
872
884
  // height the caller's type brings with it.
873
- const SELECT_SM = "h-7 py-0 pl-2 pr-7 text-xs";
885
+ const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
874
886
 
875
887
  export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
876
888
  { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest },
@@ -879,6 +891,12 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
879
891
  const generated = useId();
880
892
  // Only a number reaches the DOM; the two words are this component's own.
881
893
  const nativeSize = typeof size === "number" ? size : undefined;
894
+ // A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
895
+ // the rows in the field, no menu to drop. The dropdown dress did not fit it: the
896
+ // chevron was centred on a box several rows tall, sat on top of the rows' text
897
+ // at the end edge, and `pe-9` clipped every option nine units early. A list box
898
+ // gets the plain field instead — its own native scroll, no chevron, even padding.
899
+ const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);
882
900
  const small = size === "sm" && label === undefined;
883
901
  const fieldId = id ?? generated;
884
902
  const { isInvalid, describedBy, errorEl } = useFieldError(
@@ -888,13 +906,16 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
888
906
  rest["aria-invalid"],
889
907
  );
890
908
  // Custom chevron (native arrow hidden via appearance-none) so it sits a touch
891
- // in from the right border and matches both themes — feedback #223. A DISABLED
909
+ // in from the end border and matches both themes — feedback #223. A DISABLED
892
910
  // select has no menu to drop, so it drops the chevron too: the arrow is the one
893
911
  // thing on the control that promises a choice (Keksdose dev#474, where the
894
912
  // account type became read-only and still looked exactly like a picker).
895
- const chevron = rest.disabled ? null : (
896
- <FieldChevron className={small ? "right-1.5 size-3.5" : undefined} />
897
- );
913
+ const chevron =
914
+ rest.disabled || listBox ? null : (
915
+ <FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
916
+ );
917
+ // `pe-9` is the room the chevron takes; a list box has none to make room for.
918
+ const dress = listBox ? "overflow-y-auto" : "appearance-none pe-9";
898
919
  if (label === undefined) {
899
920
  return (
900
921
  <FieldGroup errorEl={errorEl}>
@@ -919,7 +940,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
919
940
  aria-describedby={describedBy}
920
941
  className={cn(
921
942
  FIELD_BASE,
922
- "appearance-none pr-9",
943
+ dress,
923
944
  small && SELECT_SM,
924
945
  selectClassName,
925
946
  isInvalid && FIELD_INVALID,
@@ -944,8 +965,11 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
944
965
  aria-describedby={describedBy}
945
966
  className={cn(
946
967
  FIELD_BASE,
947
- FIELD_FLOATING_PAD,
948
- "peer appearance-none pr-9",
968
+ // The floated label takes the top strip of a list box too, so its first
969
+ // row starts under the label rather than behind it.
970
+ listBox ? "pt-5 pb-1" : FIELD_FLOATING_PAD,
971
+ "peer",
972
+ dress,
949
973
  selectClassName,
950
974
  isInvalid && FIELD_INVALID,
951
975
  )}
@@ -1320,7 +1344,8 @@ export interface TabsProps<T extends string>
1320
1344
  */
1321
1345
  panelId?: string;
1322
1346
  /**
1323
- * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab.
1347
+ * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab
1348
+ * (only on the open one with `removeOn="active"`).
1324
1349
  *
1325
1350
  * The × is on the open tab only — a row of tabs each carrying one is a row of
1326
1351
  * things to hit by accident, and the tab you can remove should be the one you
@@ -1328,7 +1353,7 @@ export interface TabsProps<T extends string>
1328
1353
  * tab never widens it and never pushes the rest of the strip along (lenkbank
1329
1354
  * feedback #72). It is named "Remove <tab>" and is not a tab stop of its own: the
1330
1355
  * strip stays one stop, and the keyboard path is Delete (announced through
1331
- * `aria-keyshortcuts`), which removes whichever tab has focus.
1356
+ * `aria-keyshortcuts`), which removes whichever tab has focus (see `removeOn`).
1332
1357
  *
1333
1358
  * The strip only ASKS: it calls this, and the tab goes when the caller drops it
1334
1359
  * from `tabs` — after a confirmation, a request, whatever it needs. When it does,
@@ -1336,6 +1361,20 @@ export interface TabsProps<T extends string>
1336
1361
  * removed one was open, else to its neighbour, instead of falling to the page.
1337
1362
  */
1338
1363
  onRemove?: (id: T) => void;
1364
+ /**
1365
+ * Which tabs the Delete key removes.
1366
+ *
1367
+ * - `"every"` (default): any focused removable tab — the ARIA pattern's reading.
1368
+ * - `"active"`: only the OPEN tab, the same one that wears the ×. For a strip whose
1369
+ * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement
1370
+ * sheet at once), so arrowing across the strip and pressing Delete cannot take
1371
+ * out a sheet the user is not looking at. Delete on a closed tab does nothing,
1372
+ * and only the open tab announces the shortcut (`aria-keyshortcuts`).
1373
+ *
1374
+ * Either way the × is drawn on the open tab only and its room stays reserved on
1375
+ * every removable tab, so the strip never relayouts when the selection moves.
1376
+ */
1377
+ removeOn?: "every" | "active";
1339
1378
  /** Renders an "add" button after the last tab, outside the tablist — it is an
1340
1379
  * action, not a tab, and a tablist may own only tabs. */
1341
1380
  onAdd?: () => void;
@@ -1401,6 +1440,7 @@ export function Tabs<T extends string>({
1401
1440
  label,
1402
1441
  panelId,
1403
1442
  onRemove,
1443
+ removeOn = "every",
1404
1444
  onAdd,
1405
1445
  addLabel,
1406
1446
  labels,
@@ -1411,6 +1451,9 @@ export function Tabs<T extends string>({
1411
1451
  const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
1412
1452
  const stripRef = useRef<HTMLDivElement>(null);
1413
1453
  const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
1454
+ // Whether Delete reaches this tab: every removable tab, or only the open one.
1455
+ const deletesOnKey = (tab: TabItem<T>) =>
1456
+ isRemovable(tab) && (removeOn === "every" || tab.id === active);
1414
1457
  const nameOf = (tab: TabItem<T>) =>
1415
1458
  tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
1416
1459
 
@@ -1463,13 +1506,16 @@ export function Tabs<T extends string>({
1463
1506
  const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {
1464
1507
  // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable
1465
1508
  // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it
1466
- // rather than letting it fall through to the page.
1467
- if (e.key === "Delete" && isRemovable(tab)) {
1509
+ // rather than letting it fall through to the page. With `removeOn="active"` only
1510
+ // the open tab answers; a closed one lets the key pass untouched.
1511
+ if (e.key === "Delete" && deletesOnKey(tab)) {
1468
1512
  e.preventDefault();
1469
1513
  if (!busy) requestRemove(tab.id);
1470
1514
  return;
1471
1515
  }
1472
- const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
1516
+ // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits
1517
+ // to the left, and ArrowLeft has to reach it.
1518
+ const step = horizontalStep(e.key, e.currentTarget);
1473
1519
  if (step === 0 && e.key !== "Home" && e.key !== "End") return;
1474
1520
  const strip = e.currentTarget.closest('[role="tablist"]');
1475
1521
  if (!strip) return;
@@ -1526,7 +1572,7 @@ export function Tabs<T extends string>({
1526
1572
  // Only the open tab: see `panelId`.
1527
1573
  "aria-controls": isActive ? panelId : undefined,
1528
1574
  id: isActive && panelId ? `${panelId}-tab` : undefined,
1529
- "aria-keyshortcuts": removable ? "Delete" : undefined,
1575
+ "aria-keyshortcuts": deletesOnKey(tab) ? "Delete" : undefined,
1530
1576
  onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
1531
1577
  className: cn(
1532
1578
  wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,