@eifi1/ui-kit 0.6.1 → 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 (263) 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 +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 +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/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 +36 -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 +9 -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 +3 -0
  85. package/dist/components/number-input.d.ts +3 -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 +3 -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/swatch-picker.d.ts +10 -1
  101. package/dist/components/swatch-picker.js +2 -0
  102. package/dist/components/swatch-picker.js.map +1 -1
  103. package/dist/components/swipeable-row.d.ts +11 -2
  104. package/dist/components/swipeable-row.js +5 -3
  105. package/dist/components/swipeable-row.js.map +1 -1
  106. package/dist/components/tile-radio.js +5 -7
  107. package/dist/components/tile-radio.js.map +1 -1
  108. package/dist/components/toggle-group.d.ts +13 -3
  109. package/dist/components/toggle-group.js +21 -1
  110. package/dist/components/toggle-group.js.map +1 -1
  111. package/dist/components/toggle-legend.js +1 -2
  112. package/dist/components/toggle-legend.js.map +1 -1
  113. package/dist/components/treemap.d.ts +6 -1
  114. package/dist/components/treemap.js +16 -6
  115. package/dist/components/treemap.js.map +1 -1
  116. package/dist/components/ui.d.ts +31 -7
  117. package/dist/components/ui.js +26 -18
  118. package/dist/components/ui.js.map +1 -1
  119. package/dist/components/use-anchor-dir.d.ts +15 -0
  120. package/dist/components/use-anchor-dir.js +14 -0
  121. package/dist/components/use-anchor-dir.js.map +1 -0
  122. package/dist/components/use-mobile-reveal.d.ts +8 -1
  123. package/dist/components/use-mobile-reveal.js +3 -2
  124. package/dist/components/use-mobile-reveal.js.map +1 -1
  125. package/dist/components/use-table-state.d.ts +4 -4
  126. package/dist/components/use-table-state.js +12 -5
  127. package/dist/components/use-table-state.js.map +1 -1
  128. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  129. package/dist/data-table.d.ts +1 -1
  130. package/dist/data-table.js.map +1 -1
  131. package/dist/feedback/feedback-attachment.d.ts +22 -3
  132. package/dist/feedback/feedback-attachment.js +15 -7
  133. package/dist/feedback/feedback-attachment.js.map +1 -1
  134. package/dist/feedback/feedback-dialog.d.ts +3 -2
  135. package/dist/feedback/feedback-dialog.js.map +1 -1
  136. package/dist/feedback.d.ts +1 -1
  137. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  138. package/dist/hooks/use-windowed-rows.js +31 -0
  139. package/dist/hooks/use-windowed-rows.js.map +1 -0
  140. package/dist/i18n/defaults.d.ts +3 -0
  141. package/dist/i18n/defaults.js +5 -1
  142. package/dist/i18n/defaults.js.map +1 -1
  143. package/dist/i18n/kit-labels.d.ts +5 -0
  144. package/dist/i18n/kit-labels.js.map +1 -1
  145. package/dist/i18n/locales/de-CH.d.ts +43 -0
  146. package/dist/i18n/locales/de-CH.js +21 -0
  147. package/dist/i18n/locales/de-CH.js.map +1 -0
  148. package/dist/i18n/locales/de.d.ts +43 -0
  149. package/dist/i18n/locales/de.js +344 -0
  150. package/dist/i18n/locales/de.js.map +1 -0
  151. package/dist/i18n/locales/es.d.ts +43 -0
  152. package/dist/i18n/locales/es.js +339 -0
  153. package/dist/i18n/locales/es.js.map +1 -0
  154. package/dist/i18n/locales/fr.d.ts +43 -0
  155. package/dist/i18n/locales/fr.js +344 -0
  156. package/dist/i18n/locales/fr.js.map +1 -0
  157. package/dist/i18n/locales/hu.d.ts +43 -0
  158. package/dist/i18n/locales/hu.js +343 -0
  159. package/dist/i18n/locales/hu.js.map +1 -0
  160. package/dist/i18n/locales/it.d.ts +43 -0
  161. package/dist/i18n/locales/it.js +342 -0
  162. package/dist/i18n/locales/it.js.map +1 -0
  163. package/dist/i18n/locales/zh.d.ts +43 -0
  164. package/dist/i18n/locales/zh.js +338 -0
  165. package/dist/i18n/locales/zh.js.map +1 -0
  166. package/dist/index.d.ts +6 -3
  167. package/dist/index.js +4 -1
  168. package/dist/index.js.map +1 -1
  169. package/dist/lib/direction.d.ts +24 -0
  170. package/dist/lib/direction.js +19 -0
  171. package/dist/lib/direction.js.map +1 -0
  172. package/dist/rhf/form.js.map +1 -1
  173. package/dist/search/command-palette.d.ts +5 -0
  174. package/dist/search/command-palette.js +80 -50
  175. package/dist/search/command-palette.js.map +1 -1
  176. package/dist/shell/app-shell.d.ts +13 -1
  177. package/dist/shell/app-shell.js +133 -84
  178. package/dist/shell/app-shell.js.map +1 -1
  179. package/dist/shell/topbar-action-menu.js +1 -1
  180. package/dist/shell/topbar-action-menu.js.map +1 -1
  181. package/dist/shell/topbar-controls.d.ts +7 -3
  182. package/dist/shell/topbar-controls.js +38 -31
  183. package/dist/shell/topbar-controls.js.map +1 -1
  184. package/dist/theme/chart-palette.d.ts +7 -1
  185. package/dist/theme/chart-palette.js +2 -1
  186. package/dist/theme/chart-palette.js.map +1 -1
  187. package/dist/tour/tour.d.ts +11 -5
  188. package/dist/tour/tour.js +124 -58
  189. package/dist/tour/tour.js.map +1 -1
  190. package/dist/wizard/use-wizard.js +30 -13
  191. package/dist/wizard/use-wizard.js.map +1 -1
  192. package/dist/wizard/wizard-summary.js +1 -1
  193. package/dist/wizard/wizard-summary.js.map +1 -1
  194. package/package.json +12 -2
  195. package/src/components/account-settings.tsx +6 -2
  196. package/src/components/amount-input.tsx +10 -8
  197. package/src/components/autocomplete.tsx +110 -24
  198. package/src/components/calculator.tsx +5 -1
  199. package/src/components/chart-zoom.tsx +8 -1
  200. package/src/components/chart.tsx +68 -7
  201. package/src/components/chip.tsx +131 -49
  202. package/src/components/choice-card.tsx +77 -36
  203. package/src/components/combobox-core.tsx +147 -20
  204. package/src/components/combobox.tsx +100 -44
  205. package/src/components/currency-select.tsx +6 -6
  206. package/src/components/danger-confirm.tsx +90 -36
  207. package/src/components/data-table-pagination.tsx +32 -19
  208. package/src/components/data-table.tsx +198 -36
  209. package/src/components/date-picker.tsx +24 -7
  210. package/src/components/dialog-frame.tsx +97 -22
  211. package/src/components/disclosure.tsx +75 -13
  212. package/src/components/dropdown.tsx +17 -5
  213. package/src/components/entity-combobox.tsx +26 -13
  214. package/src/components/file-button.tsx +74 -5
  215. package/src/components/file-dropzone.tsx +44 -35
  216. package/src/components/grouped-picker.tsx +2 -2
  217. package/src/components/hover-menu.tsx +139 -10
  218. package/src/components/icon-picker.tsx +14 -1
  219. package/src/components/measured-grid.tsx +852 -0
  220. package/src/components/mini-calendar.tsx +11 -8
  221. package/src/components/month-picker.tsx +18 -11
  222. package/src/components/multi-entity-combobox.tsx +2 -2
  223. package/src/components/multi-select.tsx +34 -7
  224. package/src/components/number-input.tsx +4 -4
  225. package/src/components/numpad-sheet.tsx +5 -3
  226. package/src/components/picker-sheet.tsx +1 -1
  227. package/src/components/popover.tsx +18 -3
  228. package/src/components/series-chart-ticks.ts +52 -0
  229. package/src/components/series-chart.tsx +39 -31
  230. package/src/components/swatch-picker.tsx +11 -0
  231. package/src/components/swipeable-row.tsx +21 -6
  232. package/src/components/tile-radio.tsx +5 -7
  233. package/src/components/toggle-group.tsx +39 -3
  234. package/src/components/toggle-legend.tsx +5 -2
  235. package/src/components/treemap.tsx +28 -5
  236. package/src/components/ui.tsx +74 -30
  237. package/src/components/use-anchor-dir.ts +20 -0
  238. package/src/components/use-mobile-reveal.ts +10 -2
  239. package/src/components/use-table-state.ts +44 -7
  240. package/src/data-table.ts +1 -0
  241. package/src/feedback/feedback-attachment.tsx +36 -9
  242. package/src/feedback/feedback-dialog.tsx +3 -2
  243. package/src/hooks/use-windowed-rows.ts +69 -0
  244. package/src/i18n/defaults.ts +4 -0
  245. package/src/i18n/kit-labels.tsx +4 -0
  246. package/src/i18n/locales/de-CH.ts +30 -0
  247. package/src/i18n/locales/de.ts +365 -0
  248. package/src/i18n/locales/es.ts +358 -0
  249. package/src/i18n/locales/fr.ts +371 -0
  250. package/src/i18n/locales/hu.ts +359 -0
  251. package/src/i18n/locales/it.ts +360 -0
  252. package/src/i18n/locales/zh.ts +348 -0
  253. package/src/index.ts +10 -1
  254. package/src/lib/direction.ts +37 -0
  255. package/src/rhf/form.tsx +21 -0
  256. package/src/search/command-palette.tsx +77 -19
  257. package/src/shell/app-shell.tsx +200 -129
  258. package/src/shell/topbar-action-menu.tsx +1 -1
  259. package/src/shell/topbar-controls.tsx +19 -8
  260. package/src/theme/chart-palette.ts +9 -2
  261. package/src/tour/tour.tsx +119 -18
  262. package/src/wizard/use-wizard.ts +55 -17
  263. package/src/wizard/wizard-summary.tsx +1 -1
@@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, KeyboardEvent } from "react";
3
3
  import { ChevronLeft, ChevronRight } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
+ import { horizontalStep } from "../lib/direction";
5
6
  import { useAnnounce } from "../hooks/use-announce";
6
7
  import { parseIsoDate, sameYmd, toLocalIso } from "../lib/dates";
7
8
  import { useKitLabels, useKitLocale, useKitWeekStart } from "../i18n/kit-labels";
@@ -322,12 +323,13 @@ export function MiniCalendar({
322
323
  */
323
324
  const onDayKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
324
325
  const next = new Date(activeDate);
325
- switch (e.key) {
326
- case "ArrowLeft":
327
- next.setDate(next.getDate() - 1);
328
- break;
329
- case "ArrowRight":
330
- next.setDate(next.getDate() + 1);
326
+ // ←/→ are "previous/next day" along the READING direction: the grid's columns run
327
+ // right-to-left in RTL (it is a CSS grid, so the browser mirrors it), and ArrowLeft
328
+ // there moves to the day that sits to the left — the next one.
329
+ const step = horizontalStep(e.key, e.currentTarget);
330
+ switch (step ? "horizontal" : e.key) {
331
+ case "horizontal":
332
+ next.setDate(next.getDate() + step);
331
333
  break;
332
334
  case "ArrowUp":
333
335
  next.setDate(next.getDate() - 7);
@@ -419,7 +421,8 @@ export function MiniCalendar({
419
421
  aria-label={labels.previousMonth}
420
422
  className="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
421
423
  >
422
- <ChevronLeft className="size-4" />
424
+ {/* Mirrored in RTL: "previous" points to the reading START, which is right. */}
425
+ <ChevronLeft className="size-4 rtl:-scale-x-100" aria-hidden />
423
426
  </button>
424
427
  {/* Named by this element rather than by a string of its own, and `aria-live`
425
428
  so paging months says which month you have landed on — the caption is the
@@ -438,7 +441,7 @@ export function MiniCalendar({
438
441
  aria-label={labels.nextMonth}
439
442
  className="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
440
443
  >
441
- <ChevronRight className="size-4" />
444
+ <ChevronRight className="size-4 rtl:-scale-x-100" aria-hidden />
442
445
  </button>
443
446
  </div>
444
447
  <div
@@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from "react";
3
3
  import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
+ import { dirOf, horizontalStep, type Direction } from "../lib/direction";
5
6
  import { monthKey, pad } from "../lib/dates";
6
7
  import { FieldLabel, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER } from "./ui";
7
8
  import { Popover } from "./popover";
@@ -309,17 +310,14 @@ function MonthGrid({
309
310
 
310
311
  const onCellKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
311
312
  // Left and right are VISUAL directions: in a right-to-left page the grid runs the
312
- // other way, so "right" is the previous month there. Read from the nearest `dir`
313
- // up the tree, which for this portalled panel is in practice <html>'s.
314
- const rtl = e.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
313
+ // other way, so "right" is the previous month there. The panel is portalled, but
314
+ // it carries the field's `dir` (see MonthPicker), so the nearest `dir` is right.
315
+ const step = horizontalStep(e.key, e.currentTarget);
315
316
  const col = (active.month - 1) % COLUMNS;
316
317
  let delta: number;
317
- switch (e.key) {
318
- case "ArrowLeft":
319
- delta = rtl ? 1 : -1;
320
- break;
321
- case "ArrowRight":
322
- delta = rtl ? -1 : 1;
318
+ switch (step ? "horizontal" : e.key) {
319
+ case "horizontal":
320
+ delta = step;
323
321
  break;
324
322
  case "ArrowUp":
325
323
  delta = -COLUMNS;
@@ -502,9 +500,14 @@ export function MonthPicker({
502
500
  const panelId = `${id}-panel`;
503
501
  // Only a STRING label can double as a name; a ReactNode may hold its own controls.
504
502
  const named = typeof label === "string";
503
+ // The panel is portalled to <body>, out of the subtree whose `dir` it inherited — an
504
+ // RTL form got an LTR grid. Read when opening (`toggle` is the popover's only way
505
+ // open) and put back on the panel. Also what the arrow keys above read.
506
+ const rootRef = useRef<HTMLDivElement>(null);
507
+ const [dir, setDir] = useState<Direction>("ltr");
505
508
 
506
509
  return (
507
- <div {...wrapperRest} className={cn("relative", className)}>
510
+ <div {...wrapperRest} ref={rootRef} className={cn("relative", className)}>
508
511
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
509
512
  {/* The hidden twin the trigger is named by — see DateField. `sr-only-fixed`,
510
513
  and inside this `relative` root either way. */}
@@ -517,10 +520,14 @@ export function MonthPicker({
517
520
  width={256}
518
521
  panelId={panelId}
519
522
  labels={{ panel: labels.panel }}
523
+ dir={dir}
520
524
  trigger={({ open, toggle, ref }) => (
521
525
  <MonthFieldTrigger
522
526
  open={open}
523
- toggle={toggle}
527
+ toggle={() => {
528
+ setDir(dirOf(rootRef.current));
529
+ toggle();
530
+ }}
524
531
  triggerRef={ref}
525
532
  triggerText={triggerText}
526
533
  hasValue={selected != null}
@@ -200,7 +200,7 @@ export function MultiEntityCombobox<V extends string | number>({
200
200
  }}
201
201
  className={cn(
202
202
  FIELD_TRIGGER,
203
- "pr-9",
203
+ "pe-9",
204
204
  label !== undefined && FIELD_FLOATING_PAD,
205
205
  disabled && "cursor-not-allowed opacity-50",
206
206
  field.isInvalid && FIELD_INVALID,
@@ -226,7 +226,7 @@ export function MultiEntityCombobox<V extends string | number>({
226
226
  e.stopPropagation();
227
227
  onChange([]);
228
228
  }}
229
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
229
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
230
230
  >
231
231
  <X className="size-4" />
232
232
  </span>
@@ -8,7 +8,7 @@ import { Check } from "lucide-react";
8
8
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from "./ui";
9
9
  import { cn } from "../lib/cn";
10
10
  import { DropdownPanel, DropdownSearchHeader, useDropdownSearch } from "./dropdown";
11
- import { useActiveOptionScroll } from "./combobox-core";
11
+ import { useActiveOptionScroll, useComboboxFieldError } from "./combobox-core";
12
12
  import {
13
13
  DEFAULT_COMMON_LABELS,
14
14
  DEFAULT_MULTI_SELECT_LABELS,
@@ -50,6 +50,14 @@ export interface MultiSelectProps extends Omit<ComponentPropsWithoutRef<"div">,
50
50
  * this package carries it now, so a form can mark any of its fields rather than
51
51
  * only the one that happened to have it first. */
52
52
  invalid?: boolean;
53
+ /** What is wrong with the selection, in the caller's words. Rendered under the
54
+ * field, pointed at by the trigger's `aria-describedby` (merged with any the
55
+ * caller passed) and implies `invalid` — {@link Input}'s `error`, on the same
56
+ * rules. */
57
+ error?: ReactNode;
58
+ /** The trigger cannot be opened: FIELD_BASE's settled grey, no chevron (as a
59
+ * disabled {@link Select} drops its own), and a dimmed label. */
60
+ disabled?: boolean;
53
61
  }
54
62
 
55
63
  export function MultiSelect({
@@ -66,7 +74,10 @@ export function MultiSelect({
66
74
  className,
67
75
  panelClassName,
68
76
  invalid,
77
+ error,
78
+ disabled,
69
79
  "aria-label": ariaLabel,
80
+ "aria-describedby": ariaDescribedBy,
70
81
  ...rest
71
82
  }: MultiSelectProps) {
72
83
  const { open, setOpen, wrapperRef, triggerRef, closeToTrigger, query, setQuery, inputRef } =
@@ -81,6 +92,7 @@ export function MultiSelect({
81
92
  selectedCount: itemLabel,
82
93
  });
83
94
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
95
+ const field = useComboboxFieldError(error, invalid, ariaDescribedBy);
84
96
  // One id per instance: `aria-controls` on the trigger names the list while the
85
97
  // list is still closed.
86
98
  const uid = useId();
@@ -151,10 +163,11 @@ export function MultiSelect({
151
163
  // the thing with a role. Spread FIRST so the ARIA the trigger composes, and the
152
164
  // handlers that open the list, cannot be replaced by accident from outside.
153
165
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
154
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
166
+ {label !== undefined && <FieldLabel className={disabled ? "opacity-50" : undefined}>{label}</FieldLabel>}
155
167
  <button
156
168
  ref={triggerRef}
157
169
  type="button"
170
+ disabled={disabled}
158
171
  // A combobox, not a bare button: this trigger carried `aria-invalid`, which
159
172
  // `button` does not support, so a required-and-empty filter painted its rose
160
173
  // border and announced nothing (ESLint's `role-supports-aria-props`, the
@@ -187,15 +200,25 @@ export function MultiSelect({
187
200
  setActive(0);
188
201
  }
189
202
  }}
190
- aria-invalid={invalid || undefined}
191
- className={cn(FIELD_TRIGGER, "pr-9", label !== undefined && FIELD_FLOATING_PAD, invalid && FIELD_INVALID)}
203
+ aria-invalid={field.isInvalid || undefined}
204
+ // On the TRIGGER, where focus lands — the `aria-describedby` a caller put on
205
+ // the component is taken off the wrapper for the same reason.
206
+ aria-describedby={field.describedBy}
207
+ className={cn(
208
+ FIELD_TRIGGER,
209
+ "pe-9",
210
+ label !== undefined && FIELD_FLOATING_PAD,
211
+ // FIELD_TRIGGER's hover would still light a trigger nothing can open.
212
+ "disabled:hover:bg-[var(--bg-surface-2)]",
213
+ field.isInvalid && FIELD_INVALID,
214
+ )}
192
215
  >
193
216
  {/* The summary IS the field's value, so it inherits FIELD_BASE's ink rather
194
217
  than restating a lighter one (Keksdose dev#477). */}
195
218
  <span className="truncate">{selectedSummary}</span>
196
- <FieldChevron />
219
+ {!disabled && <FieldChevron />}
197
220
  </button>
198
- {open && (
221
+ {open && !disabled && (
199
222
  <DropdownPanel
200
223
  // 16rem by default; `panelClassName` is how a caller widens it (Keksdose
201
224
  // feedback #147: the market picker's rows carry a postcode, a town, a
@@ -266,7 +289,7 @@ export function MultiSelect({
266
289
  onClick={() => toggle(o.value)}
267
290
  onMouseEnter={() => setActive(i)}
268
291
  className={cn(
269
- "flex w-full items-center justify-between gap-2 px-3 py-1.5 text-sm text-left hover:bg-[var(--bg-hover)]",
292
+ "flex w-full items-center justify-between gap-2 px-3 py-1.5 text-sm text-start hover:bg-[var(--bg-hover)]",
270
293
  checked && "bg-[var(--bg-active)]",
271
294
  i === active && "bg-[var(--bg-hover)]",
272
295
  )}
@@ -298,6 +321,10 @@ export function MultiSelect({
298
321
  })}
299
322
  </DropdownPanel>
300
323
  )}
324
+ {/* After the panel, not before it: the panel is `absolute` with no `top`, so it
325
+ opens at its place in the flow — under the message, had the message come
326
+ first. */}
327
+ {field.errorEl}
301
328
  </div>
302
329
  );
303
330
  }
@@ -234,7 +234,7 @@ export function NumberInput({
234
234
  }}
235
235
  onKeyDown={onKeyDown}
236
236
  aria-invalid={invalid || undefined}
237
- // The right-padding sits last so it always wins over an inputClassName that
237
+ // The end padding sits last so it always wins over an inputClassName that
238
238
  // sets its own px. Both trailing controls can be on at once, so the room
239
239
  // they need is reserved together rather than by whichever happens to render:
240
240
  // the calculator is ~36px and a short unit ~24px, and a field that reserved
@@ -246,7 +246,7 @@ export function NumberInput({
246
246
  ? FLOATING_INPUT_CLASS
247
247
  : FIELD_BASE,
248
248
  inputClassName,
249
- showCalc && suffix !== undefined ? "pr-16" : showCalc ? "pr-9" : suffix !== undefined ? "pr-8" : undefined,
249
+ showCalc && suffix !== undefined ? "pe-16" : showCalc ? "pe-9" : suffix !== undefined ? "pe-8" : undefined,
250
250
  invalid && FIELD_INVALID,
251
251
  )}
252
252
  />
@@ -254,7 +254,7 @@ export function NumberInput({
254
254
  // One flex track for both, so the unit and the calculator sit side by side
255
255
  // instead of stacking on the same corner — AmountInput's arrangement, which
256
256
  // has carried a chip and a calculator together since #430.
257
- <div className="absolute inset-y-0 right-0 flex items-center">
257
+ <div className="absolute inset-y-0 end-0 flex items-center">
258
258
  {showCalc && (
259
259
  <CalculatorButton
260
260
  value={value}
@@ -267,7 +267,7 @@ export function NumberInput({
267
267
  {suffix !== undefined && (
268
268
  <span
269
269
  aria-hidden
270
- className="pointer-events-none pr-3 text-xs font-medium text-[var(--text-muted)]"
270
+ className="pointer-events-none pe-3 text-xs font-medium text-[var(--text-muted)]"
271
271
  >
272
272
  {suffix}
273
273
  </span>
@@ -203,9 +203,11 @@ export function NumberPadSheet({
203
203
  {label != null && (
204
204
  <span className="truncate pb-1 text-xs font-medium text-[var(--money-neutral)]">{label}</span>
205
205
  )}
206
- <div className="ml-auto min-w-0 text-right">
207
- <div className="truncate font-mono text-lg leading-tight text-[var(--text-primary)]">{value || "0"}</div>
208
- <div className="h-4 font-mono text-xs text-[var(--money-neutral)]">{preview}</div>
206
+ {/* At the logical end, opposite the label; the figures themselves stay
207
+ left-to-right and right-aligned, like the desktop calculator's. */}
208
+ <div className="ms-auto min-w-0">
209
+ <div dir="ltr" className="truncate text-right font-mono text-lg leading-tight text-[var(--text-primary)]">{value || "0"}</div>
210
+ <div dir="ltr" className="h-4 text-right font-mono text-xs text-[var(--money-neutral)]">{preview}</div>
209
211
  </div>
210
212
  </div>
211
213
 
@@ -246,6 +246,6 @@ export function PickerSheet({
246
246
  /** A row inside a {@link PickerSheet} — the phone-sized version of a dropdown row,
247
247
  * so a list is comfortable to hit with a thumb rather than merely legible. */
248
248
  export const SHEET_ROW_CLASS = cn(
249
- "flex min-h-11 w-full items-center gap-2 px-4 py-2 text-left text-base",
249
+ "flex min-h-11 w-full items-center gap-2 px-4 py-2 text-start text-base",
250
250
  "text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
251
251
  );
@@ -5,6 +5,8 @@ import { useAnchoredPanel } from "../hooks/use-anchored-panel";
5
5
  import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
6
6
  import { useFocusTrap } from "../hooks/use-focus-trap";
7
7
  import { cn } from "../lib/cn";
8
+ import { dirOf } from "../lib/direction";
9
+ import type { Direction } from "../lib/direction";
8
10
  import { useKitLabels } from "../i18n/kit-labels";
9
11
 
10
12
  /** The panel's accessible name. A `role="dialog"` with no name announces as "dialog"
@@ -101,13 +103,22 @@ export function Popover({
101
103
  const triggerRef = useRef<HTMLButtonElement>(null);
102
104
  const panelRef = useRef<HTMLDivElement>(null);
103
105
  const [open, setOpen] = useState(false);
106
+ // The trigger's reading direction, read when it is pressed. The panel is portalled to
107
+ // <body>, out of any `dir="rtl"` subtree the trigger sits in, so without carrying it
108
+ // across a right-to-left calendar or filter rendered left-to-right.
109
+ const [dir, setDir] = useState<Direction>("ltr");
104
110
  const close = () => setOpen(false);
105
- const toggle = () => setOpen((v) => !v);
111
+ const toggle = () => {
112
+ setDir(dirOf(triggerRef.current));
113
+ setOpen((v) => !v);
114
+ };
106
115
  // prop > `<UiKitProvider labels={{ popover }}>` > English. The provider's
107
116
  // `popover.panel` is only the last-resort name for a panel no caller named.
108
117
  const text = useKitLabels("popover", DEFAULT_POPOVER_LABELS, labels);
109
118
 
110
- // Align the panel's right edge to the trigger's and clamp it horizontally; the
119
+ // Align the panel's END edge to the trigger's — right in LTR, left in RTL; it was
120
+ // always `rect.right`, so an RTL panel hung off the wrong side — and clamp it
121
+ // horizontally; the
111
122
  // hook owns the vertical half — below by default, flipped above (and height-capped)
112
123
  // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom
113
124
  // of the screen (feedback #135). It re-measures on scroll/resize.
@@ -116,7 +127,10 @@ export function Popover({
116
127
  const pos = rect
117
128
  ? {
118
129
  top: placement.top,
119
- left: Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8),
130
+ left: Math.min(
131
+ Math.max(8, dir === "rtl" ? rect.left : rect.right - width),
132
+ window.innerWidth - width - 8,
133
+ ),
120
134
  }
121
135
  : null;
122
136
 
@@ -169,6 +183,7 @@ export function Popover({
169
183
  // element; the DOM spelling is the one being standardised on, so where both
170
184
  // are present it is the one that survives.
171
185
  aria-label={ariaLabel ?? text.panel}
186
+ dir={rest.dir ?? dir}
172
187
  // Without it the panel cannot take focus at all, and the trap above would
173
188
  // silently do nothing on a panel whose children are not yet tabbable.
174
189
  tabIndex={-1}
@@ -0,0 +1,52 @@
1
+ // Round tick values for `SeriesChart` — internal, not re-exported by `@eifi1/ui-kit/chart`.
2
+ //
3
+ // Handed an explicit numeric domain, recharts divides it into equal parts and prints
4
+ // wherever those land: a fitted band of [-11, 811] comes out as -11, 189, 389 … and a
5
+ // padded [89, 1011] as 89, 289, 489 — numbers nobody can put a ruler against. These
6
+ // are the ticks a person would draw instead: every multiple of a 1, 2 or 5 × 10^n step
7
+ // that lies INSIDE the domain. The domain itself is left alone, so the 4 % of air the
8
+ // fit leaves and a zoom window's exact edges both survive; the frame simply does not
9
+ // get a number of its own.
10
+
11
+ /** Roughly how many ticks to aim for. The 1/2/5 ladder lands anywhere from about half
12
+ * to about twice this, which is the price of every tick being a round number. */
13
+ const TARGET_TICKS = 5;
14
+
15
+ /** The round step closest to `span / target`: 1, 2 or 5 times a power of ten. */
16
+ export function niceStep(span: number, target: number = TARGET_TICKS): number {
17
+ const rough = span / Math.max(1, target);
18
+ const magnitude = 10 ** Math.floor(Math.log10(rough));
19
+ const norm = rough / magnitude;
20
+ const factor = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10;
21
+ return factor * magnitude;
22
+ }
23
+
24
+ /**
25
+ * The round ticks inside `[low, high]`, ascending — or `undefined` when there is no
26
+ * span to divide (no domain, a zero-height one, anything non-finite), which leaves the
27
+ * choice to recharts.
28
+ *
29
+ * Each tick is computed as `k × step` and then rounded to the step's own decimals:
30
+ * accumulating `+= 0.1` would print 0.30000000000000004 on the third tick.
31
+ */
32
+ export function niceTicks(
33
+ domain: readonly [number, number] | undefined,
34
+ target: number = TARGET_TICKS,
35
+ ): number[] | undefined {
36
+ if (!domain) return undefined;
37
+ const [low, high] = domain;
38
+ if (!Number.isFinite(low) || !Number.isFinite(high) || !(high > low)) return undefined;
39
+ const step = niceStep(high - low, target);
40
+ if (!(step > 0) || !Number.isFinite(step)) return undefined;
41
+ const decimals = Math.max(0, -Math.floor(Math.log10(step)));
42
+ const ticks: number[] = [];
43
+ // Half a step's tolerance per end would admit ticks outside the frame; a millionth
44
+ // of one only forgives the float error in `low / step`.
45
+ const first = Math.ceil(low / step - 1e-6);
46
+ const last = Math.floor(high / step + 1e-6);
47
+ for (let k = first; k <= last; k++) {
48
+ // `+ 0` turns the -0 that `Number("-0.00")` gives back into a plain 0.
49
+ ticks.push(Number((k * step).toFixed(decimals)) + 0);
50
+ }
51
+ return ticks.length ? ticks : undefined;
52
+ }
@@ -24,6 +24,7 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } f
24
24
  import { DEFAULT_Y_AXIS, withChartZoom, type ZoomBinding } from "./chart-zoom";
25
25
  import { STEP_DASH, strokeDash } from "./toggle-legend";
26
26
  import { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from "./series-chart-labels";
27
+ import { niceTicks } from "./series-chart-ticks";
27
28
  import { paletteFor } from "../theme/chart-palette";
28
29
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
29
30
  import { cn } from "../lib/cn";
@@ -353,6 +354,8 @@ export function StaticSeriesChart({
353
354
  };
354
355
 
355
356
  // Fitted with air around it, unless the reader has zoomed or the caller pinned it.
357
+ // Ticks are the round values INSIDE whichever domain that is (see
358
+ // `series-chart-ticks.ts`), so a zoom window still gets round numbers, just finer.
356
359
  const fittedX = zoom?.xDomain ?? paddedDomain(rows.map((row) => row[xKey]));
357
360
  const fittedY = (axis: SeriesChartAxis) =>
358
361
  zoom?.yDomains[axis.id] ??
@@ -375,6 +378,7 @@ export function StaticSeriesChart({
375
378
  // category axis would straighten exactly the curvature the chart is for.
376
379
  type="number"
377
380
  domain={fittedX ?? ["dataMin", "dataMax"]}
381
+ ticks={niceTicks(fittedX)}
378
382
  // Clip the lines to a zoom window instead of recharts widening it back out.
379
383
  allowDataOverflow={zoom?.xDomain !== undefined}
380
384
  tickLine={false}
@@ -395,37 +399,41 @@ export function StaticSeriesChart({
395
399
  />
396
400
  )}
397
401
  </XAxis>
398
- {axes.map((axis) => (
399
- <YAxis
400
- key={axis.id}
401
- yAxisId={axis.id}
402
- hide={axis.hide}
403
- domain={fittedY(axis)}
404
- allowDataOverflow={zoom?.yDomains[axis.id] !== undefined}
405
- orientation={axis.orientation ?? "left"}
406
- width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
407
- tickLine={false}
408
- axisLine={false}
409
- tickFormatter={axis.format ?? number}
410
- // With an axis per channel the tint is the only thing saying which
411
- // numbers belong to which line.
412
- tick={axis.color ? { fill: axis.color } : undefined}
413
- >
414
- {axis.title && !axis.hide && (
415
- <Label
416
- value={axis.title}
417
- // Rotated so both sides read upward from the bottom of their axis.
418
- angle={axis.orientation === "right" ? 90 : -90}
419
- position={axis.orientation === "right" ? "insideRight" : "insideLeft"}
420
- // Inline, so a sole series' colour beats the class's neutral fill —
421
- // and absent for a shared axis, so the class draws it.
422
- style={{ textAnchor: "middle", fill: soleSeriesColor(axis, series) }}
423
- offset={AXIS_TITLE_OFFSET}
424
- className="fill-[var(--text-muted)] text-[11px]"
425
- />
426
- )}
427
- </YAxis>
428
- ))}
402
+ {axes.map((axis) => {
403
+ const domain = fittedY(axis);
404
+ return (
405
+ <YAxis
406
+ key={axis.id}
407
+ yAxisId={axis.id}
408
+ hide={axis.hide}
409
+ domain={domain}
410
+ ticks={niceTicks(domain)}
411
+ allowDataOverflow={zoom?.yDomains[axis.id] !== undefined}
412
+ orientation={axis.orientation ?? "left"}
413
+ width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}
414
+ tickLine={false}
415
+ axisLine={false}
416
+ tickFormatter={axis.format ?? number}
417
+ // With an axis per channel the tint is the only thing saying which
418
+ // numbers belong to which line.
419
+ tick={axis.color ? { fill: axis.color } : undefined}
420
+ >
421
+ {axis.title && !axis.hide && (
422
+ <Label
423
+ value={axis.title}
424
+ // Rotated so both sides read upward from the bottom of their axis.
425
+ angle={axis.orientation === "right" ? 90 : -90}
426
+ position={axis.orientation === "right" ? "insideRight" : "insideLeft"}
427
+ // Inline, so a sole series' colour beats the class's neutral fill —
428
+ // and absent for a shared axis, so the class draws it.
429
+ style={{ textAnchor: "middle", fill: soleSeriesColor(axis, series) }}
430
+ offset={AXIS_TITLE_OFFSET}
431
+ className="fill-[var(--text-muted)] text-[11px]"
432
+ />
433
+ )}
434
+ </YAxis>
435
+ );
436
+ })}
429
437
  <ChartTooltip
430
438
  content={
431
439
  <ChartTooltipContent
@@ -59,6 +59,15 @@ export interface SwatchPickerProps<T extends string>
59
59
  activation?: "automatic" | "manual";
60
60
  /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
61
61
  size?: TileSize;
62
+ /**
63
+ * Classes for every tile's frame, merged after the kit's own — so a caller's
64
+ * `size-*` beats `size`'s. For a tile that has to follow a container query or a
65
+ * breakpoint (keksdose grows its tiles to 40px in a narrow card:
66
+ * `tileClassName="@max-md:size-10"`) without reaching into the kit's markup with an
67
+ * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s
68
+ * dimensions; the tile grows round it.
69
+ */
70
+ tileClassName?: string;
62
71
  disabled?: boolean;
63
72
  labels?: Partial<SwatchPickerLabels>;
64
73
  }
@@ -83,6 +92,7 @@ export function SwatchPicker<T extends string>({
83
92
  mixed = false,
84
93
  activation = "automatic",
85
94
  size = "md",
95
+ tileClassName,
86
96
  disabled = false,
87
97
  labels,
88
98
  className,
@@ -112,6 +122,7 @@ export function SwatchPicker<T extends string>({
112
122
  activation={activation}
113
123
  disabled={disabled}
114
124
  size={size}
125
+ tileClassName={tileClassName ? () => tileClassName : undefined}
115
126
  renderTile={(item) => {
116
127
  const opt = item.key === null ? undefined : byValue.get(item.key);
117
128
  return (
@@ -24,10 +24,19 @@ export interface SwipeAction {
24
24
  armedClassName: string;
25
25
  }
26
26
 
27
+ /**
28
+ * `right`/`left` are PHYSICAL drag directions in every writing direction, and stay so on
29
+ * purpose: a swipe is a movement of the finger across the glass, the gesture hook
30
+ * measures it in screen pixels, and "which way is destructive" is an app decision that
31
+ * RTL guidelines do not agree on. An app that wants mirrored actions in RTL swaps the
32
+ * two arrays itself. What the component does mirror is everything it lays out: the
33
+ * reveal panel's contents hug the physical edge being uncovered, and the keyboard
34
+ * buttons sit at the row's logical END.
35
+ */
27
36
  export interface SwipeableRowProps {
28
- /** Actions committed by dragging RIGHT, nearest threshold first. */
37
+ /** Actions committed by dragging (physically) RIGHT, nearest threshold first. */
29
38
  right?: SwipeAction[];
30
- /** Actions committed by dragging LEFT, nearest threshold first. */
39
+ /** Actions committed by dragging (physically) LEFT, nearest threshold first. */
31
40
  left?: SwipeAction[];
32
41
  /** Turn the gesture off — an open editor, a pending mutation, a locked row. This
33
42
  * also withdraws the keyboard buttons below: a row that must not be acted on must
@@ -180,8 +189,10 @@ export function SwipeableRow({
180
189
  armed ? "opacity-100" : "opacity-60",
181
190
  armed ? shown.armedClassName : shown.className,
182
191
  // The panel is anchored to the edge the row is uncovering, so the label
183
- // sits where the eye already is rather than across the screen.
184
- draggingLeft && "flex-row-reverse",
192
+ // sits where the eye already is rather than across the screen. That edge is
193
+ // physical, while a flex row starts at the INLINE start — so in RTL the two
194
+ // directions swap, or the label sat under the row on the covered side.
195
+ draggingLeft ? "flex-row-reverse rtl:flex-row" : "rtl:flex-row-reverse",
185
196
  )}
186
197
  >
187
198
  {shown.icon && (
@@ -236,10 +247,14 @@ export function SwipeableRow({
236
247
  //
237
248
  // `pointer-events-none` here, restored on a button only once it HAS focus: a
238
249
  // sr-only button is clipped to nothing but is still a hit target, and this
239
- // strip sits at the row's right edge — exactly where a leftward drag begins.
250
+ // strip sits at the row's trailing edge — exactly where a drag toward the
251
+ // start begins.
252
+ //
253
+ // `end-0`/`pe-2`, not `right-0`/`pr-2`: in RTL the right edge is where the
254
+ // row's title starts, and a focused button there covered it.
240
255
  // A gesture that silently failed to start because it landed on an invisible
241
256
  // button is not a bug anyone would find by looking at the screen.
242
- className="pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1 pr-2"
257
+ className="pointer-events-none absolute inset-y-0 end-0 z-10 flex items-center gap-1 pe-2"
243
258
  >
244
259
  {/* Both sides, each still nearest-threshold first, so the tab order reads in
245
260
  the order the gesture arms them. */}
@@ -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;