@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
@@ -2,6 +2,7 @@ import { useId, useRef } from "react";
2
2
  import type { KeyboardEvent, ReactNode } from "react";
3
3
  import { Ban, Check } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
+ import { horizontalStep } from "../lib/direction";
5
6
  import { Tooltip } from "./tooltip";
6
7
 
7
8
  /**
@@ -97,14 +98,11 @@ export function TileRadioGroup<T extends string>({
97
98
  // Left and right are VISUAL directions: in a right-to-left page the row runs the
98
99
  // other way. Up and down follow reading order either way — the APG's radio group
99
100
  // treats them as previous/next, and the tiles wrap, so there is no column to keep.
100
- const rtl = e.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
101
+ const horizontal = horizontalStep(e.key, e.currentTarget);
101
102
  let step: number | "first" | "last";
102
- switch (e.key) {
103
- case "ArrowRight":
104
- step = rtl ? -1 : 1;
105
- break;
106
- case "ArrowLeft":
107
- step = rtl ? 1 : -1;
103
+ switch (horizontal ? "horizontal" : e.key) {
104
+ case "horizontal":
105
+ step = horizontal;
108
106
  break;
109
107
  case "ArrowDown":
110
108
  step = 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
  );
@@ -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,7 +1,8 @@
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
7
  import { Tooltip } from "./tooltip";
7
8
  import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
@@ -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.
@@ -765,7 +773,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
765
773
  disabled={rest.disabled}
766
774
  aria-label={revealed ? passwordText.hide : passwordText.show}
767
775
  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"
776
+ 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
777
  >
770
778
  {revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
771
779
  </button>
@@ -804,7 +812,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
804
812
  {...rest}
805
813
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
806
814
  aria-describedby={describedBy}
807
- className={cn(FIELD_BASE, "pr-9", inputClassName, isInvalid && FIELD_INVALID)}
815
+ className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
808
816
  />
809
817
  {revealToggle}
810
818
  </div>
@@ -831,7 +839,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
831
839
  asDisplay
832
840
  ? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
833
841
  : FLOATING_INPUT_CLASS,
834
- isPassword && "pr-9",
842
+ isPassword && "pe-9",
835
843
  inputClassName,
836
844
  isInvalid && FIELD_INVALID,
837
845
  )}
@@ -858,7 +866,9 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
858
866
  * the tall box to float in, so a labelled Select ignores `"sm"`.
859
867
  *
860
868
  * 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.
869
+ * straight through, so the one HTML meaning of `size` keeps working. Above 1 (as
870
+ * with `multiple`) the browser draws a list box, and the Select drops its chevron
871
+ * and the end padding reserved for it.
862
872
  */
863
873
  size?: "sm" | "md" | number;
864
874
  /** Classes for the `<select>` itself. `className` styles the WRAPPER — the box
@@ -870,7 +880,7 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
870
880
  // The compact select: the field's colours, a toolbar button's height. `py-0` and a
871
881
  // fixed height rather than a smaller padding, so the box is 28px whatever line
872
882
  // height the caller's type brings with it.
873
- const SELECT_SM = "h-7 py-0 pl-2 pr-7 text-xs";
883
+ const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
874
884
 
875
885
  export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
876
886
  { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest },
@@ -879,6 +889,12 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
879
889
  const generated = useId();
880
890
  // Only a number reaches the DOM; the two words are this component's own.
881
891
  const nativeSize = typeof size === "number" ? size : undefined;
892
+ // A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
893
+ // the rows in the field, no menu to drop. The dropdown dress did not fit it: the
894
+ // chevron was centred on a box several rows tall, sat on top of the rows' text
895
+ // at the end edge, and `pe-9` clipped every option nine units early. A list box
896
+ // gets the plain field instead — its own native scroll, no chevron, even padding.
897
+ const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);
882
898
  const small = size === "sm" && label === undefined;
883
899
  const fieldId = id ?? generated;
884
900
  const { isInvalid, describedBy, errorEl } = useFieldError(
@@ -888,13 +904,16 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
888
904
  rest["aria-invalid"],
889
905
  );
890
906
  // 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
907
+ // in from the end border and matches both themes — feedback #223. A DISABLED
892
908
  // select has no menu to drop, so it drops the chevron too: the arrow is the one
893
909
  // thing on the control that promises a choice (Keksdose dev#474, where the
894
910
  // 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
- );
911
+ const chevron =
912
+ rest.disabled || listBox ? null : (
913
+ <FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
914
+ );
915
+ // `pe-9` is the room the chevron takes; a list box has none to make room for.
916
+ const dress = listBox ? "overflow-y-auto" : "appearance-none pe-9";
898
917
  if (label === undefined) {
899
918
  return (
900
919
  <FieldGroup errorEl={errorEl}>
@@ -919,7 +938,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
919
938
  aria-describedby={describedBy}
920
939
  className={cn(
921
940
  FIELD_BASE,
922
- "appearance-none pr-9",
941
+ dress,
923
942
  small && SELECT_SM,
924
943
  selectClassName,
925
944
  isInvalid && FIELD_INVALID,
@@ -944,8 +963,11 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
944
963
  aria-describedby={describedBy}
945
964
  className={cn(
946
965
  FIELD_BASE,
947
- FIELD_FLOATING_PAD,
948
- "peer appearance-none pr-9",
966
+ // The floated label takes the top strip of a list box too, so its first
967
+ // row starts under the label rather than behind it.
968
+ listBox ? "pt-5 pb-1" : FIELD_FLOATING_PAD,
969
+ "peer",
970
+ dress,
949
971
  selectClassName,
950
972
  isInvalid && FIELD_INVALID,
951
973
  )}
@@ -1320,7 +1342,8 @@ export interface TabsProps<T extends string>
1320
1342
  */
1321
1343
  panelId?: string;
1322
1344
  /**
1323
- * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab.
1345
+ * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab
1346
+ * (only on the open one with `removeOn="active"`).
1324
1347
  *
1325
1348
  * The × is on the open tab only — a row of tabs each carrying one is a row of
1326
1349
  * things to hit by accident, and the tab you can remove should be the one you
@@ -1328,7 +1351,7 @@ export interface TabsProps<T extends string>
1328
1351
  * tab never widens it and never pushes the rest of the strip along (lenkbank
1329
1352
  * feedback #72). It is named "Remove <tab>" and is not a tab stop of its own: the
1330
1353
  * strip stays one stop, and the keyboard path is Delete (announced through
1331
- * `aria-keyshortcuts`), which removes whichever tab has focus.
1354
+ * `aria-keyshortcuts`), which removes whichever tab has focus (see `removeOn`).
1332
1355
  *
1333
1356
  * The strip only ASKS: it calls this, and the tab goes when the caller drops it
1334
1357
  * from `tabs` — after a confirmation, a request, whatever it needs. When it does,
@@ -1336,6 +1359,20 @@ export interface TabsProps<T extends string>
1336
1359
  * removed one was open, else to its neighbour, instead of falling to the page.
1337
1360
  */
1338
1361
  onRemove?: (id: T) => void;
1362
+ /**
1363
+ * Which tabs the Delete key removes.
1364
+ *
1365
+ * - `"every"` (default): any focused removable tab — the ARIA pattern's reading.
1366
+ * - `"active"`: only the OPEN tab, the same one that wears the ×. For a strip whose
1367
+ * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement
1368
+ * sheet at once), so arrowing across the strip and pressing Delete cannot take
1369
+ * out a sheet the user is not looking at. Delete on a closed tab does nothing,
1370
+ * and only the open tab announces the shortcut (`aria-keyshortcuts`).
1371
+ *
1372
+ * Either way the × is drawn on the open tab only and its room stays reserved on
1373
+ * every removable tab, so the strip never relayouts when the selection moves.
1374
+ */
1375
+ removeOn?: "every" | "active";
1339
1376
  /** Renders an "add" button after the last tab, outside the tablist — it is an
1340
1377
  * action, not a tab, and a tablist may own only tabs. */
1341
1378
  onAdd?: () => void;
@@ -1401,6 +1438,7 @@ export function Tabs<T extends string>({
1401
1438
  label,
1402
1439
  panelId,
1403
1440
  onRemove,
1441
+ removeOn = "every",
1404
1442
  onAdd,
1405
1443
  addLabel,
1406
1444
  labels,
@@ -1411,6 +1449,9 @@ export function Tabs<T extends string>({
1411
1449
  const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
1412
1450
  const stripRef = useRef<HTMLDivElement>(null);
1413
1451
  const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
1452
+ // Whether Delete reaches this tab: every removable tab, or only the open one.
1453
+ const deletesOnKey = (tab: TabItem<T>) =>
1454
+ isRemovable(tab) && (removeOn === "every" || tab.id === active);
1414
1455
  const nameOf = (tab: TabItem<T>) =>
1415
1456
  tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
1416
1457
 
@@ -1463,13 +1504,16 @@ export function Tabs<T extends string>({
1463
1504
  const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {
1464
1505
  // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable
1465
1506
  // 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)) {
1507
+ // rather than letting it fall through to the page. With `removeOn="active"` only
1508
+ // the open tab answers; a closed one lets the key pass untouched.
1509
+ if (e.key === "Delete" && deletesOnKey(tab)) {
1468
1510
  e.preventDefault();
1469
1511
  if (!busy) requestRemove(tab.id);
1470
1512
  return;
1471
1513
  }
1472
- const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
1514
+ // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits
1515
+ // to the left, and ArrowLeft has to reach it.
1516
+ const step = horizontalStep(e.key, e.currentTarget);
1473
1517
  if (step === 0 && e.key !== "Home" && e.key !== "End") return;
1474
1518
  const strip = e.currentTarget.closest('[role="tablist"]');
1475
1519
  if (!strip) return;
@@ -1526,7 +1570,7 @@ export function Tabs<T extends string>({
1526
1570
  // Only the open tab: see `panelId`.
1527
1571
  "aria-controls": isActive ? panelId : undefined,
1528
1572
  id: isActive && panelId ? `${panelId}-tab` : undefined,
1529
- "aria-keyshortcuts": removable ? "Delete" : undefined,
1573
+ "aria-keyshortcuts": deletesOnKey(tab) ? "Delete" : undefined,
1530
1574
  onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
1531
1575
  className: cn(
1532
1576
  wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
@@ -0,0 +1,20 @@
1
+ import { useLayoutEffect, useState } from "react";
2
+ import type { RefObject } from "react";
3
+ import { dirOf, type Direction } from "../lib/direction";
4
+
5
+ /**
6
+ * The reading direction at a portalled panel's anchor — internal, not exported from
7
+ * the barrel. The field pickers' panels are portalled to `<body>`, out of the subtree
8
+ * their `dir` came from, so they carry it across on their own root.
9
+ *
10
+ * Read in a layout effect while `open`, rather than off the ref during render: the
11
+ * anchored rect these panels also wait for is measured the same way, so the panel's
12
+ * first painted frame already has both, and a render never reads a ref.
13
+ */
14
+ export function useAnchorDir(ref: RefObject<Element | null>, open: boolean): Direction {
15
+ const [dir, setDir] = useState<Direction>("ltr");
16
+ useLayoutEffect(() => {
17
+ if (open) setDir(dirOf(ref.current));
18
+ }, [open, ref]);
19
+ return dir;
20
+ }
@@ -11,6 +11,11 @@ import { useEffect, useRef, useState } from "react";
11
11
  * is why nothing resets it: narrowing a filter cannot leave the limit pointing past the
12
12
  * end, and widening one does not have to re-reveal what the user already scrolled past.
13
13
  *
14
+ * `paginated={false}` opts out entirely: a table that is short by construction (an
15
+ * invoice's line items) renders every row on a phone too. It used to be chunked here
16
+ * regardless, so a twelve-line invoice showed its first ten lines and a "Loading…"
17
+ * sentinel for rows that were already in memory.
18
+ *
14
19
  * Lifted out of `DataTable` with the rest of its state; the sentinel ref belongs to the
15
20
  * caller's `<li>`.
16
21
  */
@@ -19,6 +24,7 @@ export function useMobileReveal<T>({
19
24
  slice,
20
25
  isServer,
21
26
  isMdUp,
27
+ unpaged = false,
22
28
  defaultPageSize,
23
29
  }: {
24
30
  /** The whole sorted list — what there is to reveal. */
@@ -27,11 +33,13 @@ export function useMobileReveal<T>({
27
33
  slice: T[];
28
34
  isServer: boolean;
29
35
  isMdUp: boolean;
36
+ /** `paginated={false}` on a client-side table: show every row, reveal nothing. */
37
+ unpaged?: boolean;
30
38
  defaultPageSize: number;
31
39
  }) {
32
40
  const [mobileLimit, setMobileLimit] = useState(defaultPageSize);
33
- const mobileSlice = isServer ? slice : rows.slice(0, mobileLimit);
34
- const canRevealMoreMobile = !isServer && !isMdUp && mobileLimit < rows.length;
41
+ const mobileSlice = isServer ? slice : unpaged ? rows : rows.slice(0, mobileLimit);
42
+ const canRevealMoreMobile = !isServer && !unpaged && !isMdUp && mobileLimit < rows.length;
35
43
  const loadMoreRef = useRef<HTMLLIElement | null>(null);
36
44
 
37
45
  useEffect(() => {