@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
@@ -10,8 +10,7 @@ import {
10
10
  Filter,
11
11
  X,
12
12
  } from "lucide-react";
13
- import { useSearchParams } from "react-router";
14
- import { Card } from "./ui";
13
+ import { Card, Spinner } from "./ui";
15
14
  import { cn } from "../lib/cn";
16
15
  import {
17
16
  defaultFilterState,
@@ -35,6 +34,7 @@ import { useAnnounce } from "../hooks/use-announce";
35
34
  import { resolveDataTableLabels, type DataTableLabels } from "./data-table-labels";
36
35
  import { useKitLabelOverrides, useKitLocale } from "../i18n/kit-labels";
37
36
  import { Tooltip } from "./tooltip";
37
+ import { dirOf, isRtl, type Direction } from "../lib/direction";
38
38
 
39
39
  // ---------- Types ----------
40
40
 
@@ -78,6 +78,9 @@ interface Resize {
78
78
  pointerId: number;
79
79
  startX: number;
80
80
  startWidth: number;
81
+ /** +1 when a drag to the right widens the column, -1 in a right-to-left table,
82
+ * where the handle sits on the column's LEFT edge and widening is a drag left. */
83
+ sign: 1 | -1;
81
84
  /** What React last rendered this column at, so a CANCELLED drag can put the header
82
85
  * back — React cannot, it never saw the inline width the drag wrote. */
83
86
  hadWidth: number | undefined;
@@ -97,7 +100,19 @@ export interface ServerPagination {
97
100
  pageSize: number;
98
101
  total: number;
99
102
  onPageChange: (page: number) => void;
103
+ /** Without it the page size is the server's to choose, and the pager shows no
104
+ * page-size select at all — one that changed nothing was a control that lied. */
100
105
  onPageSizeChange?: (pageSize: number) => void;
106
+ /**
107
+ * A page is being fetched. The table marks itself `aria-busy`, dims the rows it is
108
+ * still showing (they are the PREVIOUS page's, and about to go), and replaces the
109
+ * empty state with a loading row when there is nothing to show yet — "no results"
110
+ * while the first page is still in flight is a claim the table cannot make.
111
+ *
112
+ * The pager stays usable. Disabling it would drop focus from the very button the
113
+ * user just pressed (a disabled button cannot hold focus), and a second click
114
+ * during a fetch is a request the owner already has to handle for a slow network.
115
+ */
101
116
  isLoading?: boolean;
102
117
  }
103
118
 
@@ -191,8 +206,14 @@ export interface DataTableProps<T> {
191
206
  * Controlled filter/sort state. When the `on*Change` callback is provided
192
207
  * the table stops owning that piece of state: it renders `filters`/`sorts`
193
208
  * as given and reports user interactions through the callback (including
194
- * in serverPagination mode, where the UI is otherwise disabled). The owner
195
- * is responsible for any page reset on change.
209
+ * in serverPagination mode, where the UI is otherwise disabled).
210
+ *
211
+ * Page reset: on a client-side table, a change in the VALUE of `filters` or
212
+ * `sorts` sends the table back to page 1 — whoever made the change, the user
213
+ * through the header or the owner from outside (a saved view, a chip). Compared
214
+ * by value, so passing a fresh but equal object on every render does not reset
215
+ * anything. In `serverPagination` mode the owner's `page` is the page, and
216
+ * resetting it on a filter change stays the owner's job.
196
217
  */
197
218
  filters?: FilterState;
198
219
  onFiltersChange?: (next: FilterState) => void;
@@ -263,12 +284,19 @@ export interface DataTableProps<T> {
263
284
  * `null` (or empty sides) for rows that should not move — a locked row, one whose
264
285
  * mutation is in flight, one the user has expanded.
265
286
  *
287
+ * Prefer `start`/`end`: actions committed by dragging the row toward the reading
288
+ * START or END of the line. In a left-to-right layout `end` is a drag to the
289
+ * right, and in a right-to-left one it is a drag to the left — so a "drag forward
290
+ * to archive" row stays forward in Arabic or Hebrew without a second definition.
291
+ * `left`/`right` are the physical directions and keep meaning exactly that; each
292
+ * is used only where the logical side for that direction is not given.
293
+ *
266
294
  * Mobile only, and deliberately so: the desktop table already has room for an
267
295
  * actions column, and a drag gesture on a pointer device is a worse version of a
268
296
  * button. Wraps only the row body, so an expansion panel below stays put while the
269
297
  * row above it slides.
270
298
  */
271
- mobileSwipeActions?: (row: T) => { left?: SwipeAction[]; right?: SwipeAction[] } | null;
299
+ mobileSwipeActions?: (row: T) => MobileSwipeActions | null;
272
300
  /**
273
301
  * Renders as a full-width row above the data rows in the desktop table
274
302
  * (mobile has no equivalent list-row slot, so it's desktop-only). For a
@@ -283,6 +311,59 @@ export interface DataTableProps<T> {
283
311
  export type FilterState = Record<string, FilterValue>;
284
312
  export type { SortState };
285
313
 
314
+ /** What {@link DataTableProps.mobileSwipeActions} returns for one row. */
315
+ export interface MobileSwipeActions {
316
+ /** Committed by dragging toward the reading start (left in LTR, right in RTL). */
317
+ start?: SwipeAction[];
318
+ /** Committed by dragging toward the reading end (right in LTR, left in RTL). */
319
+ end?: SwipeAction[];
320
+ /** Committed by dragging physically LEFT, whatever the direction. */
321
+ left?: SwipeAction[];
322
+ /** Committed by dragging physically RIGHT, whatever the direction. */
323
+ right?: SwipeAction[];
324
+ }
325
+
326
+ /** The logical sides onto the physical drag directions {@link SwipeableRow} takes. */
327
+ function physicalSwipe(
328
+ actions: MobileSwipeActions,
329
+ dir: Direction,
330
+ ): { left?: SwipeAction[]; right?: SwipeAction[] } {
331
+ const rtl = dir === "rtl";
332
+ return {
333
+ left: (rtl ? actions.end : actions.start) ?? actions.left,
334
+ right: (rtl ? actions.start : actions.end) ?? actions.right,
335
+ };
336
+ }
337
+
338
+ // A bare `text-right` / `text-left` token, with or without variant prefixes
339
+ // (`md:text-right`). Deliberately NOT `text-right-…` or an arbitrary value.
340
+ const PHYSICAL_ALIGN = /(^|\s)((?:[\w-]+:)*)text-(left|right)(?=\s|$)/g;
341
+
342
+ /**
343
+ * A column's `className`/`headClassName` with physical text alignment rewritten to
344
+ * logical: `text-right` → `text-end`, `text-left` → `text-start`.
345
+ *
346
+ * Every numeric column written before 0.7.0 says `text-right`, and that is what it
347
+ * MEANT: the end of the line, where digits line up. Rendered as written, a
348
+ * right-to-left table puts those numbers on the far side from their own header's
349
+ * sort button. Rewriting keeps each existing call site correct in both directions
350
+ * with no change on its side; a caller who genuinely wants the physical side in RTL
351
+ * too can say `ltr:text-right rtl:text-right`, which this leaves alone.
352
+ */
353
+ function logicalAlign(className: string | undefined): string | undefined {
354
+ return className?.replace(
355
+ PHYSICAL_ALIGN,
356
+ (token: string, lead: string, variants: string, side: string) =>
357
+ // Already scoped to one direction: that is a caller choosing the physical side.
358
+ /(^|:)(ltr|rtl):/.test(variants)
359
+ ? token
360
+ : `${lead}${variants}text-${side === "right" ? "end" : "start"}`,
361
+ );
362
+ }
363
+
364
+ /** Whether a column is end-aligned — the header then puts its filter button first. */
365
+ const END_ALIGN = /(^|\s)text-end(?=\s|$)/;
366
+
286
367
  /** Drop the undefined entries of a {@link DataTableProps.rowAttributes} map, so a
287
368
  * caller can write `{ "data-x": cond ? "y" : undefined }` and get no attribute
288
369
  * at all rather than the string "undefined" in the DOM. */
@@ -422,6 +503,7 @@ export function DataTable<T>({
422
503
  leadingRow,
423
504
  }: DataTableProps<T>) {
424
505
  const isServer = !!serverPagination;
506
+ const isLoading = !!serverPagination?.isLoading;
425
507
  // prop > provider > English, through the table's own resolver rather than
426
508
  // `useKitLabels`: that resolver derives `columnsCount` from a translated `columns`,
427
509
  // and a plain merge would skip it. `presets` is a record and is merged key by key,
@@ -443,7 +525,6 @@ export function DataTable<T>({
443
525
  [labelOverrides, labelsProp],
444
526
  );
445
527
  const locale = useKitLocale(localeProp);
446
- const [, setSearchParams] = useSearchParams();
447
528
  // Sorting, filtering and paging all change WHICH rows are on screen without
448
529
  // moving focus — the header button the user pressed is still the header button
449
530
  // they are on — so there is no other channel to say it on. See use-announce.ts.
@@ -469,7 +550,6 @@ export function DataTable<T>({
469
550
  storageKey,
470
551
  storageKeyPrefix,
471
552
  urlSync,
472
- setSearchParams,
473
553
  defaultPageSize,
474
554
  sortsProp,
475
555
  filtersProp,
@@ -608,7 +688,10 @@ export function DataTable<T>({
608
688
  const setFilterValue = (key: string, value: FilterValue) => {
609
689
  if (onFiltersChange) {
610
690
  onFiltersChange({ ...filters, [key]: value });
611
- return; // page reset is the controlling owner's job
691
+ // No `setPage(0)` here: the page resets when the new value comes back in
692
+ // through the prop — see the controlled reset in useTableState. Resetting now
693
+ // would move to page 1 for a change the owner may yet refuse.
694
+ return;
612
695
  }
613
696
  setInternalFilters((s) => ({ ...s, [key]: value }));
614
697
  setPage(0);
@@ -767,6 +850,7 @@ export function DataTable<T>({
767
850
  pointerId: e.pointerId,
768
851
  startX: e.clientX,
769
852
  startWidth,
853
+ sign: isRtl(e.currentTarget) ? -1 : 1,
770
854
  hadWidth: widths[key],
771
855
  cells: columnCells(key),
772
856
  width: startWidth,
@@ -780,7 +864,7 @@ export function DataTable<T>({
780
864
  const moveResize = (e: React.PointerEvent<HTMLElement>) => {
781
865
  const drag = resizing.current;
782
866
  if (!drag || drag.pointerId !== e.pointerId) return;
783
- drag.width = Math.max(40, Math.round(drag.startWidth + (e.clientX - drag.startX)));
867
+ drag.width = Math.max(40, Math.round(drag.startWidth + drag.sign * (e.clientX - drag.startX)));
784
868
  drag.moved = true;
785
869
  applyLiveWidth(drag.cells, drag.width);
786
870
  };
@@ -860,12 +944,24 @@ export function DataTable<T>({
860
944
  // renderer is opaque to us; a link inside one is the caller's to declare by
861
945
  // marking the column it comes from.
862
946
  const mobileCardLinkable = mobileColumns.every((c) => !c.noRowLink);
947
+ // The card list's reading direction, for the one thing on it CSS cannot flip: which
948
+ // physical drag a logical `start`/`end` swipe action means. Read off the DOM through
949
+ // a ref callback (it runs at commit, like a layout effect) because `dir` is an
950
+ // attribute of some ancestor the table does not own; LTR until the first commit,
951
+ // which is also before any finger can have touched a row.
952
+ const [mobileDir, setMobileDir] = useState<Direction>("ltr");
953
+ const mobileRootRef = (el: HTMLElement | null) => {
954
+ if (!el) return;
955
+ const d = dirOf(el);
956
+ if (d !== mobileDir) setMobileDir(d);
957
+ };
863
958
 
864
959
  const { mobileSlice, canRevealMoreMobile, loadMoreRef } = useMobileReveal({
865
960
  rows: sorted,
866
961
  slice,
867
962
  isServer,
868
963
  isMdUp,
964
+ unpaged,
869
965
  defaultPageSize,
870
966
  });
871
967
  // The row whose selection was last toggled, so Shift+click can select the range up
@@ -874,6 +970,8 @@ export function DataTable<T>({
874
970
  // header was clicked addresses a different row afterwards — and the range then ran
875
971
  // from a row the user had never touched.
876
972
  const selectionAnchor = useRef<string | number | null>(null);
973
+ /** The checkbox click that drew a range, so its `onChange` can be told apart. */
974
+ const rangeClick = useRef<Event | null>(null);
877
975
  const canSelect = (row: T) => !!selection && (selection.isSelectable?.(row) ?? true);
878
976
  const selectRange = (toIndex: number) => {
879
977
  if (!selection || selectionAnchor.current === null) return false;
@@ -926,13 +1024,14 @@ export function DataTable<T>({
926
1024
  // uses for exactly this, and it points the way the row actually opens: right into
927
1025
  // a sheet, or down into an inline panel that flips it when expanded.
928
1026
  const opensDetail = interactive && !!expandedRow;
929
- const swipe = mobileSwipeActions?.(row);
1027
+ const swipeActions = mobileSwipeActions?.(row);
1028
+ const swipe = swipeActions ? physicalSwipe(swipeActions, mobileDir) : null;
930
1029
  // An expanded row never swipes: the editor below it owns the horizontal space,
931
1030
  // and dragging the header away from its own form reads as a glitch.
932
1031
  const swipeEnabled = !!swipe && !expanded;
933
1032
  const href = mobileCardLinkable ? rowHref?.(row) : undefined;
934
1033
  const cardClass = cn(
935
- "w-full px-4 py-3 text-left flex items-center gap-3",
1034
+ "w-full px-4 py-3 text-start flex items-center gap-3",
936
1035
  // Live #320's other half: the card acknowledges the touch before the sheet
937
1036
  // arrives. On a cold route the data can take a beat, and an unacknowledged tap
938
1037
  // reads as "did that register?" — which is most of what "abrupt" means here.
@@ -979,7 +1078,11 @@ export function DataTable<T>({
979
1078
  // `aria-hidden`: the row already announces itself as a button with
980
1079
  // `aria-expanded`, so the icon would only add a second, wordless stop.
981
1080
  (mobileExpandAsDialog ? (
982
- <ChevronRight aria-hidden className="size-4 shrink-0 text-[var(--text-placeholder)]" />
1081
+ // Points INTO the sheet, which is the reading-forward direction.
1082
+ <ChevronRight
1083
+ aria-hidden
1084
+ className="size-4 shrink-0 text-[var(--text-placeholder)] rtl:-scale-x-100"
1085
+ />
983
1086
  ) : (
984
1087
  <ChevronDown
985
1088
  aria-hidden
@@ -1074,7 +1177,7 @@ export function DataTable<T>({
1074
1177
  inside either one. */}
1075
1178
  <span {...regionProps} />
1076
1179
  {!isMdUp && (
1077
- <div>
1180
+ <div ref={mobileRootRef}>
1078
1181
  {/* Mobile filter access (feedback #299): the per-column filter popovers
1079
1182
  live in the desktop header, which the card list doesn't render — so
1080
1183
  expose the same filters through a bottom sheet here. */}
@@ -1092,7 +1195,14 @@ export function DataTable<T>({
1092
1195
  locale={locale}
1093
1196
  />
1094
1197
  )}
1095
- <ul className="divide-y divide-[var(--border)]">
1198
+ <ul
1199
+ className={cn(
1200
+ "divide-y divide-[var(--border)]",
1201
+ // Same busy treatment as the desktop body; see `isLoading` below.
1202
+ isLoading && mobileSlice.length > 0 && "opacity-60 transition-opacity",
1203
+ )}
1204
+ aria-busy={isLoading || undefined}
1205
+ >
1096
1206
  {mobileGroups
1097
1207
  ? mobileGroups.map((g) => (
1098
1208
  <Fragment key={`hb-group:${g.key}`}>
@@ -1105,7 +1215,7 @@ export function DataTable<T>({
1105
1215
  : mobileSlice.map(renderMobileRow)}
1106
1216
  {mobileSlice.length === 0 && (
1107
1217
  <li className="px-4 py-6 text-center text-sm text-[var(--text-muted)]">
1108
- {empty ?? "—"}
1218
+ {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? "—")}
1109
1219
  </li>
1110
1220
  )}
1111
1221
  {/* Endless-scroll sentinel: observed by IntersectionObserver to pull in
@@ -1128,7 +1238,7 @@ export function DataTable<T>({
1128
1238
  pageSize={paginationPageSize}
1129
1239
  total={paginationTotal}
1130
1240
  onPage={goToPage}
1131
- onPageSize={(n) => serverPagination!.onPageSizeChange?.(n)}
1241
+ onPageSize={serverPagination!.onPageSizeChange}
1132
1242
  labels={labels}
1133
1243
  locale={locale}
1134
1244
  />
@@ -1160,7 +1270,14 @@ export function DataTable<T>({
1160
1270
  >
1161
1271
  {/* A table with no name is "table" in a screen reader's list of tables, and
1162
1272
  an app renders several. `labels.table` is how a call site says which. */}
1163
- <table className="w-full text-sm" aria-label={labels.table}>
1273
+ {/* `aria-busy` while a server page is in flight: a reader holds off announcing
1274
+ the rows that are about to be replaced, and the dimmed body says the same
1275
+ to the eye. The pager stays live — see `ServerPagination.isLoading`. */}
1276
+ <table
1277
+ className="w-full text-sm"
1278
+ aria-label={labels.table}
1279
+ aria-busy={isLoading || undefined}
1280
+ >
1164
1281
  {/* Sticky header. position:sticky pins to the nearest scroll-container
1165
1282
  ancestor — so the header can only stick to THIS wrapper, never the
1166
1283
  page. For that to actually hold the header in place, the wrapper
@@ -1171,7 +1288,7 @@ export function DataTable<T>({
1171
1288
  <thead> isn't reliable across browsers, so we put it on each <th>
1172
1289
  (see headClassName below). */}
1173
1290
  <thead className="bg-[var(--bg-surface-2)] text-[var(--text-secondary)]">
1174
- <tr className="text-left">
1291
+ <tr className="text-start">
1175
1292
  {selection && (
1176
1293
  <th
1177
1294
  scope="col"
@@ -1199,7 +1316,9 @@ export function DataTable<T>({
1199
1316
  const active = sortIdx !== -1;
1200
1317
  const Icon = active ? (sorts[sortIdx].dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
1201
1318
  const filterActive = filterState && isFilterActive(filterState);
1202
- const isRightAligned = col.headClassName?.includes("text-right");
1319
+ // Logical, and a legacy `text-right` still counts — see `logicalAlign`.
1320
+ const headClass = logicalAlign(col.headClassName);
1321
+ const isEndAligned = !!headClass && END_ALIGN.test(headClass);
1203
1322
  const width = widths[col.key];
1204
1323
  return (
1205
1324
  <th
@@ -1236,13 +1355,13 @@ export function DataTable<T>({
1236
1355
  // Each <th> carries its own bg so the row doesn't render
1237
1356
  // transparent over the data rows underneath.
1238
1357
  "sticky top-0 z-10 bg-[var(--bg-surface-2)] backdrop-blur-sm",
1239
- col.headClassName,
1358
+ headClass,
1240
1359
  )}
1241
1360
  >
1242
1361
  <div
1243
1362
  className={cn(
1244
1363
  "flex items-center gap-1",
1245
- isRightAligned && "flex-row-reverse",
1364
+ isEndAligned && "flex-row-reverse",
1246
1365
  )}
1247
1366
  >
1248
1367
  <Tooltip label={sortable ? labels.sortHint : undefined} portal>
@@ -1251,7 +1370,7 @@ export function DataTable<T>({
1251
1370
  onClick={(e) => sortable && toggleSort(col.key, e.shiftKey)}
1252
1371
  disabled={!sortable}
1253
1372
  className={cn(
1254
- "flex items-center gap-1 text-left",
1373
+ "flex items-center gap-1 text-start",
1255
1374
  sortable && "hover:text-[var(--text-primary)]",
1256
1375
  )}
1257
1376
  >
@@ -1321,14 +1440,18 @@ export function DataTable<T>({
1321
1440
  }}
1322
1441
  // `touch-none`: without it the browser claims a finger drag for
1323
1442
  // scrolling before the first `pointermove` ever arrives.
1324
- className="absolute right-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 cursor-col-resize select-none touch-none hover:bg-[var(--brand-bg-hover)]"
1443
+ // `end-0` and a translate that flips with it: the handle sits on the column's
1444
+ // trailing edge, which is its left one in a right-to-left table.
1445
+ className="absolute end-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 rtl:translate-x-1/2 cursor-col-resize select-none touch-none hover:bg-[var(--brand-bg-hover)]"
1325
1446
  />
1326
1447
  </th>
1327
1448
  );
1328
1449
  })}
1329
1450
  </tr>
1330
1451
  </thead>
1331
- <tbody>
1452
+ <tbody
1453
+ className={cn(isLoading && slice.length > 0 && "opacity-60 transition-opacity")}
1454
+ >
1332
1455
  {leadingRow && (
1333
1456
  <tr className="border-t border-[var(--border)]">
1334
1457
  <td colSpan={totalColSpan} className="p-0">
@@ -1395,12 +1518,27 @@ export function DataTable<T>({
1395
1518
  className="size-4 accent-indigo-600"
1396
1519
  checked={selection.isSelected(row)}
1397
1520
  // Shift+click selects the range from the last toggled
1398
- // row; preventDefault stops the native toggle (and the
1399
- // onChange that would double-handle it).
1521
+ // row. `preventDefault` puts the box back, but it does NOT
1522
+ // stop React's `onChange`: React derives a checkbox's
1523
+ // change from the `click` itself, after the browser has
1524
+ // already flipped `checked` and before the cancellation
1525
+ // reverts it — so the same click also reported itself as
1526
+ // a single toggle, and the owner got `onToggleMany` and
1527
+ // then `onToggle` for the clicked row. The click is
1528
+ // remembered by identity and its change is dropped; a
1529
+ // boolean flag would outlive a click whose change never
1530
+ // came and swallow the user's next real one.
1400
1531
  onClick={(e) => {
1401
- if (e.shiftKey && selectRange(rowIndex)) e.preventDefault();
1532
+ if (e.shiftKey && selectRange(rowIndex)) {
1533
+ e.preventDefault();
1534
+ rangeClick.current = e.nativeEvent;
1535
+ }
1402
1536
  }}
1403
1537
  onChange={(e) => {
1538
+ if (rangeClick.current && e.nativeEvent === rangeClick.current) {
1539
+ rangeClick.current = null;
1540
+ return;
1541
+ }
1404
1542
  selectionAnchor.current = rowKey(row);
1405
1543
  selection.onToggle(row, e.target.checked);
1406
1544
  }}
@@ -1416,7 +1554,11 @@ export function DataTable<T>({
1416
1554
  key={col.key}
1417
1555
  data-col={col.key}
1418
1556
  style={width ? { width, minWidth: width, maxWidth: width } : undefined}
1419
- className={cn("px-3 py-2 align-top", width && "overflow-hidden text-ellipsis", col.className)}
1557
+ className={cn(
1558
+ "px-3 py-2 align-top",
1559
+ width && "overflow-hidden text-ellipsis",
1560
+ logicalAlign(col.className),
1561
+ )}
1420
1562
  >
1421
1563
  {col === linkColumn && href ? (
1422
1564
  // No `onActivate`: the click is left to bubble to the
@@ -1459,7 +1601,9 @@ export function DataTable<T>({
1459
1601
  colSpan={totalColSpan}
1460
1602
  className="px-3 py-4 text-center text-[var(--text-muted)]"
1461
1603
  >
1462
- {empty ?? "—"}
1604
+ {/* Nothing is not the same as not-yet: "no results" while the first
1605
+ page is still in flight is a claim the table cannot make. */}
1606
+ {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? "—")}
1463
1607
  </td>
1464
1608
  </tr>
1465
1609
  )}
@@ -1475,7 +1619,7 @@ export function DataTable<T>({
1475
1619
  onPage={goToPage}
1476
1620
  onPageSize={
1477
1621
  isServer
1478
- ? (n) => serverPagination!.onPageSizeChange?.(n)
1622
+ ? serverPagination!.onPageSizeChange
1479
1623
  : (n) => {
1480
1624
  setPageSize(n);
1481
1625
  setPage(0);
@@ -1495,9 +1639,9 @@ export function DataTable<T>({
1495
1639
  tabIndex={showSettings ? -1 : 0}
1496
1640
  className={cn(
1497
1641
  "group flex shrink-0 items-start justify-center overflow-hidden pt-3 transition-[width] duration-200 ease-out",
1498
- "border-l border-[var(--border)]",
1642
+ "border-s border-[var(--border)]",
1499
1643
  "hover:bg-[var(--bg-hover)]",
1500
- showSettings ? "w-0 border-l-0" : "w-8 cursor-pointer",
1644
+ showSettings ? "w-0 border-s-0" : "w-8 cursor-pointer",
1501
1645
  )}
1502
1646
  >
1503
1647
  <span
@@ -1514,7 +1658,7 @@ export function DataTable<T>({
1514
1658
  showSettings ? "w-56" : "w-0",
1515
1659
  )}
1516
1660
  >
1517
- <div className="flex w-56 flex-col border-l border-[var(--border)] p-2">
1661
+ <div className="flex w-56 flex-col border-s border-[var(--border)] p-2">
1518
1662
  <div className="mb-2 flex items-center justify-between gap-1">
1519
1663
  <div className="text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
1520
1664
  {columnsCountLabel}
@@ -1572,6 +1716,16 @@ export function DataTable<T>({
1572
1716
  );
1573
1717
  }
1574
1718
 
1719
+ /** The loading row's content: a spinner beside the word, the word for everyone. */
1720
+ function LoadingText({ label }: { label: string }) {
1721
+ return (
1722
+ <span className="inline-flex items-center gap-2">
1723
+ <Spinner label={null} className="size-4" />
1724
+ {label}
1725
+ </span>
1726
+ );
1727
+ }
1728
+
1575
1729
  // ---------- Mobile filter sheet ----------
1576
1730
 
1577
1731
  /**
@@ -1606,6 +1760,10 @@ function MobileFilters<T>({
1606
1760
  locale?: string;
1607
1761
  }) {
1608
1762
  const [open, setOpen] = useState(false);
1763
+ // The sheet is portalled to <body>, out from under whatever `dir` the table sits
1764
+ // in, so it carries the trigger's direction with it — read at the moment of opening,
1765
+ // from the element the user actually pressed.
1766
+ const [dir, setDir] = useState<Direction>("ltr");
1609
1767
  const [expanded, setExpanded] = useState<string | null>(null);
1610
1768
  const backdropClose = useBackdropClose(() => setOpen(false));
1611
1769
  useBodyScrollLock(open);
@@ -1633,7 +1791,10 @@ function MobileFilters<T>({
1633
1791
  <div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-2">
1634
1792
  <button
1635
1793
  type="button"
1636
- onClick={() => setOpen(true)}
1794
+ onClick={(e) => {
1795
+ setDir(dirOf(e.currentTarget));
1796
+ setOpen(true);
1797
+ }}
1637
1798
  className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
1638
1799
  >
1639
1800
  <Filter className="size-4" />
@@ -1659,6 +1820,7 @@ function MobileFilters<T>({
1659
1820
  className="fixed inset-0 z-50 flex items-end justify-center bg-black/40"
1660
1821
  role="dialog"
1661
1822
  aria-modal="true"
1823
+ dir={dir}
1662
1824
  {...backdropClose}
1663
1825
  >
1664
1826
  <div className="flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-2xl bg-[var(--bg-surface)] shadow-xl">
@@ -1668,7 +1830,7 @@ function MobileFilters<T>({
1668
1830
  type="button"
1669
1831
  onClick={() => setOpen(false)}
1670
1832
  aria-label={labels.close}
1671
- className="-mr-1 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
1833
+ className="-me-1 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
1672
1834
  >
1673
1835
  <X className="size-5" />
1674
1836
  </button>
@@ -1684,7 +1846,7 @@ function MobileFilters<T>({
1684
1846
  type="button"
1685
1847
  onClick={() => setExpanded(isItemOpen ? null : col.key)}
1686
1848
  aria-expanded={isItemOpen}
1687
- className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
1849
+ className="flex w-full items-center justify-between gap-2 px-4 py-3 text-start"
1688
1850
  >
1689
1851
  <span className="flex items-center gap-2 text-sm font-medium text-[var(--text-secondary)]">
1690
1852
  {col.header}
@@ -1,7 +1,8 @@
1
- import { useEffect, useId, useMemo, useRef } from "react";
1
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, ComponentType, ReactNode, RefObject } from "react";
3
3
  import { Calendar, CalendarClock, ChevronLeft, ChevronRight, X } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
+ import { dirOf, type Direction } from "../lib/direction";
5
6
  import { addDaysIso, parseIsoDate } from "../lib/dates";
6
7
  import {
7
8
  DEFAULT_DATE_PICKER_LABELS,
@@ -157,7 +158,7 @@ function DateFieldTrigger({
157
158
  aria-invalid={invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined}
158
159
  className={cn(
159
160
  FIELD_TRIGGER,
160
- "pr-9",
161
+ "pe-9",
161
162
  padded && FIELD_FLOATING_PAD,
162
163
  disabled && "cursor-not-allowed opacity-50",
163
164
  invalid && FIELD_INVALID,
@@ -218,6 +219,12 @@ function DateField({
218
219
  }) {
219
220
  const showClear = Boolean(clearable && hasValue && !disabled);
220
221
  const [aria, wrapperRest] = splitTriggerAria(rest);
222
+ // The panel is portalled to <body> and leaves the subtree whose `dir` it inherited,
223
+ // so a calendar in an RTL form opened LTR — the grid's columns, its arrow keys and
224
+ // its chevrons all the wrong way round. Read at the moment of opening (the only way
225
+ // the popover opens is through `toggle`) and put back on the panel.
226
+ const rootRef = useRef<HTMLDivElement>(null);
227
+ const [dir, setDir] = useState<Direction>("ltr");
221
228
  const id = useId();
222
229
  const labelId = `${id}-label`;
223
230
  const valueId = `${id}-value`;
@@ -229,7 +236,7 @@ function DateField({
229
236
  return (
230
237
  // The caller's attributes land here, on the field's own box — the trigger inside is
231
238
  // named by `aria-labelledby` and must keep the id pair it is given.
232
- <div {...wrapperRest} className={cn("relative", className)}>
239
+ <div {...wrapperRest} ref={rootRef} className={cn("relative", className)}>
233
240
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
234
241
  {/* The visible FieldLabel is a plain span, not a `<label htmlFor>`, so it names
235
242
  nothing on its own — this hidden twin is what the trigger is named by.
@@ -249,10 +256,14 @@ function DateField({
249
256
  // Named for what it is. Unnamed, it fell back to `popover.panel`, and a date
250
257
  // field announced its calendar as "Popover" — in English, in every language.
251
258
  labels={{ panel: panelLabel }}
259
+ dir={dir}
252
260
  trigger={({ open, toggle, ref }) => (
253
261
  <DateFieldTrigger
254
262
  open={open}
255
- toggle={toggle}
263
+ toggle={() => {
264
+ setDir(dirOf(rootRef.current));
265
+ toggle();
266
+ }}
256
267
  triggerRef={ref}
257
268
  triggerText={triggerText}
258
269
  hasValue={hasValue}
@@ -292,14 +303,14 @@ function DateField({
292
303
  type="button"
293
304
  aria-label={clearLabel}
294
305
  onClick={onClear}
295
- className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
306
+ className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
296
307
  >
297
308
  <X className="size-4" />
298
309
  </button>
299
310
  ) : (
300
311
  <Calendar
301
312
  aria-hidden
302
- className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]"
313
+ className="pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]"
303
314
  />
304
315
  )}
305
316
  </div>
@@ -312,12 +323,16 @@ function StepButton({
312
323
  label,
313
324
  disabled,
314
325
  onClick,
326
+ mirror,
315
327
  className,
316
328
  }: {
317
329
  icon: ComponentType<{ className?: string }>;
318
330
  label: string;
319
331
  disabled: boolean;
320
332
  onClick: () => void;
333
+ /** Flip the icon in RTL — for the ‹ › day steps, whose "previous" points to the
334
+ * reading start. Not for the today icon, which has no direction. */
335
+ mirror?: boolean;
321
336
  /** The button's corners in the joined group (see DatePicker). */
322
337
  className?: string;
323
338
  }) {
@@ -343,7 +358,7 @@ function StepButton({
343
358
  className,
344
359
  )}
345
360
  >
346
- <Icon className="size-4" />
361
+ <Icon className={cn("size-4", mirror && "rtl:-scale-x-100")} />
347
362
  </button>
348
363
  </Tooltip>
349
364
  );
@@ -506,6 +521,7 @@ export function DatePicker({
506
521
  {step && (
507
522
  <StepButton
508
523
  icon={ChevronLeft}
524
+ mirror
509
525
  label={text.previousDay}
510
526
  disabled={blocked(-1)}
511
527
  onClick={() => onChange(target(-1))}
@@ -516,6 +532,7 @@ export function DatePicker({
516
532
  {step && (
517
533
  <StepButton
518
534
  icon={ChevronRight}
535
+ mirror
519
536
  label={text.nextDay}
520
537
  disabled={blocked(1)}
521
538
  onClick={() => onChange(target(1))}