@eifi1/ui-kit 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +5 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +33 -6
  9. package/dist/components/autocomplete.js +62 -16
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +5 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/chip.d.ts +34 -14
  20. package/dist/components/chip.js +63 -28
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +18 -1
  23. package/dist/components/choice-card.js +21 -8
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/combobox-core.d.ts +61 -2
  26. package/dist/components/combobox-core.js +68 -17
  27. package/dist/components/combobox-core.js.map +1 -1
  28. package/dist/components/combobox.d.ts +11 -7
  29. package/dist/components/combobox.js +51 -28
  30. package/dist/components/combobox.js.map +1 -1
  31. package/dist/components/currency-select.js +3 -3
  32. package/dist/components/currency-select.js.map +1 -1
  33. package/dist/components/danger-confirm.d.ts +30 -6
  34. package/dist/components/danger-confirm.js +51 -32
  35. package/dist/components/danger-confirm.js.map +1 -1
  36. package/dist/components/data-table-filter-popover.d.ts +1 -1
  37. package/dist/components/data-table-filters.d.ts +1 -1
  38. package/dist/components/data-table-pagination.d.ts +6 -3
  39. package/dist/components/data-table-pagination.js +6 -5
  40. package/dist/components/data-table-pagination.js.map +1 -1
  41. package/dist/components/data-table.d.ts +1 -1
  42. package/dist/components/data-table.js +332 -248
  43. package/dist/components/data-table.js.map +1 -1
  44. package/dist/components/date-picker.js +17 -7
  45. package/dist/components/date-picker.js.map +1 -1
  46. package/dist/components/dialog-frame.d.ts +28 -3
  47. package/dist/components/dialog-frame.js +47 -11
  48. package/dist/components/dialog-frame.js.map +1 -1
  49. package/dist/components/disclosure.d.ts +22 -2
  50. package/dist/components/disclosure.js +41 -8
  51. package/dist/components/disclosure.js.map +1 -1
  52. package/dist/components/dropdown.d.ts +7 -3
  53. package/dist/components/dropdown.js +6 -2
  54. package/dist/components/dropdown.js.map +1 -1
  55. package/dist/components/entity-combobox.d.ts +17 -8
  56. package/dist/components/entity-combobox.js +7 -5
  57. package/dist/components/entity-combobox.js.map +1 -1
  58. package/dist/components/file-button.d.ts +38 -5
  59. package/dist/components/file-button.js +23 -2
  60. package/dist/components/file-button.js.map +1 -1
  61. package/dist/components/file-dropzone.d.ts +11 -5
  62. package/dist/components/file-dropzone.js +14 -19
  63. package/dist/components/file-dropzone.js.map +1 -1
  64. package/dist/components/grouped-picker.js +2 -2
  65. package/dist/components/grouped-picker.js.map +1 -1
  66. package/dist/components/hover-menu.d.ts +8 -3
  67. package/dist/components/hover-menu.js +88 -4
  68. package/dist/components/hover-menu.js.map +1 -1
  69. package/dist/components/icon-picker.d.ts +10 -1
  70. package/dist/components/icon-picker.js +5 -1
  71. package/dist/components/icon-picker.js.map +1 -1
  72. package/dist/components/measured-grid.d.ts +165 -0
  73. package/dist/components/measured-grid.js +559 -0
  74. package/dist/components/measured-grid.js.map +1 -0
  75. package/dist/components/mini-calendar.js +7 -8
  76. package/dist/components/mini-calendar.js.map +1 -1
  77. package/dist/components/month-picker.js +13 -9
  78. package/dist/components/month-picker.js.map +1 -1
  79. package/dist/components/multi-entity-combobox.js +2 -2
  80. package/dist/components/multi-entity-combobox.js.map +1 -1
  81. package/dist/components/multi-select.d.ts +9 -1
  82. package/dist/components/multi-select.js +22 -8
  83. package/dist/components/multi-select.js.map +1 -1
  84. package/dist/components/number-field.d.ts +5 -0
  85. package/dist/components/number-input.d.ts +5 -0
  86. package/dist/components/number-input.js +3 -3
  87. package/dist/components/number-input.js.map +1 -1
  88. package/dist/components/numpad-sheet.d.ts +5 -0
  89. package/dist/components/numpad-sheet.js +3 -3
  90. package/dist/components/numpad-sheet.js.map +1 -1
  91. package/dist/components/picker-sheet.js +1 -1
  92. package/dist/components/picker-sheet.js.map +1 -1
  93. package/dist/components/popover.js +11 -2
  94. package/dist/components/popover.js.map +1 -1
  95. package/dist/components/series-chart-ticks.d.ts +13 -0
  96. package/dist/components/series-chart-ticks.js +29 -0
  97. package/dist/components/series-chart-ticks.js.map +1 -0
  98. package/dist/components/series-chart.js +33 -27
  99. package/dist/components/series-chart.js.map +1 -1
  100. package/dist/components/settings-fields.d.ts +2 -0
  101. package/dist/components/swatch-picker.d.ts +10 -1
  102. package/dist/components/swatch-picker.js +2 -0
  103. package/dist/components/swatch-picker.js.map +1 -1
  104. package/dist/components/swipeable-row.d.ts +11 -2
  105. package/dist/components/swipeable-row.js +5 -3
  106. package/dist/components/swipeable-row.js.map +1 -1
  107. package/dist/components/tile-radio.js +5 -7
  108. package/dist/components/tile-radio.js.map +1 -1
  109. package/dist/components/time-input.d.ts +2 -0
  110. package/dist/components/toggle-group.d.ts +13 -3
  111. package/dist/components/toggle-group.js +21 -1
  112. package/dist/components/toggle-group.js.map +1 -1
  113. package/dist/components/toggle-legend.js +1 -2
  114. package/dist/components/toggle-legend.js.map +1 -1
  115. package/dist/components/tooltip.d.ts +10 -4
  116. package/dist/components/tooltip.js +23 -6
  117. package/dist/components/tooltip.js.map +1 -1
  118. package/dist/components/treemap.d.ts +6 -1
  119. package/dist/components/treemap.js +16 -6
  120. package/dist/components/treemap.js.map +1 -1
  121. package/dist/components/ui.d.ts +36 -8
  122. package/dist/components/ui.js +27 -19
  123. package/dist/components/ui.js.map +1 -1
  124. package/dist/components/use-anchor-dir.d.ts +15 -0
  125. package/dist/components/use-anchor-dir.js +14 -0
  126. package/dist/components/use-anchor-dir.js.map +1 -0
  127. package/dist/components/use-mobile-reveal.d.ts +8 -1
  128. package/dist/components/use-mobile-reveal.js +3 -2
  129. package/dist/components/use-mobile-reveal.js.map +1 -1
  130. package/dist/components/use-table-state.d.ts +4 -4
  131. package/dist/components/use-table-state.js +12 -5
  132. package/dist/components/use-table-state.js.map +1 -1
  133. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  134. package/dist/data-table.d.ts +1 -1
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/feedback/feedback-attachment.d.ts +22 -3
  137. package/dist/feedback/feedback-attachment.js +15 -7
  138. package/dist/feedback/feedback-attachment.js.map +1 -1
  139. package/dist/feedback/feedback-dialog.d.ts +3 -2
  140. package/dist/feedback/feedback-dialog.js.map +1 -1
  141. package/dist/feedback.d.ts +1 -1
  142. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  143. package/dist/hooks/use-windowed-rows.js +31 -0
  144. package/dist/hooks/use-windowed-rows.js.map +1 -0
  145. package/dist/i18n/defaults.d.ts +5 -0
  146. package/dist/i18n/defaults.js +5 -1
  147. package/dist/i18n/defaults.js.map +1 -1
  148. package/dist/i18n/kit-labels.d.ts +7 -0
  149. package/dist/i18n/kit-labels.js.map +1 -1
  150. package/dist/i18n/locales/de-CH.d.ts +45 -0
  151. package/dist/i18n/locales/de-CH.js +21 -0
  152. package/dist/i18n/locales/de-CH.js.map +1 -0
  153. package/dist/i18n/locales/de.d.ts +45 -0
  154. package/dist/i18n/locales/de.js +344 -0
  155. package/dist/i18n/locales/de.js.map +1 -0
  156. package/dist/i18n/locales/es.d.ts +45 -0
  157. package/dist/i18n/locales/es.js +339 -0
  158. package/dist/i18n/locales/es.js.map +1 -0
  159. package/dist/i18n/locales/fr.d.ts +45 -0
  160. package/dist/i18n/locales/fr.js +344 -0
  161. package/dist/i18n/locales/fr.js.map +1 -0
  162. package/dist/i18n/locales/hu.d.ts +45 -0
  163. package/dist/i18n/locales/hu.js +343 -0
  164. package/dist/i18n/locales/hu.js.map +1 -0
  165. package/dist/i18n/locales/it.d.ts +45 -0
  166. package/dist/i18n/locales/it.js +342 -0
  167. package/dist/i18n/locales/it.js.map +1 -0
  168. package/dist/i18n/locales/zh.d.ts +45 -0
  169. package/dist/i18n/locales/zh.js +338 -0
  170. package/dist/i18n/locales/zh.js.map +1 -0
  171. package/dist/index.d.ts +7 -4
  172. package/dist/index.js +4 -1
  173. package/dist/index.js.map +1 -1
  174. package/dist/lib/direction.d.ts +24 -0
  175. package/dist/lib/direction.js +19 -0
  176. package/dist/lib/direction.js.map +1 -0
  177. package/dist/rhf/form.d.ts +2 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/rhf.d.ts +2 -0
  180. package/dist/search/command-palette.d.ts +5 -0
  181. package/dist/search/command-palette.js +80 -50
  182. package/dist/search/command-palette.js.map +1 -1
  183. package/dist/shell/app-shell.d.ts +13 -1
  184. package/dist/shell/app-shell.js +134 -85
  185. package/dist/shell/app-shell.js.map +1 -1
  186. package/dist/shell/topbar-action-menu.js +1 -1
  187. package/dist/shell/topbar-action-menu.js.map +1 -1
  188. package/dist/shell/topbar-controls.d.ts +7 -3
  189. package/dist/shell/topbar-controls.js +38 -31
  190. package/dist/shell/topbar-controls.js.map +1 -1
  191. package/dist/theme/chart-palette.d.ts +7 -1
  192. package/dist/theme/chart-palette.js +2 -1
  193. package/dist/theme/chart-palette.js.map +1 -1
  194. package/dist/tour/tour.d.ts +11 -5
  195. package/dist/tour/tour.js +124 -58
  196. package/dist/tour/tour.js.map +1 -1
  197. package/dist/wizard/use-wizard.js +30 -13
  198. package/dist/wizard/use-wizard.js.map +1 -1
  199. package/dist/wizard/wizard-summary.js +1 -1
  200. package/dist/wizard/wizard-summary.js.map +1 -1
  201. package/package.json +12 -2
  202. package/src/components/account-settings.tsx +6 -2
  203. package/src/components/amount-input.tsx +10 -8
  204. package/src/components/autocomplete.tsx +110 -24
  205. package/src/components/calculator.tsx +5 -1
  206. package/src/components/chart-zoom.tsx +8 -1
  207. package/src/components/chart.tsx +68 -7
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +74 -5
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/tile-radio.tsx +5 -7
  240. package/src/components/toggle-group.tsx +39 -3
  241. package/src/components/toggle-legend.tsx +5 -2
  242. package/src/components/tooltip.tsx +40 -12
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +79 -33
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +201 -130
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/picker-sheet.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader } from \"./dropdown\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { useVisualViewport } from \"../hooks/use-anchored-panel\";\nimport { DEFAULT_PICKER_SHEET_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * Two of the div's own attributes are omitted because this component already owns\n * the name: `title` here is the sheet's HEADING (and a ReactNode), not the browser's\n * tooltip string, and `onClose` is \"the sheet was dismissed\", not the DOM's\n * `<dialog>` close event. Everything else a caller can pass reaches the sheet.\n */\nexport interface PickerSheetProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\" | \"onClose\"> {\n open: boolean;\n onClose: () => void;\n /** The field's own label — a sheet that fills the screen has to say what it is\n * asking for, which the anchored panel got for free by sitting under it. */\n title?: ReactNode;\n query: string;\n onQueryChange: (value: string) => void;\n searchPlaceholder?: string;\n inputRef?: RefObject<HTMLInputElement | null>;\n /** Default: `pickerSheet.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n children: ReactNode;\n}\n\n/**\n * The PHONE presentation of a picker: a full-screen dialog with a search box at\n * the top and the list filling everything below it (Keksdose live #200).\n *\n * *\"Paid as full screen dialog with input. Similar to the account select that\n * already appears as full screen.\"* The account field is a native `<select>`, and\n * a phone browser renders that as a full-screen list — so the app's own pickers,\n * which are anchored dropdown panels, were the odd ones out: a 320px-tall panel\n * squeezed between the field and the keyboard, showing three or four rows of a\n * list that might have two hundred entries.\n *\n * What this fixes beyond size: the panel had to be *placed* (above/below, tracking\n * the visual viewport as the keyboard opened — see `useAnchoredPanel`), and every\n * one of those decisions is a chance to be wrong on a screen this small. A sheet\n * has no placement. It also gives the search input somewhere unambiguous to live,\n * which is the other half of the request: *\"add filler possibility to the account\n * select or even better right to the hoc the account select is derived from\"* —\n * so this lives in the shared picker, not in one form's copy of it.\n *\n * Rows are `min-h-11` (44px): the touch target the rest of the app uses. The list\n * scrolls, the header does not, and the body behind it is scroll-locked so a drag\n * that overshoots the list does not move the page underneath.\n */\nexport function PickerSheet({\n open,\n onClose,\n title,\n query,\n onQueryChange,\n searchPlaceholder,\n inputRef,\n closeLabel,\n children,\n \"aria-label\": ariaLabel,\n className,\n style,\n ...rest\n}: PickerSheetProps) {\n // Every picker in the kit opens one of these on a phone, and most of them pass\n // their own `closeLabel` through as `undefined` — so this lookup is what makes the\n // one way out of a full-screen sheet speak the app's language without each of\n // them having to (see the prop's note in `EntityCombobox`).\n const labels = useKitLabels(\"pickerSheet\", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel });\n useBodyScrollLock(open);\n /**\n * Back closes THE SHEET, not the dialog it was opened from (Keksdose live #309:\n * *\"Mouse Back does not only close the select but also the whole edit or create\n * dialog which is cumbersome.\"*).\n *\n * The sheet is a full-screen overlay over whatever opened it, and on a phone that\n * is almost always the row editor — which DOES push a history entry\n * (`data-table.tsx`). So the sheet was invisible to Back: one press popped the\n * editor's entry and took the whole dialog with it, discarding an edit in progress\n * in order to dismiss a list.\n *\n * `useOverlayHistory` was built for exactly this nesting — its stack unwinds\n * last-in-first-out, and the U-12 regression test in its own suite is written\n * around \"a Modal and a PickerSheet inside it\". The sheet simply never called it.\n *\n * Nothing else has to change: the hook is a no-op while `open` is false, and it\n * pops its own entry when the sheet closes some other way (the X, a row, the\n * backdrop) so history does not accumulate husks.\n */\n useOverlayHistory(open, onClose);\n // The visible region, while the sheet is up — see the `style` below for why a\n // full-screen sheet cannot simply be `inset-0` on a phone (live #328).\n const vv = useVisualViewport(open);\n // A fallback ref so a caller that does not need the handle still gets the opening\n // focus, rather than the behaviour depending on whether a prop was passed. What that\n // focus is FOR is on the trap below.\n const ownRef = useRef<HTMLInputElement | null>(null);\n const searchRef = inputRef ?? ownRef;\n\n const sheetRef = useRef<HTMLDivElement>(null);\n /**\n * The sheet said `aria-modal=\"true\"` and then left focus on the field behind it, so\n * a screen reader was told to hide the page the user's cursor was still standing in\n * (the audit's *\"Only Modal manages focus\"*). Through the shared hook rather than\n * the bare `.focus()` that used to live here: the hook re-reads the tabbable list on\n * every Tab, which this panel needs more than most — its list is filtered as you\n * type, so the rows a trap captured at open time are gone by the second keystroke.\n *\n * **`initialFocus` is the search box**, not the container, and this is the one place\n * in the package that overrides `Modal`'s rule. The sheet exists in order to be typed\n * into (live #212: *\"make the text input filter active on select click\"*), and the\n * callers cannot do it themselves — `Combobox`'s field runs\n * `setOpen(true); sheetInputRef.current?.focus()` in a single `onFocus`, so the focus\n * call happens while this sheet is still unmounted and the ref is null. Only the\n * sheet knows when its own input exists. Popping the keyboard is the POINT here,\n * where in a form dialog it is the cost.\n *\n * **`restoreFocus` is off**, which is the uncomfortable half. Every caller in this\n * kit opens the sheet from the field's own `onFocus` handler, so handing focus back\n * to that field on close re-opens the sheet the user has just dismissed — a row tap\n * would select a value and immediately put the list back over it. The field that\n * opened it is the only thing that knows whether re-focusing it means \"open\"; a sheet\n * that cannot tell must not guess. The cost is that focus lands on `<body>` after a\n * dismissal, which on the phone this shape is for costs nothing, and on a pointer\n * device is what happened before this change too.\n */\n useFocusTrap(sheetRef, {\n active: open,\n restoreFocus: false,\n initialFocus: () => searchRef.current,\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // The sheet is almost always the INNERMOST overlay — it opens from a field inside a\n // `Modal` or a `FullBleedDialog`, both of which take Escape on their own panel. A\n // React portal's events still travel up the React tree, so without stopping it here\n // one press would dismiss this list and the dialog underneath with it: Keksdose\n // live #309 (*\"Mouse Back does not only close the select but also the whole edit or\n // create dialog which is cumbersome\"*), which the Back gesture already learned, on\n // the key a desktop user reaches for instead.\n e.stopPropagation();\n onClose();\n };\n\n if (!open || typeof document === \"undefined\") return null;\n return createPortal(\n <div\n // Spread FIRST, and every attribute below it deliberately wins: `style`,\n // `onMouseDown` and `onKeyDown` here are not styling choices, they are live\n // #328, dev#477 and live #309 respectively, and a caller who passed one by\n // accident would re-open a bug three apps have already paid for. `style` is\n // MERGED rather than won outright (see below), which is the one case where a\n // caller has something to add.\n {...rest}\n ref={sheetRef}\n role=\"dialog\"\n aria-modal=\"true\"\n // Without it the container cannot take focus, and `useFocusTrap` has nothing to\n // fall back to when the list is empty and the search box is the only thing in\n // here — see the hook's \"nothing tabbable\" branch.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n // The caller's own name wins over the heading: a sheet titled \"Account\" in a\n // form with two of them needs to say WHICH, and `title` is what the user reads\n // rather than what a reader announces.\n aria-label={ariaLabel ?? (typeof title === \"string\" ? title : undefined)}\n // The sheet is PORTALLED to <body>, so it is not inside the field's own\n // wrapper — and `useDropdown` closes on any document mousedown landing\n // outside that wrapper. Tapping a row therefore unmounted the row (open →\n // false) before its CLICK could fire, so the tap selected nothing at all and\n // the sheet just vanished (Keksdose dev#477).\n //\n // It bit whichever sheet commits on `onClick` — the payee field's, since live\n // #200, where it hid behind the free-text value the search box was already\n // setting; and the account field's the moment dev#477 moved it onto the inline\n // picker. `ComboboxPanel`'s rows happen to commit on `onMouseDown`, so they\n // beat the document listener and were never affected — which is luck, not a\n // design, and exactly why the fix belongs to the sheet.\n //\n // Stopping the mousedown here rather than teaching every caller about the\n // portal: the sheet is modal and full-screen, so \"a click in here is not an\n // outside click\" is a property of the sheet, not of whoever opened it.\n onMouseDown={(e) => e.stopPropagation()}\n // ⚠️ `inset-0` was the whole of Keksdose live #328: *\"When the keyboard overlays\n // the entries I cannot scroll past them … to see also the last entries. Happens\n // when browsing the account select.\"*\n //\n // This sheet FOCUSES ITS OWN SEARCH BOX on open (see above), so the keyboard is\n // up every single time it is used on a phone. On Android that shrinks the visual\n // viewport and leaves the LAYOUT viewport alone — `inset-0` is the layout\n // viewport — so the sheet kept its full height with its bottom third behind the\n // keyboard. The list below is `flex-1 overflow-y-auto`, so it sized itself to\n // that hidden height too: it scrolled to ITS end while the last rows were still\n // under the keys, and no gesture could bring them out. Nothing was clipped and\n // nothing looked broken, which is why it reads as \"I cannot scroll past them\".\n //\n // `visualViewport` is the API that answers rather than hints (the live #292\n // lesson): it reports the region actually on screen, keyboard and pinch-pan\n // included, and it fires on the keyboard opening and closing. Pinned to that box,\n // the sheet ends where the keys begin and the list's own scroll covers the rest.\n // `useVisualViewport` returns null when there is nothing to correct, and then\n // this is character-for-character the sheet that was here before.\n // A caller's `style` is kept and the correction laid OVER it: the three values\n // below are the whole of live #328, so they are not negotiable, but a z-index\n // or a transition from outside has nothing to do with them. Without a visual\n // viewport to correct for this is character-for-character what the caller passed.\n style={vv ? { ...style, top: vv.top, height: vv.height, bottom: \"auto\" } : style}\n className={cn(\"fixed inset-0 z-50 flex flex-col bg-[var(--bg-surface)] outline-none\", className)}\n >\n <div className=\"flex items-center gap-2 border-b border-[var(--border)] px-3 py-2\">\n <span className=\"min-w-0 flex-1 truncate text-sm font-semibold text-[var(--text-primary)]\">\n {title}\n </span>\n <button\n type=\"button\"\n aria-label={labels.close}\n onClick={onClose}\n className=\"rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n <DropdownSearchHeader\n query={query}\n onQueryChange={onQueryChange}\n inputRef={searchRef}\n placeholder={searchPlaceholder}\n />\n {/* min-h-0 so the LIST scrolls rather than the dialog growing past the\n viewport — a flex child defaults to min-height:auto. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">{children}</div>\n </div>,\n document.body,\n );\n}\n\n/** A row inside a {@link PickerSheet} — the phone-sized version of a dropdown row,\n * so a list is comfortable to hit with a thumb rather than merely legible. */\nexport const SHEET_ROW_CLASS = cn(\n \"flex min-h-11 w-full items-center gap-2 px-4 py-2 text-left text-base\",\n \"text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\",\n);\n"],"mappings":";AA0NM,SACE,KADF;AA1NN,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,4BAA4B;AACrC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,6BAA6B,oBAAoB;AA+CnD,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AAKnB,QAAM,SAAS,aAAa,eAAe,6BAA6B,EAAE,OAAO,WAAW,CAAC;AAC7F,oBAAkB,IAAI;AAoBtB,oBAAkB,MAAM,OAAO;AAG/B,QAAM,KAAK,kBAAkB,IAAI;AAIjC,QAAM,SAAS,OAAgC,IAAI;AACnD,QAAM,YAAY,YAAY;AAE9B,QAAM,WAAW,OAAuB,IAAI;AA2B5C,eAAa,UAAU;AAAA,IACrB,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,cAAc,MAAM,UAAU;AAAA,EAChC,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,YAAQ;AAAA,EACV;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AACrD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAOE,GAAG;AAAA,QACJ,KAAK;AAAA,QACL,MAAK;AAAA,QACL,cAAW;AAAA,QAIX,UAAU;AAAA,QACV,WAAW;AAAA,QAIX,cAAY,cAAc,OAAO,UAAU,WAAW,QAAQ;AAAA,QAiB9D,aAAa,CAAC,MAAM,EAAE,gBAAgB;AAAA,QAwBtC,OAAO,KAAK,EAAE,GAAG,OAAO,KAAK,GAAG,KAAK,QAAQ,GAAG,QAAQ,QAAQ,OAAO,IAAI;AAAA,QAC3E,WAAW,GAAG,wEAAwE,SAAS;AAAA,QAE/F;AAAA,+BAAC,SAAI,WAAU,qEACb;AAAA,gCAAC,UAAK,WAAU,4EACb,iBACH;AAAA,YACA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,cAAY,OAAO;AAAA,gBACnB,SAAS;AAAA,gBACT,WAAU;AAAA,gBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,YACxB;AAAA,aACF;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,UAAU;AAAA,cACV,aAAa;AAAA;AAAA,UACf;AAAA,UAGA,oBAAC,SAAI,WAAU,qDAAqD,UAAS;AAAA;AAAA;AAAA,IAC/E;AAAA,IACA,SAAS;AAAA,EACX;AACF;AAIO,MAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/components/picker-sheet.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader } from \"./dropdown\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { useVisualViewport } from \"../hooks/use-anchored-panel\";\nimport { DEFAULT_PICKER_SHEET_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * Two of the div's own attributes are omitted because this component already owns\n * the name: `title` here is the sheet's HEADING (and a ReactNode), not the browser's\n * tooltip string, and `onClose` is \"the sheet was dismissed\", not the DOM's\n * `<dialog>` close event. Everything else a caller can pass reaches the sheet.\n */\nexport interface PickerSheetProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\" | \"onClose\"> {\n open: boolean;\n onClose: () => void;\n /** The field's own label — a sheet that fills the screen has to say what it is\n * asking for, which the anchored panel got for free by sitting under it. */\n title?: ReactNode;\n query: string;\n onQueryChange: (value: string) => void;\n searchPlaceholder?: string;\n inputRef?: RefObject<HTMLInputElement | null>;\n /** Default: `pickerSheet.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n children: ReactNode;\n}\n\n/**\n * The PHONE presentation of a picker: a full-screen dialog with a search box at\n * the top and the list filling everything below it (Keksdose live #200).\n *\n * *\"Paid as full screen dialog with input. Similar to the account select that\n * already appears as full screen.\"* The account field is a native `<select>`, and\n * a phone browser renders that as a full-screen list — so the app's own pickers,\n * which are anchored dropdown panels, were the odd ones out: a 320px-tall panel\n * squeezed between the field and the keyboard, showing three or four rows of a\n * list that might have two hundred entries.\n *\n * What this fixes beyond size: the panel had to be *placed* (above/below, tracking\n * the visual viewport as the keyboard opened — see `useAnchoredPanel`), and every\n * one of those decisions is a chance to be wrong on a screen this small. A sheet\n * has no placement. It also gives the search input somewhere unambiguous to live,\n * which is the other half of the request: *\"add filler possibility to the account\n * select or even better right to the hoc the account select is derived from\"* —\n * so this lives in the shared picker, not in one form's copy of it.\n *\n * Rows are `min-h-11` (44px): the touch target the rest of the app uses. The list\n * scrolls, the header does not, and the body behind it is scroll-locked so a drag\n * that overshoots the list does not move the page underneath.\n */\nexport function PickerSheet({\n open,\n onClose,\n title,\n query,\n onQueryChange,\n searchPlaceholder,\n inputRef,\n closeLabel,\n children,\n \"aria-label\": ariaLabel,\n className,\n style,\n ...rest\n}: PickerSheetProps) {\n // Every picker in the kit opens one of these on a phone, and most of them pass\n // their own `closeLabel` through as `undefined` — so this lookup is what makes the\n // one way out of a full-screen sheet speak the app's language without each of\n // them having to (see the prop's note in `EntityCombobox`).\n const labels = useKitLabels(\"pickerSheet\", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel });\n useBodyScrollLock(open);\n /**\n * Back closes THE SHEET, not the dialog it was opened from (Keksdose live #309:\n * *\"Mouse Back does not only close the select but also the whole edit or create\n * dialog which is cumbersome.\"*).\n *\n * The sheet is a full-screen overlay over whatever opened it, and on a phone that\n * is almost always the row editor — which DOES push a history entry\n * (`data-table.tsx`). So the sheet was invisible to Back: one press popped the\n * editor's entry and took the whole dialog with it, discarding an edit in progress\n * in order to dismiss a list.\n *\n * `useOverlayHistory` was built for exactly this nesting — its stack unwinds\n * last-in-first-out, and the U-12 regression test in its own suite is written\n * around \"a Modal and a PickerSheet inside it\". The sheet simply never called it.\n *\n * Nothing else has to change: the hook is a no-op while `open` is false, and it\n * pops its own entry when the sheet closes some other way (the X, a row, the\n * backdrop) so history does not accumulate husks.\n */\n useOverlayHistory(open, onClose);\n // The visible region, while the sheet is up — see the `style` below for why a\n // full-screen sheet cannot simply be `inset-0` on a phone (live #328).\n const vv = useVisualViewport(open);\n // A fallback ref so a caller that does not need the handle still gets the opening\n // focus, rather than the behaviour depending on whether a prop was passed. What that\n // focus is FOR is on the trap below.\n const ownRef = useRef<HTMLInputElement | null>(null);\n const searchRef = inputRef ?? ownRef;\n\n const sheetRef = useRef<HTMLDivElement>(null);\n /**\n * The sheet said `aria-modal=\"true\"` and then left focus on the field behind it, so\n * a screen reader was told to hide the page the user's cursor was still standing in\n * (the audit's *\"Only Modal manages focus\"*). Through the shared hook rather than\n * the bare `.focus()` that used to live here: the hook re-reads the tabbable list on\n * every Tab, which this panel needs more than most — its list is filtered as you\n * type, so the rows a trap captured at open time are gone by the second keystroke.\n *\n * **`initialFocus` is the search box**, not the container, and this is the one place\n * in the package that overrides `Modal`'s rule. The sheet exists in order to be typed\n * into (live #212: *\"make the text input filter active on select click\"*), and the\n * callers cannot do it themselves — `Combobox`'s field runs\n * `setOpen(true); sheetInputRef.current?.focus()` in a single `onFocus`, so the focus\n * call happens while this sheet is still unmounted and the ref is null. Only the\n * sheet knows when its own input exists. Popping the keyboard is the POINT here,\n * where in a form dialog it is the cost.\n *\n * **`restoreFocus` is off**, which is the uncomfortable half. Every caller in this\n * kit opens the sheet from the field's own `onFocus` handler, so handing focus back\n * to that field on close re-opens the sheet the user has just dismissed — a row tap\n * would select a value and immediately put the list back over it. The field that\n * opened it is the only thing that knows whether re-focusing it means \"open\"; a sheet\n * that cannot tell must not guess. The cost is that focus lands on `<body>` after a\n * dismissal, which on the phone this shape is for costs nothing, and on a pointer\n * device is what happened before this change too.\n */\n useFocusTrap(sheetRef, {\n active: open,\n restoreFocus: false,\n initialFocus: () => searchRef.current,\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // The sheet is almost always the INNERMOST overlay — it opens from a field inside a\n // `Modal` or a `FullBleedDialog`, both of which take Escape on their own panel. A\n // React portal's events still travel up the React tree, so without stopping it here\n // one press would dismiss this list and the dialog underneath with it: Keksdose\n // live #309 (*\"Mouse Back does not only close the select but also the whole edit or\n // create dialog which is cumbersome\"*), which the Back gesture already learned, on\n // the key a desktop user reaches for instead.\n e.stopPropagation();\n onClose();\n };\n\n if (!open || typeof document === \"undefined\") return null;\n return createPortal(\n <div\n // Spread FIRST, and every attribute below it deliberately wins: `style`,\n // `onMouseDown` and `onKeyDown` here are not styling choices, they are live\n // #328, dev#477 and live #309 respectively, and a caller who passed one by\n // accident would re-open a bug three apps have already paid for. `style` is\n // MERGED rather than won outright (see below), which is the one case where a\n // caller has something to add.\n {...rest}\n ref={sheetRef}\n role=\"dialog\"\n aria-modal=\"true\"\n // Without it the container cannot take focus, and `useFocusTrap` has nothing to\n // fall back to when the list is empty and the search box is the only thing in\n // here — see the hook's \"nothing tabbable\" branch.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n // The caller's own name wins over the heading: a sheet titled \"Account\" in a\n // form with two of them needs to say WHICH, and `title` is what the user reads\n // rather than what a reader announces.\n aria-label={ariaLabel ?? (typeof title === \"string\" ? title : undefined)}\n // The sheet is PORTALLED to <body>, so it is not inside the field's own\n // wrapper — and `useDropdown` closes on any document mousedown landing\n // outside that wrapper. Tapping a row therefore unmounted the row (open →\n // false) before its CLICK could fire, so the tap selected nothing at all and\n // the sheet just vanished (Keksdose dev#477).\n //\n // It bit whichever sheet commits on `onClick` — the payee field's, since live\n // #200, where it hid behind the free-text value the search box was already\n // setting; and the account field's the moment dev#477 moved it onto the inline\n // picker. `ComboboxPanel`'s rows happen to commit on `onMouseDown`, so they\n // beat the document listener and were never affected — which is luck, not a\n // design, and exactly why the fix belongs to the sheet.\n //\n // Stopping the mousedown here rather than teaching every caller about the\n // portal: the sheet is modal and full-screen, so \"a click in here is not an\n // outside click\" is a property of the sheet, not of whoever opened it.\n onMouseDown={(e) => e.stopPropagation()}\n // ⚠️ `inset-0` was the whole of Keksdose live #328: *\"When the keyboard overlays\n // the entries I cannot scroll past them … to see also the last entries. Happens\n // when browsing the account select.\"*\n //\n // This sheet FOCUSES ITS OWN SEARCH BOX on open (see above), so the keyboard is\n // up every single time it is used on a phone. On Android that shrinks the visual\n // viewport and leaves the LAYOUT viewport alone — `inset-0` is the layout\n // viewport — so the sheet kept its full height with its bottom third behind the\n // keyboard. The list below is `flex-1 overflow-y-auto`, so it sized itself to\n // that hidden height too: it scrolled to ITS end while the last rows were still\n // under the keys, and no gesture could bring them out. Nothing was clipped and\n // nothing looked broken, which is why it reads as \"I cannot scroll past them\".\n //\n // `visualViewport` is the API that answers rather than hints (the live #292\n // lesson): it reports the region actually on screen, keyboard and pinch-pan\n // included, and it fires on the keyboard opening and closing. Pinned to that box,\n // the sheet ends where the keys begin and the list's own scroll covers the rest.\n // `useVisualViewport` returns null when there is nothing to correct, and then\n // this is character-for-character the sheet that was here before.\n // A caller's `style` is kept and the correction laid OVER it: the three values\n // below are the whole of live #328, so they are not negotiable, but a z-index\n // or a transition from outside has nothing to do with them. Without a visual\n // viewport to correct for this is character-for-character what the caller passed.\n style={vv ? { ...style, top: vv.top, height: vv.height, bottom: \"auto\" } : style}\n className={cn(\"fixed inset-0 z-50 flex flex-col bg-[var(--bg-surface)] outline-none\", className)}\n >\n <div className=\"flex items-center gap-2 border-b border-[var(--border)] px-3 py-2\">\n <span className=\"min-w-0 flex-1 truncate text-sm font-semibold text-[var(--text-primary)]\">\n {title}\n </span>\n <button\n type=\"button\"\n aria-label={labels.close}\n onClick={onClose}\n className=\"rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n <DropdownSearchHeader\n query={query}\n onQueryChange={onQueryChange}\n inputRef={searchRef}\n placeholder={searchPlaceholder}\n />\n {/* min-h-0 so the LIST scrolls rather than the dialog growing past the\n viewport — a flex child defaults to min-height:auto. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">{children}</div>\n </div>,\n document.body,\n );\n}\n\n/** A row inside a {@link PickerSheet} — the phone-sized version of a dropdown row,\n * so a list is comfortable to hit with a thumb rather than merely legible. */\nexport const SHEET_ROW_CLASS = cn(\n \"flex min-h-11 w-full items-center gap-2 px-4 py-2 text-start text-base\",\n \"text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\",\n);\n"],"mappings":";AA0NM,SACE,KADF;AA1NN,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,4BAA4B;AACrC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,6BAA6B,oBAAoB;AA+CnD,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AAKnB,QAAM,SAAS,aAAa,eAAe,6BAA6B,EAAE,OAAO,WAAW,CAAC;AAC7F,oBAAkB,IAAI;AAoBtB,oBAAkB,MAAM,OAAO;AAG/B,QAAM,KAAK,kBAAkB,IAAI;AAIjC,QAAM,SAAS,OAAgC,IAAI;AACnD,QAAM,YAAY,YAAY;AAE9B,QAAM,WAAW,OAAuB,IAAI;AA2B5C,eAAa,UAAU;AAAA,IACrB,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,cAAc,MAAM,UAAU;AAAA,EAChC,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,YAAQ;AAAA,EACV;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AACrD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAOE,GAAG;AAAA,QACJ,KAAK;AAAA,QACL,MAAK;AAAA,QACL,cAAW;AAAA,QAIX,UAAU;AAAA,QACV,WAAW;AAAA,QAIX,cAAY,cAAc,OAAO,UAAU,WAAW,QAAQ;AAAA,QAiB9D,aAAa,CAAC,MAAM,EAAE,gBAAgB;AAAA,QAwBtC,OAAO,KAAK,EAAE,GAAG,OAAO,KAAK,GAAG,KAAK,QAAQ,GAAG,QAAQ,QAAQ,OAAO,IAAI;AAAA,QAC3E,WAAW,GAAG,wEAAwE,SAAS;AAAA,QAE/F;AAAA,+BAAC,SAAI,WAAU,qEACb;AAAA,gCAAC,UAAK,WAAU,4EACb,iBACH;AAAA,YACA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,cAAY,OAAO;AAAA,gBACnB,SAAS;AAAA,gBACT,WAAU;AAAA,gBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,YACxB;AAAA,aACF;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,UAAU;AAAA,cACV,aAAa;AAAA;AAAA,UACf;AAAA,UAGA,oBAAC,SAAI,WAAU,qDAAqD,UAAS;AAAA;AAAA;AAAA,IAC/E;AAAA,IACA,SAAS;AAAA,EACX;AACF;AAIO,MAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AACF;","names":[]}
@@ -6,6 +6,7 @@ import { useAnchoredPanel } from "../hooks/use-anchored-panel.js";
6
6
  import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss.js";
7
7
  import { useFocusTrap } from "../hooks/use-focus-trap.js";
8
8
  import { cn } from "../lib/cn.js";
9
+ import { dirOf } from "../lib/direction.js";
9
10
  import { useKitLabels } from "../i18n/kit-labels.js";
10
11
  const DEFAULT_POPOVER_LABELS = {
11
12
  // "Pop-up", not "Popover": the panel's accessible name is read to USERS, and
@@ -28,14 +29,21 @@ function Popover({
28
29
  const triggerRef = useRef(null);
29
30
  const panelRef = useRef(null);
30
31
  const [open, setOpen] = useState(false);
32
+ const [dir, setDir] = useState("ltr");
31
33
  const close = () => setOpen(false);
32
- const toggle = () => setOpen((v) => !v);
34
+ const toggle = () => {
35
+ setDir(dirOf(triggerRef.current));
36
+ setOpen((v) => !v);
37
+ };
33
38
  const text = useKitLabels("popover", DEFAULT_POPOVER_LABELS, labels);
34
39
  const placement = useAnchoredPanel(triggerRef, open);
35
40
  const rect = placement.rect;
36
41
  const pos = rect ? {
37
42
  top: placement.top,
38
- left: Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)
43
+ left: Math.min(
44
+ Math.max(8, dir === "rtl" ? rect.left : rect.right - width),
45
+ window.innerWidth - width - 8
46
+ )
39
47
  } : null;
40
48
  useOutsideClick([triggerRef, panelRef], close, open);
41
49
  useEscapeKey(close, open);
@@ -68,6 +76,7 @@ function Popover({
68
76
  role: "dialog",
69
77
  id: panelId,
70
78
  "aria-label": ariaLabel ?? text.panel,
79
+ dir: rest.dir ?? dir,
71
80
  tabIndex: -1,
72
81
  onKeyDown: handleKeyDown,
73
82
  style: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/popover.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useAnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/** The panel's accessible name. A `role=\"dialog\"` with no name announces as \"dialog\"\n * and nothing else, which is worse than the unlabelled div it replaced — so it is a\n * prop with an English default, like every other string this package renders. Callers\n * should pass the control's own name (\"Filter\", \"Choose a date\"); the default is only\n * there so an unmigrated call site is still named. */\nexport interface PopoverLabels {\n panel: string;\n}\n\n// Exported since the kit grew one label tree (`UiKitLabels`, src/i18n): the complete\n// English reference a translator works from has to be able to name every namespace.\nexport const DEFAULT_POPOVER_LABELS: PopoverLabels = {\n // \"Pop-up\", not \"Popover\": the panel's accessible name is read to USERS, and\n // \"popover\" is a developer's word for it (reported by keksdose, 0.5.0). Only a\n // bare Popover falls back to this — the kit's own pickers name their panels.\n panel: \"Pop-up\",\n};\n\n/**\n * Exported, and extending the `<div>` attributes, so a consumer wrapping this — which is\n * what `DateField` and `FilterPopover` in this very package do — can hand anything\n * through to the PANEL: a `data-tour` anchor for the kit's own guided tour, a test id,\n * an `aria-describedby`, or an `aria-label` naming this particular popover.\n *\n * `children` is omitted from those attributes rather than inherited: here it is a render\n * prop taking the panel's `close`, not a `ReactNode`, so the two cannot be reconciled.\n */\nexport interface PopoverProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void; ref: RefObject<HTMLButtonElement | null> }) => ReactNode;\n children: (close: () => void) => ReactNode;\n width?: number;\n labels?: Partial<PopoverLabels>;\n /** Id for the panel, so a trigger wearing `role=\"combobox\"` can `aria-controls` it.\n * Required by ARIA on that role, and the panel is rendered here rather than by the\n * caller, so the id has to come in from outside. */\n panelId?: string;\n /** Extra classes for the PANEL. The trigger is the caller's own element. */\n className?: string;\n}\n\nconst POPOVER_WIDTH = 288;\n\n/**\n * Portal-rendered popover anchored under its trigger button. The panel is fixed\n * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on\n * scroll/resize; it closes on outside-click and Escape.\n *\n * ## Trapped, not merely focused — and why, because it is the closer call\n *\n * The audit's finding was that this panel *\"portals out of the tab order with no role\n * and no focus move\"*: Tab from the trigger went straight past the panel into the page\n * behind it, so the calendar, the calculator keypad and every column filter in the kit\n * were mouse-only. Two shapes fix that, and a popover is not automatically a dialog, so\n * this was chosen rather than inherited:\n *\n * - **The light one** — move focus in on open, hand it back on close, and let Tab walk\n * OUT of the panel into whatever follows the trigger. That is the right shape for a\n * non-modal popover sitting inline in the document.\n * - **The trap** — Tab cycles inside the panel; Escape and a click outside are the\n * ways out. What is here.\n *\n * The deciding fact is the portal. This panel is rendered to the END of `<body>`, not\n * beside its trigger, so \"let Tab walk out\" does not mean \"carry on from the trigger\" —\n * it means land at the end of the document, several hundred controls from where the\n * user was. Tabbing out of it would be a worse place to arrive than the one the finding\n * complains about, and fixing that properly needs a sentinel-and-return dance that\n * nothing in this package has. The trap gets the user back to the trigger every time,\n * because `useFocusTrap` restores focus on close.\n *\n * And the panels this kit actually opens are self-contained: pick a date, tap out a\n * sum, narrow a column. Each of them commits and closes, and each already has two\n * documented ways out wired above — Escape and an outside press — so containment costs\n * a keyboard user nothing they had.\n *\n * **It stays non-modal in every other respect**, deliberately: no backdrop, no body\n * scroll lock, and no `aria-modal`, so the page behind is still readable by a screen\n * reader, still scrollable, and still clickable — clicking it is what dismisses this.\n * Only Tab is contained. A popover that took the page away as well would be a\n * {@link Modal}, and there is one of those.\n */\nexport function Popover({\n trigger,\n children,\n width = POPOVER_WIDTH,\n labels,\n panelId,\n className,\n style,\n \"aria-label\": ariaLabel,\n ...rest\n}: PopoverProps) {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [open, setOpen] = useState(false);\n const close = () => setOpen(false);\n const toggle = () => setOpen((v) => !v);\n // prop > `<UiKitProvider labels={{ popover }}>` > English. The provider's\n // `popover.panel` is only the last-resort name for a panel no caller named.\n const text = useKitLabels(\"popover\", DEFAULT_POPOVER_LABELS, labels);\n\n // Align the panel's right edge to the trigger's and clamp it horizontally; the\n // hook owns the vertical half — below by default, flipped above (and height-capped)\n // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom\n // of the screen (feedback #135). It re-measures on scroll/resize.\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n const pos = rect\n ? {\n top: placement.top,\n left: Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8),\n }\n : null;\n\n useOutsideClick([triggerRef, panelRef], close, open);\n useEscapeKey(close, open);\n\n useFocusTrap(panelRef, {\n active: open && pos !== null,\n // The panel keeps a focus one of its own children has already taken. `FilterPopover`\n // autofocuses its text box on mount — that is what the press on \"Filter\" was FOR,\n // and it is a prop there precisely so the popover keeps it — and a trap that\n // unconditionally focused the container would take it straight back off again,\n // one effect later and invisibly.\n initialFocus: () => {\n const panel = panelRef.current;\n const active = document.activeElement;\n return panel?.contains(active) ? (active as HTMLElement) : panel;\n },\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // `useEscapeKey` above listens on the document and still covers the case where\n // focus somehow is not in here. This handler is for the case that focus IS, which\n // is now the normal one — and it exists to STOP the key, because a popover opened\n // from inside a `Modal` (the calculator on an amount field is exactly that) would\n // otherwise have its Escape travel on up the React tree and close the dialog\n // underneath as well. Closing twice is harmless — `close` is a state setter, not a\n // caller's callback — so the two paths cannot double-fire anything.\n e.stopPropagation();\n close();\n };\n\n return (\n <>\n {trigger({ open, toggle, ref: triggerRef })}\n {open && pos &&\n createPortal(\n <div\n // `...rest` first: the role, the id the trigger's `aria-controls` points at,\n // the `tabIndex` the trap needs and the measured position are what make this\n // a popover, and a caller reaching for a `data-tour` anchor must not be able\n // to remove one of them by accident.\n {...rest}\n ref={panelRef}\n role=\"dialog\"\n id={panelId}\n // The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which\n // is the package's English fallback for an unnamed panel. Both name the same\n // element; the DOM spelling is the one being standardised on, so where both\n // are present it is the one that survives.\n aria-label={ariaLabel ?? text.panel}\n // Without it the panel cannot take focus at all, and the trap above would\n // silently do nothing on a panel whose children are not yet tabbable.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n style={{\n // Merged over the caller's, never replaced by it: these four are measured\n // against the trigger every scroll and resize, and a panel that dropped\n // them would paint at the top-left of the viewport.\n ...style,\n position: \"fixed\",\n top: pos.top,\n left: pos.left,\n width,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-3 shadow-lg outline-none\",\n className,\n )}\n >\n {children(close)}\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":";AAyJI,mBAIM,KAJN;AAzJJ,SAAS,QAAQ,gBAAgB;AAEjC,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC,SAAS,cAAc,uBAAuB;AAC9C,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAatB,MAAM,yBAAwC;AAAA;AAAA;AAAA;AAAA,EAInD,OAAO;AACT;AAwBA,MAAM,gBAAgB;AAwCf,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,GAAG;AACL,GAAiB;AACf,QAAM,aAAa,OAA0B,IAAI;AACjD,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,QAAQ,MAAM,QAAQ,KAAK;AACjC,QAAM,SAAS,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAC;AAGtC,QAAM,OAAO,aAAa,WAAW,wBAAwB,MAAM;AAMnE,QAAM,YAAY,iBAAiB,YAAY,IAAI;AACnD,QAAM,OAAO,UAAU;AACvB,QAAM,MAAM,OACR;AAAA,IACE,KAAK,UAAU;AAAA,IACf,MAAM,KAAK,IAAI,KAAK,IAAI,GAAG,KAAK,QAAQ,KAAK,GAAG,OAAO,aAAa,QAAQ,CAAC;AAAA,EAC/E,IACA;AAEJ,kBAAgB,CAAC,YAAY,QAAQ,GAAG,OAAO,IAAI;AACnD,eAAa,OAAO,IAAI;AAExB,eAAa,UAAU;AAAA,IACrB,QAAQ,QAAQ,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMxB,cAAc,MAAM;AAClB,YAAM,QAAQ,SAAS;AACvB,YAAM,SAAS,SAAS;AACxB,aAAO,OAAO,SAAS,MAAM,IAAK,SAAyB;AAAA,IAC7D;AAAA,EACF,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,UAAM;AAAA,EACR;AAEA,SACE,iCACG;AAAA,YAAQ,EAAE,MAAM,QAAQ,KAAK,WAAW,CAAC;AAAA,IACzC,QAAQ,OACP;AAAA,MACE;AAAA,QAAC;AAAA;AAAA,UAKE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAK;AAAA,UACL,IAAI;AAAA,UAKJ,cAAY,aAAa,KAAK;AAAA,UAG9B,UAAU;AAAA,UACV,WAAW;AAAA,UACX,OAAO;AAAA;AAAA;AAAA;AAAA,YAIL,GAAG;AAAA,YACH,UAAU;AAAA,YACV,KAAK,IAAI;AAAA,YACT,MAAM,IAAI;AAAA,YACV;AAAA,YACA,WAAW,UAAU;AAAA,UACvB;AAAA,UACA,WAAW;AAAA,YACT;AAAA,YACA;AAAA,UACF;AAAA,UAEC,mBAAS,KAAK;AAAA;AAAA,MACjB;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/popover.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useAnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf } from \"../lib/direction\";\nimport type { Direction } from \"../lib/direction\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/** The panel's accessible name. A `role=\"dialog\"` with no name announces as \"dialog\"\n * and nothing else, which is worse than the unlabelled div it replaced — so it is a\n * prop with an English default, like every other string this package renders. Callers\n * should pass the control's own name (\"Filter\", \"Choose a date\"); the default is only\n * there so an unmigrated call site is still named. */\nexport interface PopoverLabels {\n panel: string;\n}\n\n// Exported since the kit grew one label tree (`UiKitLabels`, src/i18n): the complete\n// English reference a translator works from has to be able to name every namespace.\nexport const DEFAULT_POPOVER_LABELS: PopoverLabels = {\n // \"Pop-up\", not \"Popover\": the panel's accessible name is read to USERS, and\n // \"popover\" is a developer's word for it (reported by keksdose, 0.5.0). Only a\n // bare Popover falls back to this — the kit's own pickers name their panels.\n panel: \"Pop-up\",\n};\n\n/**\n * Exported, and extending the `<div>` attributes, so a consumer wrapping this — which is\n * what `DateField` and `FilterPopover` in this very package do — can hand anything\n * through to the PANEL: a `data-tour` anchor for the kit's own guided tour, a test id,\n * an `aria-describedby`, or an `aria-label` naming this particular popover.\n *\n * `children` is omitted from those attributes rather than inherited: here it is a render\n * prop taking the panel's `close`, not a `ReactNode`, so the two cannot be reconciled.\n */\nexport interface PopoverProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void; ref: RefObject<HTMLButtonElement | null> }) => ReactNode;\n children: (close: () => void) => ReactNode;\n width?: number;\n labels?: Partial<PopoverLabels>;\n /** Id for the panel, so a trigger wearing `role=\"combobox\"` can `aria-controls` it.\n * Required by ARIA on that role, and the panel is rendered here rather than by the\n * caller, so the id has to come in from outside. */\n panelId?: string;\n /** Extra classes for the PANEL. The trigger is the caller's own element. */\n className?: string;\n}\n\nconst POPOVER_WIDTH = 288;\n\n/**\n * Portal-rendered popover anchored under its trigger button. The panel is fixed\n * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on\n * scroll/resize; it closes on outside-click and Escape.\n *\n * ## Trapped, not merely focused — and why, because it is the closer call\n *\n * The audit's finding was that this panel *\"portals out of the tab order with no role\n * and no focus move\"*: Tab from the trigger went straight past the panel into the page\n * behind it, so the calendar, the calculator keypad and every column filter in the kit\n * were mouse-only. Two shapes fix that, and a popover is not automatically a dialog, so\n * this was chosen rather than inherited:\n *\n * - **The light one** — move focus in on open, hand it back on close, and let Tab walk\n * OUT of the panel into whatever follows the trigger. That is the right shape for a\n * non-modal popover sitting inline in the document.\n * - **The trap** — Tab cycles inside the panel; Escape and a click outside are the\n * ways out. What is here.\n *\n * The deciding fact is the portal. This panel is rendered to the END of `<body>`, not\n * beside its trigger, so \"let Tab walk out\" does not mean \"carry on from the trigger\" —\n * it means land at the end of the document, several hundred controls from where the\n * user was. Tabbing out of it would be a worse place to arrive than the one the finding\n * complains about, and fixing that properly needs a sentinel-and-return dance that\n * nothing in this package has. The trap gets the user back to the trigger every time,\n * because `useFocusTrap` restores focus on close.\n *\n * And the panels this kit actually opens are self-contained: pick a date, tap out a\n * sum, narrow a column. Each of them commits and closes, and each already has two\n * documented ways out wired above — Escape and an outside press — so containment costs\n * a keyboard user nothing they had.\n *\n * **It stays non-modal in every other respect**, deliberately: no backdrop, no body\n * scroll lock, and no `aria-modal`, so the page behind is still readable by a screen\n * reader, still scrollable, and still clickable — clicking it is what dismisses this.\n * Only Tab is contained. A popover that took the page away as well would be a\n * {@link Modal}, and there is one of those.\n */\nexport function Popover({\n trigger,\n children,\n width = POPOVER_WIDTH,\n labels,\n panelId,\n className,\n style,\n \"aria-label\": ariaLabel,\n ...rest\n}: PopoverProps) {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [open, setOpen] = useState(false);\n // The trigger's reading direction, read when it is pressed. The panel is portalled to\n // <body>, out of any `dir=\"rtl\"` subtree the trigger sits in, so without carrying it\n // across a right-to-left calendar or filter rendered left-to-right.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const close = () => setOpen(false);\n const toggle = () => {\n setDir(dirOf(triggerRef.current));\n setOpen((v) => !v);\n };\n // prop > `<UiKitProvider labels={{ popover }}>` > English. The provider's\n // `popover.panel` is only the last-resort name for a panel no caller named.\n const text = useKitLabels(\"popover\", DEFAULT_POPOVER_LABELS, labels);\n\n // Align the panel's END edge to the trigger's — right in LTR, left in RTL; it was\n // always `rect.right`, so an RTL panel hung off the wrong side — and clamp it\n // horizontally; the\n // hook owns the vertical half — below by default, flipped above (and height-capped)\n // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom\n // of the screen (feedback #135). It re-measures on scroll/resize.\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n const pos = rect\n ? {\n top: placement.top,\n left: Math.min(\n Math.max(8, dir === \"rtl\" ? rect.left : rect.right - width),\n window.innerWidth - width - 8,\n ),\n }\n : null;\n\n useOutsideClick([triggerRef, panelRef], close, open);\n useEscapeKey(close, open);\n\n useFocusTrap(panelRef, {\n active: open && pos !== null,\n // The panel keeps a focus one of its own children has already taken. `FilterPopover`\n // autofocuses its text box on mount — that is what the press on \"Filter\" was FOR,\n // and it is a prop there precisely so the popover keeps it — and a trap that\n // unconditionally focused the container would take it straight back off again,\n // one effect later and invisibly.\n initialFocus: () => {\n const panel = panelRef.current;\n const active = document.activeElement;\n return panel?.contains(active) ? (active as HTMLElement) : panel;\n },\n });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // `useEscapeKey` above listens on the document and still covers the case where\n // focus somehow is not in here. This handler is for the case that focus IS, which\n // is now the normal one — and it exists to STOP the key, because a popover opened\n // from inside a `Modal` (the calculator on an amount field is exactly that) would\n // otherwise have its Escape travel on up the React tree and close the dialog\n // underneath as well. Closing twice is harmless — `close` is a state setter, not a\n // caller's callback — so the two paths cannot double-fire anything.\n e.stopPropagation();\n close();\n };\n\n return (\n <>\n {trigger({ open, toggle, ref: triggerRef })}\n {open && pos &&\n createPortal(\n <div\n // `...rest` first: the role, the id the trigger's `aria-controls` points at,\n // the `tabIndex` the trap needs and the measured position are what make this\n // a popover, and a caller reaching for a `data-tour` anchor must not be able\n // to remove one of them by accident.\n {...rest}\n ref={panelRef}\n role=\"dialog\"\n id={panelId}\n // The caller's own `aria-label` wins over {@link PopoverLabels.panel}, which\n // is the package's English fallback for an unnamed panel. Both name the same\n // element; the DOM spelling is the one being standardised on, so where both\n // are present it is the one that survives.\n aria-label={ariaLabel ?? text.panel}\n dir={rest.dir ?? dir}\n // Without it the panel cannot take focus at all, and the trap above would\n // silently do nothing on a panel whose children are not yet tabbable.\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n style={{\n // Merged over the caller's, never replaced by it: these four are measured\n // against the trigger every scroll and resize, and a panel that dropped\n // them would paint at the top-left of the viewport.\n ...style,\n position: \"fixed\",\n top: pos.top,\n left: pos.left,\n width,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-3 shadow-lg outline-none\",\n className,\n )}\n >\n {children(close)}\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":";AAuKI,mBAIM,KAJN;AAvKJ,SAAS,QAAQ,gBAAgB;AAEjC,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC,SAAS,cAAc,uBAAuB;AAC9C,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB,SAAS,aAAa;AAEtB,SAAS,oBAAoB;AAatB,MAAM,yBAAwC;AAAA;AAAA;AAAA;AAAA,EAInD,OAAO;AACT;AAwBA,MAAM,gBAAgB;AAwCf,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,GAAG;AACL,GAAiB;AACf,QAAM,aAAa,OAA0B,IAAI;AACjD,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AAItC,QAAM,CAAC,KAAK,MAAM,IAAI,SAAoB,KAAK;AAC/C,QAAM,QAAQ,MAAM,QAAQ,KAAK;AACjC,QAAM,SAAS,MAAM;AACnB,WAAO,MAAM,WAAW,OAAO,CAAC;AAChC,YAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,EACnB;AAGA,QAAM,OAAO,aAAa,WAAW,wBAAwB,MAAM;AAQnE,QAAM,YAAY,iBAAiB,YAAY,IAAI;AACnD,QAAM,OAAO,UAAU;AACvB,QAAM,MAAM,OACR;AAAA,IACE,KAAK,UAAU;AAAA,IACf,MAAM,KAAK;AAAA,MACT,KAAK,IAAI,GAAG,QAAQ,QAAQ,KAAK,OAAO,KAAK,QAAQ,KAAK;AAAA,MAC1D,OAAO,aAAa,QAAQ;AAAA,IAC9B;AAAA,EACF,IACA;AAEJ,kBAAgB,CAAC,YAAY,QAAQ,GAAG,OAAO,IAAI;AACnD,eAAa,OAAO,IAAI;AAExB,eAAa,UAAU;AAAA,IACrB,QAAQ,QAAQ,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMxB,cAAc,MAAM;AAClB,YAAM,QAAQ,SAAS;AACvB,YAAM,SAAS,SAAS;AACxB,aAAO,OAAO,SAAS,MAAM,IAAK,SAAyB;AAAA,IAC7D;AAAA,EACF,CAAC;AAED,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,UAAM;AAAA,EACR;AAEA,SACE,iCACG;AAAA,YAAQ,EAAE,MAAM,QAAQ,KAAK,WAAW,CAAC;AAAA,IACzC,QAAQ,OACP;AAAA,MACE;AAAA,QAAC;AAAA;AAAA,UAKE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAK;AAAA,UACL,IAAI;AAAA,UAKJ,cAAY,aAAa,KAAK;AAAA,UAC9B,KAAK,KAAK,OAAO;AAAA,UAGjB,UAAU;AAAA,UACV,WAAW;AAAA,UACX,OAAO;AAAA;AAAA;AAAA;AAAA,YAIL,GAAG;AAAA,YACH,UAAU;AAAA,YACV,KAAK,IAAI;AAAA,YACT,MAAM,IAAI;AAAA,YACV;AAAA,YACA,WAAW,UAAU;AAAA,UACvB;AAAA,UACA,WAAW;AAAA,YACT;AAAA,YACA;AAAA,UACF;AAAA,UAEC,mBAAS,KAAK;AAAA;AAAA,MACjB;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
@@ -0,0 +1,13 @@
1
+ /** The round step closest to `span / target`: 1, 2 or 5 times a power of ten. */
2
+ declare function niceStep(span: number, target?: number): number;
3
+ /**
4
+ * The round ticks inside `[low, high]`, ascending — or `undefined` when there is no
5
+ * span to divide (no domain, a zero-height one, anything non-finite), which leaves the
6
+ * choice to recharts.
7
+ *
8
+ * Each tick is computed as `k × step` and then rounded to the step's own decimals:
9
+ * accumulating `+= 0.1` would print 0.30000000000000004 on the third tick.
10
+ */
11
+ declare function niceTicks(domain: readonly [number, number] | undefined, target?: number): number[] | undefined;
12
+
13
+ export { niceStep, niceTicks };
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ const TARGET_TICKS = 5;
3
+ function niceStep(span, target = TARGET_TICKS) {
4
+ const rough = span / Math.max(1, target);
5
+ const magnitude = 10 ** Math.floor(Math.log10(rough));
6
+ const norm = rough / magnitude;
7
+ const factor = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10;
8
+ return factor * magnitude;
9
+ }
10
+ function niceTicks(domain, target = TARGET_TICKS) {
11
+ if (!domain) return void 0;
12
+ const [low, high] = domain;
13
+ if (!Number.isFinite(low) || !Number.isFinite(high) || !(high > low)) return void 0;
14
+ const step = niceStep(high - low, target);
15
+ if (!(step > 0) || !Number.isFinite(step)) return void 0;
16
+ const decimals = Math.max(0, -Math.floor(Math.log10(step)));
17
+ const ticks = [];
18
+ const first = Math.ceil(low / step - 1e-6);
19
+ const last = Math.floor(high / step + 1e-6);
20
+ for (let k = first; k <= last; k++) {
21
+ ticks.push(Number((k * step).toFixed(decimals)) + 0);
22
+ }
23
+ return ticks.length ? ticks : void 0;
24
+ }
25
+ export {
26
+ niceStep,
27
+ niceTicks
28
+ };
29
+ //# sourceMappingURL=series-chart-ticks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/series-chart-ticks.ts"],"sourcesContent":["// Round tick values for `SeriesChart` — internal, not re-exported by `@eifi1/ui-kit/chart`.\n//\n// Handed an explicit numeric domain, recharts divides it into equal parts and prints\n// wherever those land: a fitted band of [-11, 811] comes out as -11, 189, 389 … and a\n// padded [89, 1011] as 89, 289, 489 — numbers nobody can put a ruler against. These\n// are the ticks a person would draw instead: every multiple of a 1, 2 or 5 × 10^n step\n// that lies INSIDE the domain. The domain itself is left alone, so the 4 % of air the\n// fit leaves and a zoom window's exact edges both survive; the frame simply does not\n// get a number of its own.\n\n/** Roughly how many ticks to aim for. The 1/2/5 ladder lands anywhere from about half\n * to about twice this, which is the price of every tick being a round number. */\nconst TARGET_TICKS = 5;\n\n/** The round step closest to `span / target`: 1, 2 or 5 times a power of ten. */\nexport function niceStep(span: number, target: number = TARGET_TICKS): number {\n const rough = span / Math.max(1, target);\n const magnitude = 10 ** Math.floor(Math.log10(rough));\n const norm = rough / magnitude;\n const factor = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10;\n return factor * magnitude;\n}\n\n/**\n * The round ticks inside `[low, high]`, ascending — or `undefined` when there is no\n * span to divide (no domain, a zero-height one, anything non-finite), which leaves the\n * choice to recharts.\n *\n * Each tick is computed as `k × step` and then rounded to the step's own decimals:\n * accumulating `+= 0.1` would print 0.30000000000000004 on the third tick.\n */\nexport function niceTicks(\n domain: readonly [number, number] | undefined,\n target: number = TARGET_TICKS,\n): number[] | undefined {\n if (!domain) return undefined;\n const [low, high] = domain;\n if (!Number.isFinite(low) || !Number.isFinite(high) || !(high > low)) return undefined;\n const step = niceStep(high - low, target);\n if (!(step > 0) || !Number.isFinite(step)) return undefined;\n const decimals = Math.max(0, -Math.floor(Math.log10(step)));\n const ticks: number[] = [];\n // Half a step's tolerance per end would admit ticks outside the frame; a millionth\n // of one only forgives the float error in `low / step`.\n const first = Math.ceil(low / step - 1e-6);\n const last = Math.floor(high / step + 1e-6);\n for (let k = first; k <= last; k++) {\n // `+ 0` turns the -0 that `Number(\"-0.00\")` gives back into a plain 0.\n ticks.push(Number((k * step).toFixed(decimals)) + 0);\n }\n return ticks.length ? ticks : undefined;\n}\n"],"mappings":";AAYA,MAAM,eAAe;AAGd,SAAS,SAAS,MAAc,SAAiB,cAAsB;AAC5E,QAAM,QAAQ,OAAO,KAAK,IAAI,GAAG,MAAM;AACvC,QAAM,YAAY,MAAM,KAAK,MAAM,KAAK,MAAM,KAAK,CAAC;AACpD,QAAM,OAAO,QAAQ;AACrB,QAAM,SAAS,OAAO,MAAM,IAAI,OAAO,IAAI,IAAI,OAAO,IAAI,IAAI;AAC9D,SAAO,SAAS;AAClB;AAUO,SAAS,UACd,QACA,SAAiB,cACK;AACtB,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,CAAC,KAAK,IAAI,IAAI;AACpB,MAAI,CAAC,OAAO,SAAS,GAAG,KAAK,CAAC,OAAO,SAAS,IAAI,KAAK,EAAE,OAAO,KAAM,QAAO;AAC7E,QAAM,OAAO,SAAS,OAAO,KAAK,MAAM;AACxC,MAAI,EAAE,OAAO,MAAM,CAAC,OAAO,SAAS,IAAI,EAAG,QAAO;AAClD,QAAM,WAAW,KAAK,IAAI,GAAG,CAAC,KAAK,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC;AAC1D,QAAM,QAAkB,CAAC;AAGzB,QAAM,QAAQ,KAAK,KAAK,MAAM,OAAO,IAAI;AACzC,QAAM,OAAO,KAAK,MAAM,OAAO,OAAO,IAAI;AAC1C,WAAS,IAAI,OAAO,KAAK,MAAM,KAAK;AAElC,UAAM,KAAK,QAAQ,IAAI,MAAM,QAAQ,QAAQ,CAAC,IAAI,CAAC;AAAA,EACrD;AACA,SAAO,MAAM,SAAS,QAAQ;AAChC;","names":[]}
@@ -6,6 +6,7 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart.js";
6
6
  import { DEFAULT_Y_AXIS, withChartZoom } from "./chart-zoom.js";
7
7
  import { STEP_DASH, strokeDash } from "./toggle-legend.js";
8
8
  import { DEFAULT_SERIES_CHART_LABELS } from "./series-chart-labels.js";
9
+ import { niceTicks } from "./series-chart-ticks.js";
9
10
  import { paletteFor } from "../theme/chart-palette.js";
10
11
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
11
12
  import { cn } from "../lib/cn.js";
@@ -113,6 +114,7 @@ function StaticSeriesChart({
113
114
  dataKey: xKey,
114
115
  type: "number",
115
116
  domain: fittedX ?? ["dataMin", "dataMax"],
117
+ ticks: niceTicks(fittedX),
116
118
  allowDataOverflow: zoom?.xDomain !== void 0,
117
119
  tickLine: false,
118
120
  axisLine: false,
@@ -131,33 +133,37 @@ function StaticSeriesChart({
131
133
  )
132
134
  }
133
135
  ),
134
- axes.map((axis) => /* @__PURE__ */ jsx(
135
- YAxis,
136
- {
137
- yAxisId: axis.id,
138
- hide: axis.hide,
139
- domain: fittedY(axis),
140
- allowDataOverflow: zoom?.yDomains[axis.id] !== void 0,
141
- orientation: axis.orientation ?? "left",
142
- width: axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide),
143
- tickLine: false,
144
- axisLine: false,
145
- tickFormatter: axis.format ?? number,
146
- tick: axis.color ? { fill: axis.color } : void 0,
147
- children: axis.title && !axis.hide && /* @__PURE__ */ jsx(
148
- Label,
149
- {
150
- value: axis.title,
151
- angle: axis.orientation === "right" ? 90 : -90,
152
- position: axis.orientation === "right" ? "insideRight" : "insideLeft",
153
- style: { textAnchor: "middle", fill: soleSeriesColor(axis, series) },
154
- offset: AXIS_TITLE_OFFSET,
155
- className: "fill-[var(--text-muted)] text-[11px]"
156
- }
157
- )
158
- },
159
- axis.id
160
- )),
136
+ axes.map((axis) => {
137
+ const domain = fittedY(axis);
138
+ return /* @__PURE__ */ jsx(
139
+ YAxis,
140
+ {
141
+ yAxisId: axis.id,
142
+ hide: axis.hide,
143
+ domain,
144
+ ticks: niceTicks(domain),
145
+ allowDataOverflow: zoom?.yDomains[axis.id] !== void 0,
146
+ orientation: axis.orientation ?? "left",
147
+ width: axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide),
148
+ tickLine: false,
149
+ axisLine: false,
150
+ tickFormatter: axis.format ?? number,
151
+ tick: axis.color ? { fill: axis.color } : void 0,
152
+ children: axis.title && !axis.hide && /* @__PURE__ */ jsx(
153
+ Label,
154
+ {
155
+ value: axis.title,
156
+ angle: axis.orientation === "right" ? 90 : -90,
157
+ position: axis.orientation === "right" ? "insideRight" : "insideLeft",
158
+ style: { textAnchor: "middle", fill: soleSeriesColor(axis, series) },
159
+ offset: AXIS_TITLE_OFFSET,
160
+ className: "fill-[var(--text-muted)] text-[11px]"
161
+ }
162
+ )
163
+ },
164
+ axis.id
165
+ );
166
+ }),
161
167
  /* @__PURE__ */ jsx(
162
168
  ChartTooltip,
163
169
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/series-chart.tsx"],"sourcesContent":["// The measurement plot: series against a swept numeric abscissa, any number of y axes,\n// dash patterns, vertical spans, and drag-to-zoom. Lifted out of lenkbank, where every\n// chart on every screen is this one arrangement of the kit's shell — so the arrangement\n// lives here once instead of as thirteen hand-assembled copies drifting apart.\n//\n// Built on `ChartContainer` + `ChartTooltipContent`, so it themes with the rest of the\n// chart kit: series default to the `--chart-N` ramp, axis text and grid take the\n// shell's tokens. It stays domain-free — every unit, title and number format is the\n// caller's, and the few words it can say itself are the `seriesChart` label namespace.\n//\n// **Every series may keep its own scale.** Position runs in millimetres and jerk in\n// hundreds of thousands of them per second cubed; on one axis the position line is\n// flat against the bottom. Normalising each series to its peak makes the y axis mean\n// nothing, so a caller declares an axis per incomparable channel instead, and may hide\n// all but the ones worth a column of numbers.\n//\n// Charts are drawn left-to-right in every writing direction — an abscissa is a number\n// line, not text — so `orientation` below is physical, and only the HTML chrome (the\n// reset button) is placed by logical side.\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { CartesianGrid, Label, Line, LineChart, ReferenceLine, XAxis, YAxis } from \"recharts\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { DEFAULT_Y_AXIS, withChartZoom, type ZoomBinding } from \"./chart-zoom\";\nimport { STEP_DASH, strokeDash } from \"./toggle-legend\";\nimport { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from \"./series-chart-labels\";\nimport { paletteFor } from \"../theme/chart-palette\";\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\n\nexport interface SeriesChartSeries {\n /** The row key this line reads — and the `--color-<key>` it is painted with, so it\n * must be a CSS identifier. Build keys out of data with {@link seriesKey}. */\n key: string;\n label: ReactNode;\n /** Any CSS colour the chart shell accepts (hex, rgb/hsl, `var(--token)`). Default:\n * the kit's categorical ramp by position, `paletteFor(index)`. */\n color?: string;\n /** Marks the PAIRED line of a pair — the return stroke against the out-stroke, the\n * reference against the measurement. Shorthand for `dash: 1`. */\n dashed?: boolean;\n /** Which of {@link STROKE_PATTERNS} this line takes, for charts where the stroke says\n * WHICH QUANTITY and the colour says WHICH MEASUREMENT. */\n dash?: number;\n /** `stepAfter`, for a whole-number channel that jumps rather than travels — a\n * straight line between index 0 and index 1 draws an index of 0.5, which does not\n * exist. Drawn in the {@link STEP_DASH} pattern, whatever `dash` says. */\n step?: boolean;\n /** Which of `axes` this line is measured on. Default: `\"y\"`, the single one. */\n axis?: string;\n}\n\ninterface SeriesChartAxisShape {\n id: string;\n /**\n * Which side of the plot the ticks stand on. Left unless said otherwise — and said\n * otherwise for the right-hand chart of a FACING pair (see `facingAxes`), whose\n * scales go on the outside so neither column of numbers stands between two pictures\n * being compared.\n */\n orientation?: \"left\" | \"right\";\n /** Tints the ticks — and the title too, when exactly one series is measured on this\n * axis (see {@link soleSeriesColor}). */\n color?: string;\n /** What the TICKS need, in px. The title's strip is added on top. Default 48. */\n width?: number;\n /**\n * Pin the scale instead of fitting it to this chart's data. For a ROW of charts\n * read against each other: auto-fitted, a 200 N loop and a 2000 N one draw the\n * identical picture, and the comparison the layout promises is the one it fails.\n */\n domain?: [number, number];\n /** A tick, formatted — as a BARE number. The unit is said once, in the title; the\n * tooltip gets it from `valueFormat`. Default: `Intl.NumberFormat` in the kit's\n * locale, up to two fraction digits. */\n format?: (value: number) => string;\n}\n\n/**\n * A y axis — and a VISIBLE one says what it measures.\n *\n * A union rather than an optional field, so the rule is the type's: a column of numbers\n * whose quantity and unit are not written down is a column somebody reads as whatever\n * they expected. `title: \"\"` is the explicit opt-out (the right-hand chart of a facing\n * pair, whose neighbour already said it). A HIDDEN axis scales its lines and draws\n * nothing, so there is nowhere for a title to go.\n */\nexport type SeriesChartAxis = SeriesChartAxisShape &\n ({ title: string; hide?: false } | { title?: string; hide: true });\n\nexport interface SeriesChartX {\n /** Row key of the abscissa. Default `\"x\"`, which is what {@link mergeSeries} writes. */\n key?: string;\n /** What the abscissa is, with its unit. Drawn under the axis. */\n title?: string;\n /** A tick, formatted — bare, like the y axes'. */\n format?: (value: number) => string;\n /**\n * Whether this chart prints the abscissa's ticks. On by default; off for every chart\n * of a stack sharing one x window but the bottom one, which prints them for all. It\n * costs no alignment: the x mapping is decided by the margins and the y bands.\n */\n ticks?: boolean;\n /** The tooltip's heading. Default: the abscissa value through `format`. */\n label?: (value: number) => ReactNode;\n}\n\n/**\n * A vertical segment in data coordinates — two y at one x, which a row-per-abscissa\n * chart structurally cannot draw. For CLOSING a shape the series already drew: the\n * turnarounds of a hysteresis loop, where the abscissa stands still while the value\n * falls from one branch to the other.\n */\nexport interface SeriesChartSpan {\n /** Distinct within the chart. */\n key: string;\n /** Which y axis `from`/`to` are measured on. Default `\"y\"`. */\n axis?: string;\n x: number;\n from: number;\n to: number;\n /** Default: `var(--text-muted)`. */\n color?: string;\n}\n\nexport interface SeriesChartProps {\n /** One row per abscissa value. A missing key is a hole (see `connectNulls`). */\n rows: Record<string, number>[];\n series: SeriesChartSeries[];\n /** Default: one untitled axis, `\"y\"`. */\n axes?: SeriesChartAxis[];\n x?: SeriesChartX;\n /** A value in the tooltip, where a number stands on its own and needs its unit.\n * Default: `Intl.NumberFormat` in the kit's locale. */\n valueFormat?: (value: number) => string;\n /** Tailwind height class. Default `h-72`. The empty state takes it too, so a chart\n * losing its last line does not relayout the page under it. */\n height?: string;\n /** Bridge holes left by sources sampled on different grids. See {@link mergeSeries}. */\n connectNulls?: boolean;\n /** Shown, centred at the chart's height, instead of an empty chart. Default: the\n * `seriesChart.empty` label. `null` shows nothing. */\n empty?: ReactNode;\n /**\n * Milliseconds for a line to draw itself in. Off by default: a chart beside a live\n * editor re-renders on every keystroke, and an animation restarted mid-flight reads\n * as the curve twitching. Turn it on where series come and go by choice.\n */\n animationMs?: number;\n spans?: SeriesChartSpan[];\n /** Supplied by `withChartZoom` and by nothing else. */\n zoom?: ZoomBinding;\n /** Per-chart strings over `<UiKitProvider labels={{ seriesChart }}>`. */\n labels?: Partial<SeriesChartLabels>;\n /** BCP 47 tag for the default number formats. Default: the provider's. */\n locale?: string;\n /** Onto the chart's root (`ChartContainer`) — e.g. an app's own grid ink. */\n className?: string;\n}\n\n/** How much of an axis band the rotated title takes, in px. Reserved rather than\n * shared with the ticks: a title merely offset inside the band prints on top of a\n * five-figure tick, and which gives way depends on the data. */\nexport const AXIS_TITLE_STRIP = 16;\n\n/** Where inside its strip the rotated title sits. */\nconst AXIS_TITLE_OFFSET = 10;\n\n/** The width the ticks of a y axis get when a caller does not say. */\nexport const AXIS_TICK_WIDTH = 48;\n\n/**\n * How much air a FITTED axis leaves between the data and its frame, as a fraction of\n * the range. Recharts fits `[dataMin, dataMax]`, which draws a peak half a stroke\n * outside the plot and a curve running to its limit indistinguishable from the frame.\n */\nconst AUTO_PAD = 0.04;\n\n/** The band a fitted axis draws, padded — and a real band for constant data, which is\n * a zero-height domain recharts cannot scale into. `undefined` when nothing finite. */\nexport function paddedDomain(values: Iterable<number>): [number, number] | undefined {\n let low = Infinity;\n let high = -Infinity;\n for (const value of values) {\n if (!Number.isFinite(value)) continue;\n if (value < low) low = value;\n if (value > high) high = value;\n }\n return padBand(low, high);\n}\n\n/**\n * {@link paddedDomain} for a caller that has already walked its own data — so a row\n * of charts can share one scale without building an array to find two numbers in, and\n * without a second padding rule. `low > high` means \"nothing was seen\": `undefined`.\n */\nexport function padBand(low: number, high: number): [number, number] | undefined {\n if (!(low <= high)) return undefined;\n const pad = (high - low) * AUTO_PAD || Math.abs(high) * AUTO_PAD || 1;\n return [low - pad, high + pad];\n}\n\n/** The whole band a y axis occupies: its ticks, and its title when it draws one. For\n * lining a heading up over a PLOT rather than over its column. */\nexport function axisBandWidth(tickWidth: number | undefined, titled: boolean): number {\n return (tickWidth ?? AXIS_TICK_WIDTH) + (titled ? AXIS_TITLE_STRIP : 0);\n}\n\n/**\n * The colour an axis TITLE takes, or `undefined` for the neutral one.\n *\n * An axis measuring exactly one line is that line's, and saying so in the title lets a\n * reader find a channel's scale without counting axes. An axis measuring several is\n * nobody's — a title in the first one's colour would be claiming it. Counted off the\n * series, so toggling a second line onto an axis gives the colour up in the same render.\n */\nexport function soleSeriesColor(\n axis: Pick<SeriesChartAxisShape, \"id\" | \"color\">,\n series: readonly Pick<SeriesChartSeries, \"axis\">[],\n): string | undefined {\n const mine = series.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id);\n return mine.length === 1 ? axis.color : undefined;\n}\n\n/** The single-axis case, which is most charts. `width` is what the TICKS need;\n * `orientation` is which side they stand on. */\nexport function oneAxis(\n format: ((value: number) => string) | undefined,\n title: string,\n width?: number,\n orientation?: \"left\" | \"right\",\n): SeriesChartAxis[] {\n return [{ id: DEFAULT_Y_AXIS, format, title, width, orientation }];\n}\n\n/** One source's samples: an abscissa and the channels measured against it. */\nexport interface SeriesSource {\n x: number[];\n channels: Record<string, number[]>;\n}\n\n/**\n * Merge sources sampled on different grids into rows the chart can draw.\n *\n * Merged ON THE ABSCISSA VALUE ITSELF, never interpolated onto a common grid: a line\n * through invented points looks exactly like one through measured ones. Where a source\n * has no sample the key is absent, and `connectNulls` bridges the hole with the same\n * straight segment an interpolation would have drawn — without claiming a measurement.\n */\nexport function mergeSeries(sources: SeriesSource[]): Record<string, number>[] {\n const byAbscissa = new Map<number, Record<string, number>>();\n for (const source of sources) {\n source.x.forEach((value, index) => {\n const row = byAbscissa.get(value) ?? { x: value };\n for (const [key, channel] of Object.entries(source.channels)) row[key] = channel[index];\n byAbscissa.set(value, row);\n });\n }\n return [...byAbscissa.values()].sort((a, b) => a.x - b.x);\n}\n\n/**\n * A series key built out of parts — as a CSS identifier.\n *\n * The chart shell paints each series through `--color-<key>`, and skips (with a\n * console warning) any key that is not `[A-Za-z_][A-Za-z0-9_-]*`. A line whose colour\n * was skipped has no stroke at all: an axis, a grid and nothing between them. So a key\n * assembled out of DATA — an id, an enum, a side — goes through here: every other\n * character becomes `_`, the parts are joined with `_`, and a key that would start with\n * a digit or a hyphen gets a leading `_`.\n */\nexport function seriesKey(...parts: (string | number)[]): string {\n const key = parts.map((part) => String(part).replace(/[^A-Za-z0-9_-]/g, \"_\")).join(\"_\");\n return /^[A-Za-z_]/.test(key) ? key : `_${key}`;\n}\n\n/** The number format every default falls back to. */\nfunction useDefaultFormat(localeProp: string | undefined): (value: number) => string {\n const locale = useKitLocale(localeProp);\n return useMemo(() => {\n const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });\n return (value: number) => format.format(value);\n }, [locale]);\n}\n\n/** The Y-axis list a chart draws when the caller gave none. */\nconst ONE_UNTITLED_AXIS: SeriesChartAxis[] = [{ id: DEFAULT_Y_AXIS, title: \"\" }];\n\n/**\n * {@link SeriesChart} without the zoom: the same picture, no drag layer, no reset\n * button. For a thumbnail, a print view, or a chart the consumer wraps in its own\n * interaction.\n */\nexport function StaticSeriesChart({\n rows,\n x = {},\n series,\n axes = ONE_UNTITLED_AXIS,\n valueFormat,\n height = \"h-72\",\n connectNulls,\n empty,\n animationMs = 0,\n spans,\n zoom,\n labels: labelsProp,\n locale,\n className,\n}: SeriesChartProps) {\n const labels = useKitLabels(\"seriesChart\", DEFAULT_SERIES_CHART_LABELS, labelsProp);\n const number = useDefaultFormat(locale);\n\n // The empty state takes the chart's own height and is centred in it: a chart that\n // shrank to its \"nothing to draw\" sentence moved everything under it up the page\n // the moment a legend entry was switched off.\n if (!rows.length || !series.length) {\n return (\n <div className={cn(\"flex w-full items-center justify-center px-2 text-center\", height)}>\n {empty !== undefined ? (\n empty\n ) : (\n <p className=\"text-xs text-[var(--text-muted)]\">{labels.empty}</p>\n )}\n </div>\n );\n }\n\n const xKey = x.key ?? \"x\";\n const xFormat = x.format ?? number;\n const config: ChartConfig = Object.fromEntries(\n series.map((entry, index) => [\n entry.key,\n { label: entry.label, color: entry.color ?? paletteFor(index) },\n ]),\n );\n const visible = axes.filter((axis) => !axis.hide);\n // The grid hangs its horizontal rules off ONE y axis, and recharts looks for the one\n // whose id is its own default (`0`). Every axis here is named, so without saying\n // which, it finds none and draws no horizontal rules at all. The first visible axis,\n // because those are the ticks a reader puts a ruler on.\n const gridAxis = (visible[0] ?? axes[0])?.id ?? DEFAULT_Y_AXIS;\n const onLeft = visible.some((axis) => (axis.orientation ?? \"left\") === \"left\");\n const onRight = visible.some((axis) => axis.orientation === \"right\");\n // The margins are what is left once the axes have their bands: an axis band IS the\n // gutter on its side, so a margin beside one is a second gutter. What remains is the\n // overhang of the first/last x tick where no y axis covers it.\n const margin = {\n top: 8,\n right: onRight ? 0 : 10,\n left: onLeft ? 0 : 10,\n // Under the ticks, not under the whole axis: whatever comes next owns the gap.\n bottom: x.title ? 18 : 2,\n };\n\n // Fitted with air around it, unless the reader has zoomed or the caller pinned it.\n const fittedX = zoom?.xDomain ?? paddedDomain(rows.map((row) => row[xKey]));\n const fittedY = (axis: SeriesChartAxis) =>\n zoom?.yDomains[axis.id] ??\n axis.domain ??\n paddedDomain(\n series\n .filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id)\n .flatMap((entry) => rows.map((row) => row[entry.key])),\n );\n\n return (\n <ChartContainer config={config} className={cn(\"w-full\", height, className)}>\n <LineChart data={rows} margin={margin}>\n {/* Both ways: a measurement plot is read by putting a ruler on it, and a\n horizontal-only grid answers half of those questions. */}\n <CartesianGrid yAxisId={gridAxis} />\n <XAxis\n dataKey={xKey}\n // Numeric, not categorical: a measured sweep is unevenly spaced, and a\n // category axis would straighten exactly the curvature the chart is for.\n type=\"number\"\n domain={fittedX ?? [\"dataMin\", \"dataMax\"]}\n // Clip the lines to a zoom window instead of recharts widening it back out.\n allowDataOverflow={zoom?.xDomain !== undefined}\n tickLine={false}\n axisLine={false}\n minTickGap={32}\n tick={x.ticks === false ? false : undefined}\n // With neither ticks nor title there is nothing to reserve the band for, and\n // recharts' own 30 px would leave a gap under every chart of a stack.\n height={x.ticks === false && !x.title ? 4 : undefined}\n tickFormatter={xFormat}\n >\n {x.title && (\n <Label\n value={x.title}\n position=\"insideBottom\"\n offset={-12}\n className=\"fill-[var(--text-muted)] text-[11px]\"\n />\n )}\n </XAxis>\n {axes.map((axis) => (\n <YAxis\n key={axis.id}\n yAxisId={axis.id}\n hide={axis.hide}\n domain={fittedY(axis)}\n allowDataOverflow={zoom?.yDomains[axis.id] !== undefined}\n orientation={axis.orientation ?? \"left\"}\n width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}\n tickLine={false}\n axisLine={false}\n tickFormatter={axis.format ?? number}\n // With an axis per channel the tint is the only thing saying which\n // numbers belong to which line.\n tick={axis.color ? { fill: axis.color } : undefined}\n >\n {axis.title && !axis.hide && (\n <Label\n value={axis.title}\n // Rotated so both sides read upward from the bottom of their axis.\n angle={axis.orientation === \"right\" ? 90 : -90}\n position={axis.orientation === \"right\" ? \"insideRight\" : \"insideLeft\"}\n // Inline, so a sole series' colour beats the class's neutral fill —\n // and absent for a shared axis, so the class draws it.\n style={{ textAnchor: \"middle\", fill: soleSeriesColor(axis, series) }}\n offset={AXIS_TITLE_OFFSET}\n className=\"fill-[var(--text-muted)] text-[11px]\"\n />\n )}\n </YAxis>\n ))}\n <ChartTooltip\n content={\n <ChartTooltipContent\n labelFormatter={(value) => (x.label ?? xFormat)(Number(value))}\n valueFormatter={valueFormat ?? number}\n />\n }\n />\n {series.map((entry) => (\n <Line\n key={entry.key}\n yAxisId={entry.axis ?? DEFAULT_Y_AXIS}\n type={entry.step ? \"stepAfter\" : \"monotone\"}\n dataKey={entry.key}\n stroke={`var(--color-${entry.key})`}\n strokeWidth={entry.step ? 1.5 : 2}\n strokeDasharray={strokeDash(\n entry.step ? STEP_DASH : (entry.dash ?? (entry.dashed ? 1 : 0)),\n )}\n dot={false}\n connectNulls={connectNulls}\n isAnimationActive={animationMs > 0}\n animationDuration={animationMs}\n animationEasing=\"ease-out\"\n />\n ))}\n {spans?.map((span) => (\n <ReferenceLine\n key={span.key}\n yAxisId={span.axis ?? DEFAULT_Y_AXIS}\n segment={[\n { x: span.x, y: span.from },\n { x: span.x, y: span.to },\n ]}\n stroke={span.color ?? \"var(--text-muted)\"}\n strokeWidth={2}\n // Discarded rather than clamped when zoomed past: a span clamped to the\n // window's edge draws a vertical line at an abscissa it never stood at.\n ifOverflow=\"discard\"\n />\n ))}\n {zoom?.layer}\n </LineChart>\n </ChartContainer>\n );\n}\n\n/**\n * The series chart, zoomable. Drag across the plot to zoom (the drag's shape picks the\n * axes), double-click or press the reset button to go back; wrap several in\n * `SharedXZoom` to move their x windows together.\n */\nexport const SeriesChart = withChartZoom(StaticSeriesChart);\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/series-chart.tsx"],"sourcesContent":["// The measurement plot: series against a swept numeric abscissa, any number of y axes,\n// dash patterns, vertical spans, and drag-to-zoom. Lifted out of lenkbank, where every\n// chart on every screen is this one arrangement of the kit's shell — so the arrangement\n// lives here once instead of as thirteen hand-assembled copies drifting apart.\n//\n// Built on `ChartContainer` + `ChartTooltipContent`, so it themes with the rest of the\n// chart kit: series default to the `--chart-N` ramp, axis text and grid take the\n// shell's tokens. It stays domain-free — every unit, title and number format is the\n// caller's, and the few words it can say itself are the `seriesChart` label namespace.\n//\n// **Every series may keep its own scale.** Position runs in millimetres and jerk in\n// hundreds of thousands of them per second cubed; on one axis the position line is\n// flat against the bottom. Normalising each series to its peak makes the y axis mean\n// nothing, so a caller declares an axis per incomparable channel instead, and may hide\n// all but the ones worth a column of numbers.\n//\n// Charts are drawn left-to-right in every writing direction — an abscissa is a number\n// line, not text — so `orientation` below is physical, and only the HTML chrome (the\n// reset button) is placed by logical side.\nimport { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { CartesianGrid, Label, Line, LineChart, ReferenceLine, XAxis, YAxis } from \"recharts\";\nimport { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"./chart\";\nimport { DEFAULT_Y_AXIS, withChartZoom, type ZoomBinding } from \"./chart-zoom\";\nimport { STEP_DASH, strokeDash } from \"./toggle-legend\";\nimport { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from \"./series-chart-labels\";\nimport { niceTicks } from \"./series-chart-ticks\";\nimport { paletteFor } from \"../theme/chart-palette\";\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\n\nexport interface SeriesChartSeries {\n /** The row key this line reads — and the `--color-<key>` it is painted with, so it\n * must be a CSS identifier. Build keys out of data with {@link seriesKey}. */\n key: string;\n label: ReactNode;\n /** Any CSS colour the chart shell accepts (hex, rgb/hsl, `var(--token)`). Default:\n * the kit's categorical ramp by position, `paletteFor(index)`. */\n color?: string;\n /** Marks the PAIRED line of a pair — the return stroke against the out-stroke, the\n * reference against the measurement. Shorthand for `dash: 1`. */\n dashed?: boolean;\n /** Which of {@link STROKE_PATTERNS} this line takes, for charts where the stroke says\n * WHICH QUANTITY and the colour says WHICH MEASUREMENT. */\n dash?: number;\n /** `stepAfter`, for a whole-number channel that jumps rather than travels — a\n * straight line between index 0 and index 1 draws an index of 0.5, which does not\n * exist. Drawn in the {@link STEP_DASH} pattern, whatever `dash` says. */\n step?: boolean;\n /** Which of `axes` this line is measured on. Default: `\"y\"`, the single one. */\n axis?: string;\n}\n\ninterface SeriesChartAxisShape {\n id: string;\n /**\n * Which side of the plot the ticks stand on. Left unless said otherwise — and said\n * otherwise for the right-hand chart of a FACING pair (see `facingAxes`), whose\n * scales go on the outside so neither column of numbers stands between two pictures\n * being compared.\n */\n orientation?: \"left\" | \"right\";\n /** Tints the ticks — and the title too, when exactly one series is measured on this\n * axis (see {@link soleSeriesColor}). */\n color?: string;\n /** What the TICKS need, in px. The title's strip is added on top. Default 48. */\n width?: number;\n /**\n * Pin the scale instead of fitting it to this chart's data. For a ROW of charts\n * read against each other: auto-fitted, a 200 N loop and a 2000 N one draw the\n * identical picture, and the comparison the layout promises is the one it fails.\n */\n domain?: [number, number];\n /** A tick, formatted — as a BARE number. The unit is said once, in the title; the\n * tooltip gets it from `valueFormat`. Default: `Intl.NumberFormat` in the kit's\n * locale, up to two fraction digits. */\n format?: (value: number) => string;\n}\n\n/**\n * A y axis — and a VISIBLE one says what it measures.\n *\n * A union rather than an optional field, so the rule is the type's: a column of numbers\n * whose quantity and unit are not written down is a column somebody reads as whatever\n * they expected. `title: \"\"` is the explicit opt-out (the right-hand chart of a facing\n * pair, whose neighbour already said it). A HIDDEN axis scales its lines and draws\n * nothing, so there is nowhere for a title to go.\n */\nexport type SeriesChartAxis = SeriesChartAxisShape &\n ({ title: string; hide?: false } | { title?: string; hide: true });\n\nexport interface SeriesChartX {\n /** Row key of the abscissa. Default `\"x\"`, which is what {@link mergeSeries} writes. */\n key?: string;\n /** What the abscissa is, with its unit. Drawn under the axis. */\n title?: string;\n /** A tick, formatted — bare, like the y axes'. */\n format?: (value: number) => string;\n /**\n * Whether this chart prints the abscissa's ticks. On by default; off for every chart\n * of a stack sharing one x window but the bottom one, which prints them for all. It\n * costs no alignment: the x mapping is decided by the margins and the y bands.\n */\n ticks?: boolean;\n /** The tooltip's heading. Default: the abscissa value through `format`. */\n label?: (value: number) => ReactNode;\n}\n\n/**\n * A vertical segment in data coordinates — two y at one x, which a row-per-abscissa\n * chart structurally cannot draw. For CLOSING a shape the series already drew: the\n * turnarounds of a hysteresis loop, where the abscissa stands still while the value\n * falls from one branch to the other.\n */\nexport interface SeriesChartSpan {\n /** Distinct within the chart. */\n key: string;\n /** Which y axis `from`/`to` are measured on. Default `\"y\"`. */\n axis?: string;\n x: number;\n from: number;\n to: number;\n /** Default: `var(--text-muted)`. */\n color?: string;\n}\n\nexport interface SeriesChartProps {\n /** One row per abscissa value. A missing key is a hole (see `connectNulls`). */\n rows: Record<string, number>[];\n series: SeriesChartSeries[];\n /** Default: one untitled axis, `\"y\"`. */\n axes?: SeriesChartAxis[];\n x?: SeriesChartX;\n /** A value in the tooltip, where a number stands on its own and needs its unit.\n * Default: `Intl.NumberFormat` in the kit's locale. */\n valueFormat?: (value: number) => string;\n /** Tailwind height class. Default `h-72`. The empty state takes it too, so a chart\n * losing its last line does not relayout the page under it. */\n height?: string;\n /** Bridge holes left by sources sampled on different grids. See {@link mergeSeries}. */\n connectNulls?: boolean;\n /** Shown, centred at the chart's height, instead of an empty chart. Default: the\n * `seriesChart.empty` label. `null` shows nothing. */\n empty?: ReactNode;\n /**\n * Milliseconds for a line to draw itself in. Off by default: a chart beside a live\n * editor re-renders on every keystroke, and an animation restarted mid-flight reads\n * as the curve twitching. Turn it on where series come and go by choice.\n */\n animationMs?: number;\n spans?: SeriesChartSpan[];\n /** Supplied by `withChartZoom` and by nothing else. */\n zoom?: ZoomBinding;\n /** Per-chart strings over `<UiKitProvider labels={{ seriesChart }}>`. */\n labels?: Partial<SeriesChartLabels>;\n /** BCP 47 tag for the default number formats. Default: the provider's. */\n locale?: string;\n /** Onto the chart's root (`ChartContainer`) — e.g. an app's own grid ink. */\n className?: string;\n}\n\n/** How much of an axis band the rotated title takes, in px. Reserved rather than\n * shared with the ticks: a title merely offset inside the band prints on top of a\n * five-figure tick, and which gives way depends on the data. */\nexport const AXIS_TITLE_STRIP = 16;\n\n/** Where inside its strip the rotated title sits. */\nconst AXIS_TITLE_OFFSET = 10;\n\n/** The width the ticks of a y axis get when a caller does not say. */\nexport const AXIS_TICK_WIDTH = 48;\n\n/**\n * How much air a FITTED axis leaves between the data and its frame, as a fraction of\n * the range. Recharts fits `[dataMin, dataMax]`, which draws a peak half a stroke\n * outside the plot and a curve running to its limit indistinguishable from the frame.\n */\nconst AUTO_PAD = 0.04;\n\n/** The band a fitted axis draws, padded — and a real band for constant data, which is\n * a zero-height domain recharts cannot scale into. `undefined` when nothing finite. */\nexport function paddedDomain(values: Iterable<number>): [number, number] | undefined {\n let low = Infinity;\n let high = -Infinity;\n for (const value of values) {\n if (!Number.isFinite(value)) continue;\n if (value < low) low = value;\n if (value > high) high = value;\n }\n return padBand(low, high);\n}\n\n/**\n * {@link paddedDomain} for a caller that has already walked its own data — so a row\n * of charts can share one scale without building an array to find two numbers in, and\n * without a second padding rule. `low > high` means \"nothing was seen\": `undefined`.\n */\nexport function padBand(low: number, high: number): [number, number] | undefined {\n if (!(low <= high)) return undefined;\n const pad = (high - low) * AUTO_PAD || Math.abs(high) * AUTO_PAD || 1;\n return [low - pad, high + pad];\n}\n\n/** The whole band a y axis occupies: its ticks, and its title when it draws one. For\n * lining a heading up over a PLOT rather than over its column. */\nexport function axisBandWidth(tickWidth: number | undefined, titled: boolean): number {\n return (tickWidth ?? AXIS_TICK_WIDTH) + (titled ? AXIS_TITLE_STRIP : 0);\n}\n\n/**\n * The colour an axis TITLE takes, or `undefined` for the neutral one.\n *\n * An axis measuring exactly one line is that line's, and saying so in the title lets a\n * reader find a channel's scale without counting axes. An axis measuring several is\n * nobody's — a title in the first one's colour would be claiming it. Counted off the\n * series, so toggling a second line onto an axis gives the colour up in the same render.\n */\nexport function soleSeriesColor(\n axis: Pick<SeriesChartAxisShape, \"id\" | \"color\">,\n series: readonly Pick<SeriesChartSeries, \"axis\">[],\n): string | undefined {\n const mine = series.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id);\n return mine.length === 1 ? axis.color : undefined;\n}\n\n/** The single-axis case, which is most charts. `width` is what the TICKS need;\n * `orientation` is which side they stand on. */\nexport function oneAxis(\n format: ((value: number) => string) | undefined,\n title: string,\n width?: number,\n orientation?: \"left\" | \"right\",\n): SeriesChartAxis[] {\n return [{ id: DEFAULT_Y_AXIS, format, title, width, orientation }];\n}\n\n/** One source's samples: an abscissa and the channels measured against it. */\nexport interface SeriesSource {\n x: number[];\n channels: Record<string, number[]>;\n}\n\n/**\n * Merge sources sampled on different grids into rows the chart can draw.\n *\n * Merged ON THE ABSCISSA VALUE ITSELF, never interpolated onto a common grid: a line\n * through invented points looks exactly like one through measured ones. Where a source\n * has no sample the key is absent, and `connectNulls` bridges the hole with the same\n * straight segment an interpolation would have drawn — without claiming a measurement.\n */\nexport function mergeSeries(sources: SeriesSource[]): Record<string, number>[] {\n const byAbscissa = new Map<number, Record<string, number>>();\n for (const source of sources) {\n source.x.forEach((value, index) => {\n const row = byAbscissa.get(value) ?? { x: value };\n for (const [key, channel] of Object.entries(source.channels)) row[key] = channel[index];\n byAbscissa.set(value, row);\n });\n }\n return [...byAbscissa.values()].sort((a, b) => a.x - b.x);\n}\n\n/**\n * A series key built out of parts — as a CSS identifier.\n *\n * The chart shell paints each series through `--color-<key>`, and skips (with a\n * console warning) any key that is not `[A-Za-z_][A-Za-z0-9_-]*`. A line whose colour\n * was skipped has no stroke at all: an axis, a grid and nothing between them. So a key\n * assembled out of DATA — an id, an enum, a side — goes through here: every other\n * character becomes `_`, the parts are joined with `_`, and a key that would start with\n * a digit or a hyphen gets a leading `_`.\n */\nexport function seriesKey(...parts: (string | number)[]): string {\n const key = parts.map((part) => String(part).replace(/[^A-Za-z0-9_-]/g, \"_\")).join(\"_\");\n return /^[A-Za-z_]/.test(key) ? key : `_${key}`;\n}\n\n/** The number format every default falls back to. */\nfunction useDefaultFormat(localeProp: string | undefined): (value: number) => string {\n const locale = useKitLocale(localeProp);\n return useMemo(() => {\n const format = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });\n return (value: number) => format.format(value);\n }, [locale]);\n}\n\n/** The Y-axis list a chart draws when the caller gave none. */\nconst ONE_UNTITLED_AXIS: SeriesChartAxis[] = [{ id: DEFAULT_Y_AXIS, title: \"\" }];\n\n/**\n * {@link SeriesChart} without the zoom: the same picture, no drag layer, no reset\n * button. For a thumbnail, a print view, or a chart the consumer wraps in its own\n * interaction.\n */\nexport function StaticSeriesChart({\n rows,\n x = {},\n series,\n axes = ONE_UNTITLED_AXIS,\n valueFormat,\n height = \"h-72\",\n connectNulls,\n empty,\n animationMs = 0,\n spans,\n zoom,\n labels: labelsProp,\n locale,\n className,\n}: SeriesChartProps) {\n const labels = useKitLabels(\"seriesChart\", DEFAULT_SERIES_CHART_LABELS, labelsProp);\n const number = useDefaultFormat(locale);\n\n // The empty state takes the chart's own height and is centred in it: a chart that\n // shrank to its \"nothing to draw\" sentence moved everything under it up the page\n // the moment a legend entry was switched off.\n if (!rows.length || !series.length) {\n return (\n <div className={cn(\"flex w-full items-center justify-center px-2 text-center\", height)}>\n {empty !== undefined ? (\n empty\n ) : (\n <p className=\"text-xs text-[var(--text-muted)]\">{labels.empty}</p>\n )}\n </div>\n );\n }\n\n const xKey = x.key ?? \"x\";\n const xFormat = x.format ?? number;\n const config: ChartConfig = Object.fromEntries(\n series.map((entry, index) => [\n entry.key,\n { label: entry.label, color: entry.color ?? paletteFor(index) },\n ]),\n );\n const visible = axes.filter((axis) => !axis.hide);\n // The grid hangs its horizontal rules off ONE y axis, and recharts looks for the one\n // whose id is its own default (`0`). Every axis here is named, so without saying\n // which, it finds none and draws no horizontal rules at all. The first visible axis,\n // because those are the ticks a reader puts a ruler on.\n const gridAxis = (visible[0] ?? axes[0])?.id ?? DEFAULT_Y_AXIS;\n const onLeft = visible.some((axis) => (axis.orientation ?? \"left\") === \"left\");\n const onRight = visible.some((axis) => axis.orientation === \"right\");\n // The margins are what is left once the axes have their bands: an axis band IS the\n // gutter on its side, so a margin beside one is a second gutter. What remains is the\n // overhang of the first/last x tick where no y axis covers it.\n const margin = {\n top: 8,\n right: onRight ? 0 : 10,\n left: onLeft ? 0 : 10,\n // Under the ticks, not under the whole axis: whatever comes next owns the gap.\n bottom: x.title ? 18 : 2,\n };\n\n // Fitted with air around it, unless the reader has zoomed or the caller pinned it.\n // Ticks are the round values INSIDE whichever domain that is (see\n // `series-chart-ticks.ts`), so a zoom window still gets round numbers, just finer.\n const fittedX = zoom?.xDomain ?? paddedDomain(rows.map((row) => row[xKey]));\n const fittedY = (axis: SeriesChartAxis) =>\n zoom?.yDomains[axis.id] ??\n axis.domain ??\n paddedDomain(\n series\n .filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id)\n .flatMap((entry) => rows.map((row) => row[entry.key])),\n );\n\n return (\n <ChartContainer config={config} className={cn(\"w-full\", height, className)}>\n <LineChart data={rows} margin={margin}>\n {/* Both ways: a measurement plot is read by putting a ruler on it, and a\n horizontal-only grid answers half of those questions. */}\n <CartesianGrid yAxisId={gridAxis} />\n <XAxis\n dataKey={xKey}\n // Numeric, not categorical: a measured sweep is unevenly spaced, and a\n // category axis would straighten exactly the curvature the chart is for.\n type=\"number\"\n domain={fittedX ?? [\"dataMin\", \"dataMax\"]}\n ticks={niceTicks(fittedX)}\n // Clip the lines to a zoom window instead of recharts widening it back out.\n allowDataOverflow={zoom?.xDomain !== undefined}\n tickLine={false}\n axisLine={false}\n minTickGap={32}\n tick={x.ticks === false ? false : undefined}\n // With neither ticks nor title there is nothing to reserve the band for, and\n // recharts' own 30 px would leave a gap under every chart of a stack.\n height={x.ticks === false && !x.title ? 4 : undefined}\n tickFormatter={xFormat}\n >\n {x.title && (\n <Label\n value={x.title}\n position=\"insideBottom\"\n offset={-12}\n className=\"fill-[var(--text-muted)] text-[11px]\"\n />\n )}\n </XAxis>\n {axes.map((axis) => {\n const domain = fittedY(axis);\n return (\n <YAxis\n key={axis.id}\n yAxisId={axis.id}\n hide={axis.hide}\n domain={domain}\n ticks={niceTicks(domain)}\n allowDataOverflow={zoom?.yDomains[axis.id] !== undefined}\n orientation={axis.orientation ?? \"left\"}\n width={axisBandWidth(axis.width, Boolean(axis.title) && !axis.hide)}\n tickLine={false}\n axisLine={false}\n tickFormatter={axis.format ?? number}\n // With an axis per channel the tint is the only thing saying which\n // numbers belong to which line.\n tick={axis.color ? { fill: axis.color } : undefined}\n >\n {axis.title && !axis.hide && (\n <Label\n value={axis.title}\n // Rotated so both sides read upward from the bottom of their axis.\n angle={axis.orientation === \"right\" ? 90 : -90}\n position={axis.orientation === \"right\" ? \"insideRight\" : \"insideLeft\"}\n // Inline, so a sole series' colour beats the class's neutral fill —\n // and absent for a shared axis, so the class draws it.\n style={{ textAnchor: \"middle\", fill: soleSeriesColor(axis, series) }}\n offset={AXIS_TITLE_OFFSET}\n className=\"fill-[var(--text-muted)] text-[11px]\"\n />\n )}\n </YAxis>\n );\n })}\n <ChartTooltip\n content={\n <ChartTooltipContent\n labelFormatter={(value) => (x.label ?? xFormat)(Number(value))}\n valueFormatter={valueFormat ?? number}\n />\n }\n />\n {series.map((entry) => (\n <Line\n key={entry.key}\n yAxisId={entry.axis ?? DEFAULT_Y_AXIS}\n type={entry.step ? \"stepAfter\" : \"monotone\"}\n dataKey={entry.key}\n stroke={`var(--color-${entry.key})`}\n strokeWidth={entry.step ? 1.5 : 2}\n strokeDasharray={strokeDash(\n entry.step ? STEP_DASH : (entry.dash ?? (entry.dashed ? 1 : 0)),\n )}\n dot={false}\n connectNulls={connectNulls}\n isAnimationActive={animationMs > 0}\n animationDuration={animationMs}\n animationEasing=\"ease-out\"\n />\n ))}\n {spans?.map((span) => (\n <ReferenceLine\n key={span.key}\n yAxisId={span.axis ?? DEFAULT_Y_AXIS}\n segment={[\n { x: span.x, y: span.from },\n { x: span.x, y: span.to },\n ]}\n stroke={span.color ?? \"var(--text-muted)\"}\n strokeWidth={2}\n // Discarded rather than clamped when zoomed past: a span clamped to the\n // window's edge draws a vertical line at an abscissa it never stood at.\n ifOverflow=\"discard\"\n />\n ))}\n {zoom?.layer}\n </LineChart>\n </ChartContainer>\n );\n}\n\n/**\n * The series chart, zoomable. Drag across the plot to zoom (the drag's shape picks the\n * axes), double-click or press the reset button to go back; wrap several in\n * `SharedXZoom` to move their x windows together.\n */\nexport const SeriesChart = withChartZoom(StaticSeriesChart);\n"],"mappings":"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","names":[]}
@@ -2,6 +2,8 @@ import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { SelectProps } from './ui.js';
4
4
  import { ThemePreference } from '../theme/theme-store.js';
5
+ import './tooltip.js';
6
+ import '../hooks/use-anchored-rect.js';
5
7
  import 'zustand/middleware';
6
8
  import 'zustand';
7
9
 
@@ -49,6 +49,15 @@ interface SwatchPickerProps<T extends string> extends Omit<ComponentPropsWithout
49
49
  activation?: "automatic" | "manual";
50
50
  /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
51
51
  size?: TileSize;
52
+ /**
53
+ * Classes for every tile's frame, merged after the kit's own — so a caller's
54
+ * `size-*` beats `size`'s. For a tile that has to follow a container query or a
55
+ * breakpoint (keksdose grows its tiles to 40px in a narrow card:
56
+ * `tileClassName="@max-md:size-10"`) without reaching into the kit's markup with an
57
+ * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s
58
+ * dimensions; the tile grows round it.
59
+ */
60
+ tileClassName?: string;
52
61
  disabled?: boolean;
53
62
  labels?: Partial<SwatchPickerLabels>;
54
63
  }
@@ -64,6 +73,6 @@ interface SwatchPickerProps<T extends string> extends Omit<ComponentPropsWithout
64
73
  * choose as they go; Home and End jump to the ends. Left and right follow the page's
65
74
  * direction.
66
75
  */
67
- declare function SwatchPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, size, disabled, labels, className, ...rest }: SwatchPickerProps<T>): react.JSX.Element;
76
+ declare function SwatchPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, size, tileClassName, disabled, labels, className, ...rest }: SwatchPickerProps<T>): react.JSX.Element;
68
77
 
69
78
  export { DEFAULT_SWATCH_PICKER_LABELS, type SwatchOption, SwatchPicker, type SwatchPickerLabels, type SwatchPickerProps };
@@ -16,6 +16,7 @@ function SwatchPicker({
16
16
  mixed = false,
17
17
  activation = "automatic",
18
18
  size = "md",
19
+ tileClassName,
19
20
  disabled = false,
20
21
  labels,
21
22
  className,
@@ -46,6 +47,7 @@ function SwatchPicker({
46
47
  activation,
47
48
  disabled,
48
49
  size,
50
+ tileClassName: tileClassName ? () => tileClassName : void 0,
49
51
  renderTile: (item) => {
50
52
  const opt = item.key === null ? void 0 : byValue.get(item.key);
51
53
  return /* @__PURE__ */ jsx(
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/swatch-picker.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\n\nexport interface SwatchPickerLabels {\n /** The \"no colour\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n}\n\nexport const DEFAULT_SWATCH_PICKER_LABELS: SwatchPickerLabels = {\n none: \"No colour\",\n mixed: \"Mixed: the selected items have different colours\",\n};\n\nexport interface SwatchOption<T extends string> {\n value: T;\n /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an\n * inline background, so it does not have to be a class the kit's CSS knows.\n * Leave it out when `swatchClassName` paints the dot instead. */\n color?: string;\n /** The colour's name, in the user's language. Shown in the bubble and read out;\n * required, because a swatch with no name is a colour only some people can read. */\n label: string;\n /** Classes for the swatch dot, for a palette that lives in classes rather than\n * values (a light/dark pair). An inline `color` would win over a class, so pass\n * one or the other. */\n swatchClassName?: string;\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Groceries\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\n/**\n * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's\n * is omitted rather than shadowed, as on {@link ToggleGroup}.\n */\nexport interface SwatchPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: SwatchOption<T>[];\n /** The chosen colour, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no colour\" tile whose value is `null`. Clearing a colour has to be\n * as reachable as setting one, and a radio cannot be unchecked by clicking it. */\n allowNone?: boolean;\n /**\n * \"Some of each\" — a bulk edit over rows whose colours differ. Nothing is checked\n * (not even \"none\", whatever `value` says), and the group is described as mixed.\n * Distinct from `value={null}`, which is an answer: \"no colour\".\n */\n mixed?: boolean;\n /** See {@link IconPickerProps.activation}. Default \"automatic\". */\n activation?: \"automatic\" | \"manual\";\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n disabled?: boolean;\n labels?: Partial<SwatchPickerLabels>;\n}\n\n/**\n * A row of colour swatches that is one radio group.\n *\n * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading\n * above it. Each swatch is named by its `label` and shows it in a bubble on hover and\n * focus, so the name is not the screen reader's alone.\n *\n * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one\n * while nothing is checked; the arrow keys move between swatches and, by default,\n * choose as they go; Home and End jump to the ends. Left and right follow the page's\n * direction.\n */\nexport function SwatchPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n size = \"md\",\n disabled = false,\n labels,\n className,\n ...rest\n}: SwatchPickerProps<T>) {\n const text = useKitLabels(\"swatchPicker\", DEFAULT_SWATCH_PICKER_LABELS, labels);\n const mixedId = useId();\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n return (\n <div\n {...rest}\n role=\"radiogroup\"\n aria-disabled={disabled || undefined}\n aria-describedby={\n [rest[\"aria-describedby\"], mixed && mixedId].filter(Boolean).join(\" \") || undefined\n }\n className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n renderTile={(item) => {\n const opt = item.key === null ? undefined : byValue.get(item.key);\n return (\n <span\n aria-hidden\n // A hairline round the dot, drawn over whatever colour it is: a pale\n // yellow swatch on a white surface is otherwise a hole in the row.\n className={cn(\n \"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15\",\n TILE_SIZE[size].swatch,\n opt?.swatchClassName,\n )}\n style={{ background: opt?.color }}\n />\n );\n }}\n />\n {/* `hidden`, not `sr-only`: a description is read from hidden text, and this\n way it takes no room in the row. */}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n </div>\n );\n}\n"],"mappings":";AAkGI,SAmBQ,KAnBR;AAlGJ,SAAS,OAAO,eAAe;AAE/B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,WAAW,sBAAsB;AAUnC,MAAM,+BAAmD;AAAA,EAC9D,MAAM;AAAA,EACN,OAAO;AACT;AA4DO,SAAS,aAA+B;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,OAAO;AAAA,EACP,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AACvB,QAAM,OAAO,aAAa,gBAAgB,8BAA8B,MAAM;AAC9E,QAAM,UAAU,MAAM;AACtB,QAAM,UAAU,QAAQ,MAAM,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC;AAClF,QAAM,QAAuB;AAAA,IAC3B,GAAI,YAAY,CAAC,EAAE,KAAK,MAAM,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC;AAAA,IACrD,GAAG,QAAQ,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,UAAU,EAAE,SAAS,EAAE;AAAA,EAC9F;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,MAAK;AAAA,MACL,iBAAe,YAAY;AAAA,MAC3B,oBACE,CAAC,KAAK,kBAAkB,GAAG,SAAS,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,KAAK;AAAA,MAE5E,WAAW,GAAG,uCAAuC,SAAS;AAAA,MAE9D;AAAA;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,SAAS,QAAQ,SAAY;AAAA,YAC7B,UAAU;AAAA,YACV;AAAA,YACA;AAAA,YACA;AAAA,YACA,YAAY,CAAC,SAAS;AACpB,oBAAM,MAAM,KAAK,QAAQ,OAAO,SAAY,QAAQ,IAAI,KAAK,GAAG;AAChE,qBACE;AAAA,gBAAC;AAAA;AAAA,kBACC,eAAW;AAAA,kBAGX,WAAW;AAAA,oBACT;AAAA,oBACA,UAAU,IAAI,EAAE;AAAA,oBAChB,KAAK;AAAA,kBACP;AAAA,kBACA,OAAO,EAAE,YAAY,KAAK,MAAM;AAAA;AAAA,cAClC;AAAA,YAEJ;AAAA;AAAA,QACF;AAAA,QAGC,SACC,oBAAC,UAAK,IAAI,SAAS,QAAM,MACtB,eAAK,OACR;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/swatch-picker.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\n\nexport interface SwatchPickerLabels {\n /** The \"no colour\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n}\n\nexport const DEFAULT_SWATCH_PICKER_LABELS: SwatchPickerLabels = {\n none: \"No colour\",\n mixed: \"Mixed: the selected items have different colours\",\n};\n\nexport interface SwatchOption<T extends string> {\n value: T;\n /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an\n * inline background, so it does not have to be a class the kit's CSS knows.\n * Leave it out when `swatchClassName` paints the dot instead. */\n color?: string;\n /** The colour's name, in the user's language. Shown in the bubble and read out;\n * required, because a swatch with no name is a colour only some people can read. */\n label: string;\n /** Classes for the swatch dot, for a palette that lives in classes rather than\n * values (a light/dark pair). An inline `color` would win over a class, so pass\n * one or the other. */\n swatchClassName?: string;\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Groceries\"). The tile wears a dot while it has one. */\n note?: string;\n disabled?: boolean;\n}\n\n/**\n * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's\n * is omitted rather than shadowed, as on {@link ToggleGroup}.\n */\nexport interface SwatchPickerProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: SwatchOption<T>[];\n /** The chosen colour, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no colour\" tile whose value is `null`. Clearing a colour has to be\n * as reachable as setting one, and a radio cannot be unchecked by clicking it. */\n allowNone?: boolean;\n /**\n * \"Some of each\" — a bulk edit over rows whose colours differ. Nothing is checked\n * (not even \"none\", whatever `value` says), and the group is described as mixed.\n * Distinct from `value={null}`, which is an answer: \"no colour\".\n */\n mixed?: boolean;\n /** See {@link IconPickerProps.activation}. Default \"automatic\". */\n activation?: \"automatic\" | \"manual\";\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n /**\n * Classes for every tile's frame, merged after the kit's own — so a caller's\n * `size-*` beats `size`'s. For a tile that has to follow a container query or a\n * breakpoint (keksdose grows its tiles to 40px in a narrow card:\n * `tileClassName=\"@max-md:size-10\"`) without reaching into the kit's markup with an\n * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s\n * dimensions; the tile grows round it.\n */\n tileClassName?: string;\n disabled?: boolean;\n labels?: Partial<SwatchPickerLabels>;\n}\n\n/**\n * A row of colour swatches that is one radio group.\n *\n * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading\n * above it. Each swatch is named by its `label` and shows it in a bubble on hover and\n * focus, so the name is not the screen reader's alone.\n *\n * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one\n * while nothing is checked; the arrow keys move between swatches and, by default,\n * choose as they go; Home and End jump to the ends. Left and right follow the page's\n * direction.\n */\nexport function SwatchPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n size = \"md\",\n tileClassName,\n disabled = false,\n labels,\n className,\n ...rest\n}: SwatchPickerProps<T>) {\n const text = useKitLabels(\"swatchPicker\", DEFAULT_SWATCH_PICKER_LABELS, labels);\n const mixedId = useId();\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),\n ];\n return (\n <div\n {...rest}\n role=\"radiogroup\"\n aria-disabled={disabled || undefined}\n aria-describedby={\n [rest[\"aria-describedby\"], mixed && mixedId].filter(Boolean).join(\" \") || undefined\n }\n className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n >\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n onSelect={onChange}\n activation={activation}\n disabled={disabled}\n size={size}\n tileClassName={tileClassName ? () => tileClassName : undefined}\n renderTile={(item) => {\n const opt = item.key === null ? undefined : byValue.get(item.key);\n return (\n <span\n aria-hidden\n // A hairline round the dot, drawn over whatever colour it is: a pale\n // yellow swatch on a white surface is otherwise a hole in the row.\n className={cn(\n \"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15\",\n TILE_SIZE[size].swatch,\n opt?.swatchClassName,\n )}\n style={{ background: opt?.color }}\n />\n );\n }}\n />\n {/* `hidden`, not `sr-only`: a description is read from hidden text, and this\n way it takes no room in the row. */}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n </div>\n );\n}\n"],"mappings":";AA4GI,SAoBQ,KApBR;AA5GJ,SAAS,OAAO,eAAe;AAE/B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,WAAW,sBAAsB;AAUnC,MAAM,+BAAmD;AAAA,EAC9D,MAAM;AAAA,EACN,OAAO;AACT;AAqEO,SAAS,aAA+B;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,OAAO;AAAA,EACP;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AACvB,QAAM,OAAO,aAAa,gBAAgB,8BAA8B,MAAM;AAC9E,QAAM,UAAU,MAAM;AACtB,QAAM,UAAU,QAAQ,MAAM,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC;AAClF,QAAM,QAAuB;AAAA,IAC3B,GAAI,YAAY,CAAC,EAAE,KAAK,MAAM,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC;AAAA,IACrD,GAAG,QAAQ,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,UAAU,EAAE,SAAS,EAAE;AAAA,EAC9F;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,MAAK;AAAA,MACL,iBAAe,YAAY;AAAA,MAC3B,oBACE,CAAC,KAAK,kBAAkB,GAAG,SAAS,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,KAAK;AAAA,MAE5E,WAAW,GAAG,uCAAuC,SAAS;AAAA,MAE9D;AAAA;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,SAAS,QAAQ,SAAY;AAAA,YAC7B,UAAU;AAAA,YACV;AAAA,YACA;AAAA,YACA;AAAA,YACA,eAAe,gBAAgB,MAAM,gBAAgB;AAAA,YACrD,YAAY,CAAC,SAAS;AACpB,oBAAM,MAAM,KAAK,QAAQ,OAAO,SAAY,QAAQ,IAAI,KAAK,GAAG;AAChE,qBACE;AAAA,gBAAC;AAAA;AAAA,kBACC,eAAW;AAAA,kBAGX,WAAW;AAAA,oBACT;AAAA,oBACA,UAAU,IAAI,EAAE;AAAA,oBAChB,KAAK;AAAA,kBACP;AAAA,kBACA,OAAO,EAAE,YAAY,KAAK,MAAM;AAAA;AAAA,cAClC;AAAA,YAEJ;AAAA;AAAA,QACF;AAAA,QAGC,SACC,oBAAC,UAAK,IAAI,SAAS,QAAM,MACtB,eAAK,OACR;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
@@ -20,10 +20,19 @@ interface SwipeAction {
20
20
  /** Background utility class once the drag has passed this action's threshold. */
21
21
  armedClassName: string;
22
22
  }
23
+ /**
24
+ * `right`/`left` are PHYSICAL drag directions in every writing direction, and stay so on
25
+ * purpose: a swipe is a movement of the finger across the glass, the gesture hook
26
+ * measures it in screen pixels, and "which way is destructive" is an app decision that
27
+ * RTL guidelines do not agree on. An app that wants mirrored actions in RTL swaps the
28
+ * two arrays itself. What the component does mirror is everything it lays out: the
29
+ * reveal panel's contents hug the physical edge being uncovered, and the keyboard
30
+ * buttons sit at the row's logical END.
31
+ */
23
32
  interface SwipeableRowProps {
24
- /** Actions committed by dragging RIGHT, nearest threshold first. */
33
+ /** Actions committed by dragging (physically) RIGHT, nearest threshold first. */
25
34
  right?: SwipeAction[];
26
- /** Actions committed by dragging LEFT, nearest threshold first. */
35
+ /** Actions committed by dragging (physically) LEFT, nearest threshold first. */
27
36
  left?: SwipeAction[];
28
37
  /** Turn the gesture off — an open editor, a pending mutation, a locked row. This
29
38
  * also withdraws the keyboard buttons below: a row that must not be acted on must
@@ -68,8 +68,10 @@ function SwipeableRow({
68
68
  armed ? "opacity-100" : "opacity-60",
69
69
  armed ? shown.armedClassName : shown.className,
70
70
  // The panel is anchored to the edge the row is uncovering, so the label
71
- // sits where the eye already is rather than across the screen.
72
- draggingLeft && "flex-row-reverse"
71
+ // sits where the eye already is rather than across the screen. That edge is
72
+ // physical, while a flex row starts at the INLINE start — so in RTL the two
73
+ // directions swap, or the label sat under the row on the covered side.
74
+ draggingLeft ? "flex-row-reverse rtl:flex-row" : "rtl:flex-row-reverse"
73
75
  ),
74
76
  children: [
75
77
  shown.icon && /* @__PURE__ */ jsx(
@@ -118,7 +120,7 @@ function SwipeableRow({
118
120
  {
119
121
  role: "group",
120
122
  "aria-label": labels.actions,
121
- className: "pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1 pr-2",
123
+ className: "pointer-events-none absolute inset-y-0 end-0 z-10 flex items-center gap-1 pe-2",
122
124
  children: [...rightActions, ...leftActions].map((action, i) => /* @__PURE__ */ jsxs(
123
125
  "button",
124
126
  {