@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/swipeable-row.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useRowSwipe, type SwipeStage } from \"../hooks/use-row-swipe\";\nimport { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * One armed swipe action: what it does, and how the reveal panel presents it.\n *\n * The two background classes are the idle and armed states. Before the first\n * threshold is crossed the panel previews the nearest action in its IDLE colour, so\n * the user can see what a little more drag will do; crossing the threshold flips it to\n * `armedClassName`. Passing the same value for both simply removes that feedback.\n */\nexport interface SwipeAction {\n onCommit: () => void;\n label: string;\n /** Rendered inside the panel. Icons stay the consumer's choice — this package\n * ships no opinion about which icon set an app uses. */\n icon?: ReactNode;\n /** Background utility class while the action is previewed but not yet armed. */\n className: string;\n /** Background utility class once the drag has passed this action's threshold. */\n armedClassName: string;\n}\n\nexport interface SwipeableRowProps {\n /** Actions committed by dragging RIGHT, nearest threshold first. */\n right?: SwipeAction[];\n /** Actions committed by dragging LEFT, nearest threshold first. */\n left?: SwipeAction[];\n /** Turn the gesture off — an open editor, a pending mutation, a locked row. This\n * also withdraws the keyboard buttons below: a row that must not be acted on must\n * not be actionable by ANY input. */\n enabled?: boolean;\n /** Names the revealed action buttons as a set (\"Row actions\"), for a reader that\n * reaches the group before it reaches any one button. Default:\n * `swipeableRow.actions` from the {@link UiKitProvider}, else English. */\n actionsLabel?: string;\n className?: string;\n children: ReactNode;\n}\n\n/** How much of the row stays on screen at full drag. A row that can be pulled\n * entirely off its own track reads as already deleted, and there is nothing left to\n * drag back; a sliver keeps the gesture reversible and the row identifiable. */\nconst PEEK_PX = 40;\n\n/**\n * A row whose horizontal drag reveals actions underneath it.\n *\n * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be\n * two half-width blocks side by side, which capped the drag at half the row so the\n * opposite panel could never be exposed — and on a row bound to three actions that\n * left ~55px between stages on a phone, close enough that the wrong one committed.\n * Only the side being dragged toward is painted now, so full width costs nothing:\n * at rest the content covers both anyway, and a drag only ever reveals one side.\n *\n * Thresholds are still not fixed pixel values — they are spread evenly across the\n * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed\n * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every\n * action reachable and roughly doubles the room between them.\n *\n * **Whether the action will fire is stated three ways, not one** (feedback #174): the\n * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the\n * label turns bold — plus the existing haptic tick per newly armed stage. A colour\n * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot\n * distinguish the two tones.\n *\n * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe\n * would also fire whatever click handler the row content carries (expanding it, opening\n * a dialog) on top of the action it just committed.\n *\n * ## The keyboard path is buttons, and deliberately not a typed gesture\n *\n * Every action here used to live exclusively inside a pointer drag — `onCommit` had no\n * other caller — so with a keyboard, with a screen reader, or on any input that cannot\n * express 120px of horizontal travel, the row's actions did not exist at all. That is\n * not a rough edge on a phone control: {@link DataTable} mounts this for every row of\n * its mobile layout, and the actions an app puts here are its destructive ones.\n *\n * The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that\n * \"swipes\". That invents a private idiom with nothing to discover it by, and it makes\n * the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.\n * So each action is ALSO a real button, `sr-only` until it takes focus and then shown at\n * the row's trailing edge, firing the identical `onCommit`: one definition of what an\n * action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from\n * the eye and not from the accessibility tree, so the row also ANNOUNCES what can be\n * done to it rather than keeping it behind a gesture nobody can hear.\n */\nexport function SwipeableRow({\n right,\n left,\n enabled = true,\n actionsLabel,\n className,\n children,\n}: SwipeableRowProps) {\n const labels = useKitLabels(\"swipeableRow\", DEFAULT_SWIPEABLE_ROW_LABELS, {\n actions: actionsLabel,\n });\n const rowRef = useRef<HTMLDivElement>(null);\n const [rowWidth, setRowWidth] = useState(0);\n useEffect(() => {\n const el = rowRef.current;\n if (!el) return;\n setRowWidth(el.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver((entries) => {\n for (const e of entries) setRowWidth(e.contentRect.width);\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const rightActions = right ?? [];\n const leftActions = left ?? [];\n const active = enabled && (rightActions.length > 0 || leftActions.length > 0);\n // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a\n // very narrow row (or one measured before layout) from producing a zero or\n // negative drag, which would arm every stage at once.\n const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;\n const spread = (actions: SwipeAction[]): SwipeStage[] =>\n actions.map((a, i) => ({\n threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),\n onCommit: a.onCommit,\n }));\n\n const swipe = useRowSwipe({\n enabled: active,\n rightStages: spread(rightActions),\n leftStages: spread(leftActions),\n maxDrag,\n });\n\n // A haptic tick per newly armed threshold, so each stage is felt and not only seen.\n const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;\n const prevArmed = useRef(-1);\n useEffect(() => {\n if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);\n prevArmed.current = armedNow;\n }, [armedNow]);\n\n // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.\n const dx = swipe.dx;\n // Only the side being dragged toward is painted, which is what makes a full-width\n // panel safe.\n //\n // The side has to SURVIVE the release, though, and that is what this ref is for\n // (Keksdose feedback dev#467): *\"Swiping a category to the left with no option — if\n // letting it go it gets, for the way back, the background color of the opposite\n // swipe direction where there should be no color change at all.\"* On release dx\n // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read\n // \"right\" for exactly the frames where the left side was still uncovered — and\n // painted the RIGHT action's colour into the gap. With no left action at all that\n // colour was the only thing the gesture ever showed.\n //\n // Written during render rather than in an effect: an effect would repaint a frame\n // later (visibly, at 150ms) and `setState` in an effect body is an eslint error in\n // the consuming app. The value is derived from this render's own dx, so a\n // concurrent re-render recomputes it identically.\n const lastSide = useRef<\"left\" | \"right\">(\"right\");\n if (dx !== 0) lastSide.current = dx < 0 ? \"left\" : \"right\";\n const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === \"left\";\n const shown = draggingLeft\n ? leftActions[Math.max(0, swipe.armedLeftIndex)]\n : rightActions[Math.max(0, swipe.armedRightIndex)];\n const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;\n\n return (\n <div ref={rowRef} className={cn(\"relative overflow-hidden\", className)}>\n {active && shown && (\n <div\n className={cn(\n \"pointer-events-none absolute inset-0 flex items-center gap-2 px-4\",\n \"text-xs font-medium text-white transition-all\",\n // Dimmed until the drag has actually passed a threshold: \"nothing will\n // happen yet\" has to look different from \"let go and this fires\", and on\n // a moving row a one-step colour change was too quiet to notice.\n armed ? \"opacity-100\" : \"opacity-60\",\n armed ? shown.armedClassName : shown.className,\n // The panel is anchored to the edge the row is uncovering, so the label\n // sits where the eye already is rather than across the screen.\n draggingLeft && \"flex-row-reverse\",\n )}\n >\n {shown.icon && (\n <span\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-full transition-all\",\n // Armed, the icon gets a disc of its own and grows — a second,\n // non-colour signal, and the one that stays readable at a glance.\n armed ? \"size-8 scale-110 bg-white/25\" : \"size-7 bg-white/10\",\n )}\n >\n {shown.icon}\n </span>\n )}\n <span className={cn(\"truncate transition-all\", armed && \"text-sm font-semibold\")}>\n {shown.label}\n </span>\n </div>\n )}\n <div\n // Capture phase: the row's own click handler lives on a DESCENDANT, so a\n // bubble-phase listener here would run after it — too late to suppress.\n onClickCapture={(e) => {\n if (!swipe.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }}\n className={cn(\n \"relative bg-[var(--bg-surface)]\",\n // A mouse drag is a supported way to swipe (use-row-swipe gates only on\n // the button, not the pointer type), and a horizontal mouse drag over\n // text also SELECTS that text — so committing an action left the row\n // highlighted blue. Suppressed only while dragging, so text in a row at\n // rest stays selectable.\n swipe.dragging ? \"select-none\" : \"transition-transform duration-150\",\n )}\n style={{\n transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,\n touchAction: active ? \"pan-y\" : undefined,\n }}\n {...(active ? swipe.handlers : {})}\n >\n {children}\n </div>\n {active && (\n <div\n role=\"group\"\n aria-label={labels.actions}\n // OUTSIDE the sliding div on purpose: an action that travels with the row it\n // acts on is a moving target, and the drag transform would carry it off the\n // screen mid-gesture.\n //\n // `pointer-events-none` here, restored on a button only once it HAS focus: a\n // sr-only button is clipped to nothing but is still a hit target, and this\n // strip sits at the row's right edge — exactly where a leftward drag begins.\n // A gesture that silently failed to start because it landed on an invisible\n // button is not a bug anyone would find by looking at the screen.\n className=\"pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1 pr-2\"\n >\n {/* Both sides, each still nearest-threshold first, so the tab order reads in\n the order the gesture arms them. */}\n {[...rightActions, ...leftActions].map((action, i) => (\n <button\n key={`${action.label}-${i}`}\n type=\"button\"\n onClick={action.onCommit}\n className={cn(\n \"sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm\",\n \"focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5\",\n \"focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n )}\n >\n {/* The label is the button's accessible name, so the caller's icon is\n decoration on top of it rather than a second reading of it. */}\n {action.icon && (\n <span aria-hidden=\"true\" className=\"[&_svg]:size-4\">\n {action.icon}\n </span>\n )}\n {action.label}\n </button>\n ))}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/swipeable-row.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useRowSwipe, type SwipeStage } from \"../hooks/use-row-swipe\";\nimport { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * One armed swipe action: what it does, and how the reveal panel presents it.\n *\n * The two background classes are the idle and armed states. Before the first\n * threshold is crossed the panel previews the nearest action in its IDLE colour, so\n * the user can see what a little more drag will do; crossing the threshold flips it to\n * `armedClassName`. Passing the same value for both simply removes that feedback.\n */\nexport interface SwipeAction {\n onCommit: () => void;\n label: string;\n /** Rendered inside the panel. Icons stay the consumer's choice — this package\n * ships no opinion about which icon set an app uses. */\n icon?: ReactNode;\n /** Background utility class while the action is previewed but not yet armed. */\n className: string;\n /** Background utility class once the drag has passed this action's threshold. */\n armedClassName: string;\n}\n\n/**\n * `right`/`left` are PHYSICAL drag directions in every writing direction, and stay so on\n * purpose: a swipe is a movement of the finger across the glass, the gesture hook\n * measures it in screen pixels, and \"which way is destructive\" is an app decision that\n * RTL guidelines do not agree on. An app that wants mirrored actions in RTL swaps the\n * two arrays itself. What the component does mirror is everything it lays out: the\n * reveal panel's contents hug the physical edge being uncovered, and the keyboard\n * buttons sit at the row's logical END.\n */\nexport interface SwipeableRowProps {\n /** Actions committed by dragging (physically) RIGHT, nearest threshold first. */\n right?: SwipeAction[];\n /** Actions committed by dragging (physically) LEFT, nearest threshold first. */\n left?: SwipeAction[];\n /** Turn the gesture off — an open editor, a pending mutation, a locked row. This\n * also withdraws the keyboard buttons below: a row that must not be acted on must\n * not be actionable by ANY input. */\n enabled?: boolean;\n /** Names the revealed action buttons as a set (\"Row actions\"), for a reader that\n * reaches the group before it reaches any one button. Default:\n * `swipeableRow.actions` from the {@link UiKitProvider}, else English. */\n actionsLabel?: string;\n className?: string;\n children: ReactNode;\n}\n\n/** How much of the row stays on screen at full drag. A row that can be pulled\n * entirely off its own track reads as already deleted, and there is nothing left to\n * drag back; a sliver keeps the gesture reversible and the row identifiable. */\nconst PEEK_PX = 40;\n\n/**\n * A row whose horizontal drag reveals actions underneath it.\n *\n * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be\n * two half-width blocks side by side, which capped the drag at half the row so the\n * opposite panel could never be exposed — and on a row bound to three actions that\n * left ~55px between stages on a phone, close enough that the wrong one committed.\n * Only the side being dragged toward is painted now, so full width costs nothing:\n * at rest the content covers both anyway, and a drag only ever reveals one side.\n *\n * Thresholds are still not fixed pixel values — they are spread evenly across the\n * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed\n * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every\n * action reachable and roughly doubles the room between them.\n *\n * **Whether the action will fire is stated three ways, not one** (feedback #174): the\n * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the\n * label turns bold — plus the existing haptic tick per newly armed stage. A colour\n * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot\n * distinguish the two tones.\n *\n * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe\n * would also fire whatever click handler the row content carries (expanding it, opening\n * a dialog) on top of the action it just committed.\n *\n * ## The keyboard path is buttons, and deliberately not a typed gesture\n *\n * Every action here used to live exclusively inside a pointer drag — `onCommit` had no\n * other caller — so with a keyboard, with a screen reader, or on any input that cannot\n * express 120px of horizontal travel, the row's actions did not exist at all. That is\n * not a rough edge on a phone control: {@link DataTable} mounts this for every row of\n * its mobile layout, and the actions an app puts here are its destructive ones.\n *\n * The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that\n * \"swipes\". That invents a private idiom with nothing to discover it by, and it makes\n * the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.\n * So each action is ALSO a real button, `sr-only` until it takes focus and then shown at\n * the row's trailing edge, firing the identical `onCommit`: one definition of what an\n * action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from\n * the eye and not from the accessibility tree, so the row also ANNOUNCES what can be\n * done to it rather than keeping it behind a gesture nobody can hear.\n */\nexport function SwipeableRow({\n right,\n left,\n enabled = true,\n actionsLabel,\n className,\n children,\n}: SwipeableRowProps) {\n const labels = useKitLabels(\"swipeableRow\", DEFAULT_SWIPEABLE_ROW_LABELS, {\n actions: actionsLabel,\n });\n const rowRef = useRef<HTMLDivElement>(null);\n const [rowWidth, setRowWidth] = useState(0);\n useEffect(() => {\n const el = rowRef.current;\n if (!el) return;\n setRowWidth(el.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver((entries) => {\n for (const e of entries) setRowWidth(e.contentRect.width);\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const rightActions = right ?? [];\n const leftActions = left ?? [];\n const active = enabled && (rightActions.length > 0 || leftActions.length > 0);\n // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a\n // very narrow row (or one measured before layout) from producing a zero or\n // negative drag, which would arm every stage at once.\n const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;\n const spread = (actions: SwipeAction[]): SwipeStage[] =>\n actions.map((a, i) => ({\n threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),\n onCommit: a.onCommit,\n }));\n\n const swipe = useRowSwipe({\n enabled: active,\n rightStages: spread(rightActions),\n leftStages: spread(leftActions),\n maxDrag,\n });\n\n // A haptic tick per newly armed threshold, so each stage is felt and not only seen.\n const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;\n const prevArmed = useRef(-1);\n useEffect(() => {\n if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);\n prevArmed.current = armedNow;\n }, [armedNow]);\n\n // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.\n const dx = swipe.dx;\n // Only the side being dragged toward is painted, which is what makes a full-width\n // panel safe.\n //\n // The side has to SURVIVE the release, though, and that is what this ref is for\n // (Keksdose feedback dev#467): *\"Swiping a category to the left with no option — if\n // letting it go it gets, for the way back, the background color of the opposite\n // swipe direction where there should be no color change at all.\"* On release dx\n // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read\n // \"right\" for exactly the frames where the left side was still uncovered — and\n // painted the RIGHT action's colour into the gap. With no left action at all that\n // colour was the only thing the gesture ever showed.\n //\n // Written during render rather than in an effect: an effect would repaint a frame\n // later (visibly, at 150ms) and `setState` in an effect body is an eslint error in\n // the consuming app. The value is derived from this render's own dx, so a\n // concurrent re-render recomputes it identically.\n const lastSide = useRef<\"left\" | \"right\">(\"right\");\n if (dx !== 0) lastSide.current = dx < 0 ? \"left\" : \"right\";\n const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === \"left\";\n const shown = draggingLeft\n ? leftActions[Math.max(0, swipe.armedLeftIndex)]\n : rightActions[Math.max(0, swipe.armedRightIndex)];\n const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;\n\n return (\n <div ref={rowRef} className={cn(\"relative overflow-hidden\", className)}>\n {active && shown && (\n <div\n className={cn(\n \"pointer-events-none absolute inset-0 flex items-center gap-2 px-4\",\n \"text-xs font-medium text-white transition-all\",\n // Dimmed until the drag has actually passed a threshold: \"nothing will\n // happen yet\" has to look different from \"let go and this fires\", and on\n // a moving row a one-step colour change was too quiet to notice.\n armed ? \"opacity-100\" : \"opacity-60\",\n armed ? shown.armedClassName : shown.className,\n // The panel is anchored to the edge the row is uncovering, so the label\n // sits where the eye already is rather than across the screen. That edge is\n // physical, while a flex row starts at the INLINE start — so in RTL the two\n // directions swap, or the label sat under the row on the covered side.\n draggingLeft ? \"flex-row-reverse rtl:flex-row\" : \"rtl:flex-row-reverse\",\n )}\n >\n {shown.icon && (\n <span\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-full transition-all\",\n // Armed, the icon gets a disc of its own and grows — a second,\n // non-colour signal, and the one that stays readable at a glance.\n armed ? \"size-8 scale-110 bg-white/25\" : \"size-7 bg-white/10\",\n )}\n >\n {shown.icon}\n </span>\n )}\n <span className={cn(\"truncate transition-all\", armed && \"text-sm font-semibold\")}>\n {shown.label}\n </span>\n </div>\n )}\n <div\n // Capture phase: the row's own click handler lives on a DESCENDANT, so a\n // bubble-phase listener here would run after it — too late to suppress.\n onClickCapture={(e) => {\n if (!swipe.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }}\n className={cn(\n \"relative bg-[var(--bg-surface)]\",\n // A mouse drag is a supported way to swipe (use-row-swipe gates only on\n // the button, not the pointer type), and a horizontal mouse drag over\n // text also SELECTS that text — so committing an action left the row\n // highlighted blue. Suppressed only while dragging, so text in a row at\n // rest stays selectable.\n swipe.dragging ? \"select-none\" : \"transition-transform duration-150\",\n )}\n style={{\n transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,\n touchAction: active ? \"pan-y\" : undefined,\n }}\n {...(active ? swipe.handlers : {})}\n >\n {children}\n </div>\n {active && (\n <div\n role=\"group\"\n aria-label={labels.actions}\n // OUTSIDE the sliding div on purpose: an action that travels with the row it\n // acts on is a moving target, and the drag transform would carry it off the\n // screen mid-gesture.\n //\n // `pointer-events-none` here, restored on a button only once it HAS focus: a\n // sr-only button is clipped to nothing but is still a hit target, and this\n // strip sits at the row's trailing edge — exactly where a drag toward the\n // start begins.\n //\n // `end-0`/`pe-2`, not `right-0`/`pr-2`: in RTL the right edge is where the\n // row's title starts, and a focused button there covered it.\n // A gesture that silently failed to start because it landed on an invisible\n // button is not a bug anyone would find by looking at the screen.\n className=\"pointer-events-none absolute inset-y-0 end-0 z-10 flex items-center gap-1 pe-2\"\n >\n {/* Both sides, each still nearest-threshold first, so the tab order reads in\n the order the gesture arms them. */}\n {[...rightActions, ...leftActions].map((action, i) => (\n <button\n key={`${action.label}-${i}`}\n type=\"button\"\n onClick={action.onCommit}\n className={cn(\n \"sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm\",\n \"focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5\",\n \"focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n )}\n >\n {/* The label is the button's accessible name, so the caller's icon is\n decoration on top of it rather than a second reading of it. */}\n {action.icon && (\n <span aria-hidden=\"true\" className=\"[&_svg]:size-4\">\n {action.icon}\n </span>\n )}\n {action.label}\n </button>\n ))}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef } from "react";
4
4
  import { Ban, Check } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
+ import { horizontalStep } from "../lib/direction.js";
6
7
  import { Tooltip } from "./tooltip.js";
7
8
  const TILE_SIZE = {
8
9
  sm: { tile: "size-7", glyph: "size-3.5", swatch: "size-4" },
@@ -28,14 +29,11 @@ function TileRadioGroup({
28
29
  const checkedIndex = checked === void 0 ? -1 : items.findIndex((it) => it.key === checked);
29
30
  const tabStop = checkedIndex >= 0 && !items[checkedIndex].disabled ? checkedIndex : items.findIndex((it) => !it.disabled);
30
31
  const onKeyDown = (e, index) => {
31
- const rtl = e.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
32
+ const horizontal = horizontalStep(e.key, e.currentTarget);
32
33
  let step;
33
- switch (e.key) {
34
- case "ArrowRight":
35
- step = rtl ? -1 : 1;
36
- break;
37
- case "ArrowLeft":
38
- step = rtl ? 1 : -1;
34
+ switch (horizontal ? "horizontal" : e.key) {
35
+ case "horizontal":
36
+ step = horizontal;
39
37
  break;
40
38
  case "ArrowDown":
41
39
  step = 1;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/tile-radio.tsx"],"sourcesContent":["import { useId, useRef } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { Ban, Check } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Tooltip } from \"./tooltip\";\n\n/**\n * The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square\n * tiles that behaves as ONE radio group. Private to the package — not re-exported\n * from the barrel.\n *\n * Keksdose had three of these (the category colours, the category symbols, the\n * transaction flags) and all three were rows of `aria-pressed` buttons: a separate\n * tab stop per tile, and \"pressed\" on a choice that is really one-of-n. The APG radio\n * group is the shape a screen reader expects for that (\"Red, radio, 2 of 7, checked\")\n * and the one a keyboard expects too — a single tab stop, arrows to move.\n */\n\nexport type TileSize = \"sm\" | \"md\" | \"lg\";\n\n/** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */\nexport const TILE_SIZE: Record<TileSize, { tile: string; glyph: string; swatch: string }> = {\n sm: { tile: \"size-7\", glyph: \"size-3.5\", swatch: \"size-4\" },\n md: { tile: \"size-8\", glyph: \"size-4\", swatch: \"size-5\" },\n lg: { tile: \"size-11\", glyph: \"size-5\", swatch: \"size-6\" },\n};\n\n/** One tile, whatever it shows. `key` is `null` for the \"none\" entry. */\nexport interface TileItem<T extends string> {\n key: T | null;\n label: string;\n /** Said after the name, and shown in the bubble under it. Marks the tile with a\n * dot, so the note is visible without hovering every tile to find it. */\n note?: string;\n disabled?: boolean;\n}\n\nexport interface TileRadioGroupProps<T extends string> {\n items: TileItem<T>[];\n /** The checked key, or `undefined` for \"nothing is checked\" (the mixed state, or\n * a value that is not among the options). */\n checked: T | null | undefined;\n onSelect: (key: T | null) => void;\n /** \"automatic\": an arrow key moves AND checks, the APG default. \"manual\": arrows\n * move focus only, Space or Enter checks — for a picker whose change is expensive\n * (a bulk edit that saves on every change). */\n activation: \"automatic\" | \"manual\";\n disabled: boolean;\n size: TileSize;\n /** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */\n renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;\n /** Extra classes for a tile, per item. */\n tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;\n}\n\n/**\n * The tile's frame. Selection is a thicker, darker frame AND a tick in the corner —\n * never a colour change alone,\n * because the thing inside a swatch tile IS a colour, and a red frame round a red dot\n * says nothing to a reader who cannot see red.\n *\n * `focus-visible` with an offset in the surface colour, as the checkbox does: the ring\n * must not be painted in the colour of the frame it touches.\n */\nconst TILE_BASE =\n \"relative inline-flex shrink-0 items-center justify-center rounded-md border transition-colors \" +\n \"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] \" +\n \"disabled:cursor-not-allowed disabled:opacity-50\";\nconst TILE_IDLE =\n \"border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]\";\nconst TILE_SELECTED =\n \"border-[var(--text-primary)] bg-[var(--bg-surface)] ring-1 ring-[var(--text-primary)]\";\n\nexport function TileRadioGroup<T extends string>({\n items,\n checked,\n onSelect,\n activation,\n disabled,\n size,\n renderTile,\n tileClassName,\n}: TileRadioGroupProps<T>) {\n const refs = useRef<Array<HTMLButtonElement | null>>([]);\n const noteId = useId();\n const s = TILE_SIZE[size];\n const checkedIndex =\n checked === undefined ? -1 : items.findIndex((it) => it.key === checked);\n // The ONE tab stop: the checked tile, else the first that can be chosen — the APG\n // rule, so Tab into a group with nothing checked still lands somewhere.\n const tabStop =\n checkedIndex >= 0 && !items[checkedIndex].disabled\n ? checkedIndex\n : items.findIndex((it) => !it.disabled);\n\n const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {\n // Left and right are VISUAL directions: in a right-to-left page the row runs the\n // other way. Up and down follow reading order either way — the APG's radio group\n // treats them as previous/next, and the tiles wrap, so there is no column to keep.\n const rtl = e.currentTarget.closest(\"[dir]\")?.getAttribute(\"dir\") === \"rtl\";\n let step: number | \"first\" | \"last\";\n switch (e.key) {\n case \"ArrowRight\":\n step = rtl ? -1 : 1;\n break;\n case \"ArrowLeft\":\n step = rtl ? 1 : -1;\n break;\n case \"ArrowDown\":\n step = 1;\n break;\n case \"ArrowUp\":\n step = -1;\n break;\n case \"Home\":\n step = \"first\";\n break;\n case \"End\":\n step = \"last\";\n break;\n default:\n return;\n }\n e.preventDefault();\n const enabled = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);\n if (!enabled.length) return;\n const at = enabled.indexOf(index);\n let target: number;\n if (step === \"first\") target = enabled[0];\n else if (step === \"last\") target = enabled[enabled.length - 1];\n // Wrapping, as a native radio group does: the last tile's \"next\" is the first.\n else target = enabled[(at + step + enabled.length) % enabled.length];\n refs.current[target]?.focus();\n if (activation === \"automatic\") onSelect(items[target].key);\n };\n\n return (\n <>\n {items.map((item, i) => {\n const selected = i === checkedIndex;\n const button = (\n <button\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n aria-label={item.label}\n // The note is a DESCRIPTION, read after the name and the state, not glued\n // into the name — the punctuation between the two would be English's.\n aria-describedby={item.note ? `${noteId}-${i}` : undefined}\n tabIndex={i === tabStop ? 0 : -1}\n disabled={disabled || item.disabled}\n // Clicking the checked tile again does nothing: a radio is not a toggle.\n // \"None\" is the way back to nothing, and it is a tile of its own.\n onClick={() => !selected && onSelect(item.key)}\n onKeyDown={(e) => onKeyDown(e, i)}\n className={cn(\n TILE_BASE,\n s.tile,\n selected ? TILE_SELECTED : TILE_IDLE,\n tileClassName?.(item, selected),\n )}\n >\n {item.key === null ? (\n <Ban aria-hidden className={cn(s.glyph, \"text-[var(--text-muted)]\")} />\n ) : (\n renderTile(item, selected)\n )}\n {item.note && (\n <>\n {/* A dot rather than a dimmed tile: dimming reads as \"disabled\", and a\n tile with a note is entirely available. */}\n <span\n aria-hidden\n className=\"absolute end-0.5 top-0.5 size-1.5 rounded-full bg-[var(--text-muted)]\"\n />\n {/* `hidden` still serves as a description: aria-describedby reads\n hidden text, which is what keeps it out of the layout. */}\n <span id={`${noteId}-${i}`} hidden>\n {item.note}\n </span>\n </>\n )}\n {selected && <SelectedTick />}\n </button>\n );\n return (\n // The bubble is the SIGHTED user's name for the tile — eight unlabelled\n // squares are the complaint that put a bubble on Keksdose's (live #289).\n // It wraps a span rather than the button, because a Tooltip hands its\n // child an `aria-describedby`, and a tile named \"Red\" and described as\n // \"Red\" is read out twice.\n <Tooltip\n key={item.key ?? \"\\u0000none\"}\n label={\n item.note ? (\n <>\n <span className=\"block\">{item.label}</span>\n <span className=\"block font-normal text-[var(--text-muted)]\">{item.note}</span>\n </>\n ) : (\n item.label\n )\n }\n portal\n >\n <span className=\"inline-flex\">{button}</span>\n </Tooltip>\n );\n })}\n </>\n );\n}\n\n/** The tick on a selected tile — a small badge in the corner, in the text colour on\n * the surface colour, so it reads the same over every swatch colour. */\nfunction SelectedTick() {\n return (\n <span\n aria-hidden\n className=\"absolute -bottom-1 -end-1 inline-flex size-3.5 items-center justify-center rounded-full bg-[var(--text-primary)] text-[var(--bg-surface)]\"\n >\n <Check strokeWidth={3} className=\"size-2.5\" />\n </span>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/tile-radio.tsx"],"sourcesContent":["import { useId, useRef } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { Ban, Check } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { Tooltip } from \"./tooltip\";\n\n/**\n * The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square\n * tiles that behaves as ONE radio group. Private to the package — not re-exported\n * from the barrel.\n *\n * Keksdose had three of these (the category colours, the category symbols, the\n * transaction flags) and all three were rows of `aria-pressed` buttons: a separate\n * tab stop per tile, and \"pressed\" on a choice that is really one-of-n. The APG radio\n * group is the shape a screen reader expects for that (\"Red, radio, 2 of 7, checked\")\n * and the one a keyboard expects too — a single tab stop, arrows to move.\n */\n\nexport type TileSize = \"sm\" | \"md\" | \"lg\";\n\n/** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */\nexport const TILE_SIZE: Record<TileSize, { tile: string; glyph: string; swatch: string }> = {\n sm: { tile: \"size-7\", glyph: \"size-3.5\", swatch: \"size-4\" },\n md: { tile: \"size-8\", glyph: \"size-4\", swatch: \"size-5\" },\n lg: { tile: \"size-11\", glyph: \"size-5\", swatch: \"size-6\" },\n};\n\n/** One tile, whatever it shows. `key` is `null` for the \"none\" entry. */\nexport interface TileItem<T extends string> {\n key: T | null;\n label: string;\n /** Said after the name, and shown in the bubble under it. Marks the tile with a\n * dot, so the note is visible without hovering every tile to find it. */\n note?: string;\n disabled?: boolean;\n}\n\nexport interface TileRadioGroupProps<T extends string> {\n items: TileItem<T>[];\n /** The checked key, or `undefined` for \"nothing is checked\" (the mixed state, or\n * a value that is not among the options). */\n checked: T | null | undefined;\n onSelect: (key: T | null) => void;\n /** \"automatic\": an arrow key moves AND checks, the APG default. \"manual\": arrows\n * move focus only, Space or Enter checks — for a picker whose change is expensive\n * (a bulk edit that saves on every change). */\n activation: \"automatic\" | \"manual\";\n disabled: boolean;\n size: TileSize;\n /** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */\n renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;\n /** Extra classes for a tile, per item. */\n tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;\n}\n\n/**\n * The tile's frame. Selection is a thicker, darker frame AND a tick in the corner —\n * never a colour change alone,\n * because the thing inside a swatch tile IS a colour, and a red frame round a red dot\n * says nothing to a reader who cannot see red.\n *\n * `focus-visible` with an offset in the surface colour, as the checkbox does: the ring\n * must not be painted in the colour of the frame it touches.\n */\nconst TILE_BASE =\n \"relative inline-flex shrink-0 items-center justify-center rounded-md border transition-colors \" +\n \"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface)] \" +\n \"disabled:cursor-not-allowed disabled:opacity-50\";\nconst TILE_IDLE =\n \"border-[var(--border)] bg-[var(--bg-surface)] hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover)]\";\nconst TILE_SELECTED =\n \"border-[var(--text-primary)] bg-[var(--bg-surface)] ring-1 ring-[var(--text-primary)]\";\n\nexport function TileRadioGroup<T extends string>({\n items,\n checked,\n onSelect,\n activation,\n disabled,\n size,\n renderTile,\n tileClassName,\n}: TileRadioGroupProps<T>) {\n const refs = useRef<Array<HTMLButtonElement | null>>([]);\n const noteId = useId();\n const s = TILE_SIZE[size];\n const checkedIndex =\n checked === undefined ? -1 : items.findIndex((it) => it.key === checked);\n // The ONE tab stop: the checked tile, else the first that can be chosen — the APG\n // rule, so Tab into a group with nothing checked still lands somewhere.\n const tabStop =\n checkedIndex >= 0 && !items[checkedIndex].disabled\n ? checkedIndex\n : items.findIndex((it) => !it.disabled);\n\n const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {\n // Left and right are VISUAL directions: in a right-to-left page the row runs the\n // other way. Up and down follow reading order either way — the APG's radio group\n // treats them as previous/next, and the tiles wrap, so there is no column to keep.\n const horizontal = horizontalStep(e.key, e.currentTarget);\n let step: number | \"first\" | \"last\";\n switch (horizontal ? \"horizontal\" : e.key) {\n case \"horizontal\":\n step = horizontal;\n break;\n case \"ArrowDown\":\n step = 1;\n break;\n case \"ArrowUp\":\n step = -1;\n break;\n case \"Home\":\n step = \"first\";\n break;\n case \"End\":\n step = \"last\";\n break;\n default:\n return;\n }\n e.preventDefault();\n const enabled = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);\n if (!enabled.length) return;\n const at = enabled.indexOf(index);\n let target: number;\n if (step === \"first\") target = enabled[0];\n else if (step === \"last\") target = enabled[enabled.length - 1];\n // Wrapping, as a native radio group does: the last tile's \"next\" is the first.\n else target = enabled[(at + step + enabled.length) % enabled.length];\n refs.current[target]?.focus();\n if (activation === \"automatic\") onSelect(items[target].key);\n };\n\n return (\n <>\n {items.map((item, i) => {\n const selected = i === checkedIndex;\n const button = (\n <button\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n aria-label={item.label}\n // The note is a DESCRIPTION, read after the name and the state, not glued\n // into the name — the punctuation between the two would be English's.\n aria-describedby={item.note ? `${noteId}-${i}` : undefined}\n tabIndex={i === tabStop ? 0 : -1}\n disabled={disabled || item.disabled}\n // Clicking the checked tile again does nothing: a radio is not a toggle.\n // \"None\" is the way back to nothing, and it is a tile of its own.\n onClick={() => !selected && onSelect(item.key)}\n onKeyDown={(e) => onKeyDown(e, i)}\n className={cn(\n TILE_BASE,\n s.tile,\n selected ? TILE_SELECTED : TILE_IDLE,\n tileClassName?.(item, selected),\n )}\n >\n {item.key === null ? (\n <Ban aria-hidden className={cn(s.glyph, \"text-[var(--text-muted)]\")} />\n ) : (\n renderTile(item, selected)\n )}\n {item.note && (\n <>\n {/* A dot rather than a dimmed tile: dimming reads as \"disabled\", and a\n tile with a note is entirely available. */}\n <span\n aria-hidden\n className=\"absolute end-0.5 top-0.5 size-1.5 rounded-full bg-[var(--text-muted)]\"\n />\n {/* `hidden` still serves as a description: aria-describedby reads\n hidden text, which is what keeps it out of the layout. */}\n <span id={`${noteId}-${i}`} hidden>\n {item.note}\n </span>\n </>\n )}\n {selected && <SelectedTick />}\n </button>\n );\n return (\n // The bubble is the SIGHTED user's name for the tile — eight unlabelled\n // squares are the complaint that put a bubble on Keksdose's (live #289).\n // It wraps a span rather than the button, because a Tooltip hands its\n // child an `aria-describedby`, and a tile named \"Red\" and described as\n // \"Red\" is read out twice.\n <Tooltip\n key={item.key ?? \"\\u0000none\"}\n label={\n item.note ? (\n <>\n <span className=\"block\">{item.label}</span>\n <span className=\"block font-normal text-[var(--text-muted)]\">{item.note}</span>\n </>\n ) : (\n item.label\n )\n }\n portal\n >\n <span className=\"inline-flex\">{button}</span>\n </Tooltip>\n );\n })}\n </>\n );\n}\n\n/** The tick on a selected tile — a small badge in the corner, in the text colour on\n * the surface colour, so it reads the same over every swatch colour. */\nfunction SelectedTick() {\n return (\n <span\n aria-hidden\n className=\"absolute -bottom-1 -end-1 inline-flex size-3.5 items-center justify-center rounded-full bg-[var(--text-primary)] text-[var(--bg-surface)]\"\n >\n <Check strokeWidth={3} className=\"size-2.5\" />\n </span>\n );\n}\n"],"mappings":";AAoKc,SAKA,UALA,KAKA,YALA;AApKd,SAAS,OAAO,cAAc;AAE9B,SAAS,KAAK,aAAa;AAC3B,SAAS,UAAU;AACnB,SAAS,sBAAsB;AAC/B,SAAS,eAAe;AAiBjB,MAAM,YAA+E;AAAA,EAC1F,IAAI,EAAE,MAAM,UAAU,OAAO,YAAY,QAAQ,SAAS;AAAA,EAC1D,IAAI,EAAE,MAAM,UAAU,OAAO,UAAU,QAAQ,SAAS;AAAA,EACxD,IAAI,EAAE,MAAM,WAAW,OAAO,UAAU,QAAQ,SAAS;AAC3D;AAuCA,MAAM,YACJ;AAGF,MAAM,YACJ;AACF,MAAM,gBACJ;AAEK,SAAS,eAAiC;AAAA,EAC/C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA2B;AACzB,QAAM,OAAO,OAAwC,CAAC,CAAC;AACvD,QAAM,SAAS,MAAM;AACrB,QAAM,IAAI,UAAU,IAAI;AACxB,QAAM,eACJ,YAAY,SAAY,KAAK,MAAM,UAAU,CAAC,OAAO,GAAG,QAAQ,OAAO;AAGzE,QAAM,UACJ,gBAAgB,KAAK,CAAC,MAAM,YAAY,EAAE,WACtC,eACA,MAAM,UAAU,CAAC,OAAO,CAAC,GAAG,QAAQ;AAE1C,QAAM,YAAY,CAAC,GAAqC,UAAkB;AAIxE,UAAM,aAAa,eAAe,EAAE,KAAK,EAAE,aAAa;AACxD,QAAI;AACJ,YAAQ,aAAa,eAAe,EAAE,KAAK;AAAA,MACzC,KAAK;AACH,eAAO;AACP;AAAA,MACF,KAAK;AACH,eAAO;AACP;AAAA,MACF,KAAK;AACH,eAAO;AACP;AAAA,MACF,KAAK;AACH,eAAO;AACP;AAAA,MACF,KAAK;AACH,eAAO;AACP;AAAA,MACF;AACE;AAAA,IACJ;AACA,MAAE,eAAe;AACjB,UAAM,UAAU,MAAM,IAAI,CAAC,IAAI,MAAO,GAAG,WAAW,KAAK,CAAE,EAAE,OAAO,CAAC,MAAM,KAAK,CAAC;AACjF,QAAI,CAAC,QAAQ,OAAQ;AACrB,UAAM,KAAK,QAAQ,QAAQ,KAAK;AAChC,QAAI;AACJ,QAAI,SAAS,QAAS,UAAS,QAAQ,CAAC;AAAA,aAC/B,SAAS,OAAQ,UAAS,QAAQ,QAAQ,SAAS,CAAC;AAAA,QAExD,UAAS,SAAS,KAAK,OAAO,QAAQ,UAAU,QAAQ,MAAM;AACnE,SAAK,QAAQ,MAAM,GAAG,MAAM;AAC5B,QAAI,eAAe,YAAa,UAAS,MAAM,MAAM,EAAE,GAAG;AAAA,EAC5D;AAEA,SACE,gCACG,gBAAM,IAAI,CAAC,MAAM,MAAM;AACtB,UAAM,WAAW,MAAM;AACvB,UAAM,SACJ;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,CAAC,OAAO;AACX,eAAK,QAAQ,CAAC,IAAI;AAAA,QACpB;AAAA,QACA,MAAK;AAAA,QACL,MAAK;AAAA,QACL,gBAAc;AAAA,QACd,cAAY,KAAK;AAAA,QAGjB,oBAAkB,KAAK,OAAO,GAAG,MAAM,IAAI,CAAC,KAAK;AAAA,QACjD,UAAU,MAAM,UAAU,IAAI;AAAA,QAC9B,UAAU,YAAY,KAAK;AAAA,QAG3B,SAAS,MAAM,CAAC,YAAY,SAAS,KAAK,GAAG;AAAA,QAC7C,WAAW,CAAC,MAAM,UAAU,GAAG,CAAC;AAAA,QAChC,WAAW;AAAA,UACT;AAAA,UACA,EAAE;AAAA,UACF,WAAW,gBAAgB;AAAA,UAC3B,gBAAgB,MAAM,QAAQ;AAAA,QAChC;AAAA,QAEC;AAAA,eAAK,QAAQ,OACZ,oBAAC,OAAI,eAAW,MAAC,WAAW,GAAG,EAAE,OAAO,0BAA0B,GAAG,IAErE,WAAW,MAAM,QAAQ;AAAA,UAE1B,KAAK,QACJ,iCAGE;AAAA;AAAA,cAAC;AAAA;AAAA,gBACC,eAAW;AAAA,gBACX,WAAU;AAAA;AAAA,YACZ;AAAA,YAGA,oBAAC,UAAK,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,QAAM,MAC/B,eAAK,MACR;AAAA,aACF;AAAA,UAED,YAAY,oBAAC,gBAAa;AAAA;AAAA;AAAA,IAC7B;AAEF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAME;AAAA,QAAC;AAAA;AAAA,UAEC,OACE,KAAK,OACH,iCACE;AAAA,gCAAC,UAAK,WAAU,SAAS,eAAK,OAAM;AAAA,YACpC,oBAAC,UAAK,WAAU,8CAA8C,eAAK,MAAK;AAAA,aAC1E,IAEA,KAAK;AAAA,UAGT,QAAM;AAAA,UAEN,8BAAC,UAAK,WAAU,eAAe,kBAAO;AAAA;AAAA,QAbjC,KAAK,OAAO;AAAA,MAcnB;AAAA;AAAA,EAEJ,CAAC,GACH;AAEJ;AAIA,SAAS,eAAe;AACtB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,eAAW;AAAA,MACX,WAAU;AAAA,MAEV,8BAAC,SAAM,aAAa,GAAG,WAAU,YAAW;AAAA;AAAA,EAC9C;AAEJ;","names":[]}
@@ -1,5 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { InputProps } from './ui.js';
3
+ import './tooltip.js';
4
+ import '../hooks/use-anchored-rect.js';
3
5
 
4
6
  /**
5
7
  * A time of day, as a `"HH:mm"` string (`"HH:mm:ss"` once `step` asks for seconds).
@@ -1,5 +1,4 @@
1
- import * as react from 'react';
2
- import { ComponentPropsWithoutRef } from 'react';
1
+ import { ComponentPropsWithoutRef, ReactElement } from 'react';
3
2
 
4
3
  interface ToggleOption<T extends string> {
5
4
  value: T;
@@ -66,6 +65,17 @@ interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBasePro
66
65
  /** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group
67
66
  * can actually emit `null` — no existing caller has a `null` to handle. */
68
67
  type ToggleGroupProps<T extends string> = ToggleGroupRequiredProps<T> | ToggleGroupClearableProps<T>;
69
- declare function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): react.JSX.Element;
68
+ /**
69
+ * Overloaded rather than typed by the union alone (keksdose, "Gaps found adopting
70
+ * 0.6.0" #7). Inferring `T` through a union of prop shapes let TypeScript settle on
71
+ * `string`, so `<ToggleGroup allowEmpty value={filter} onChange={setFilter} />` over a
72
+ * `useState<Status | null>` did not compile unless the caller spelled
73
+ * `<ToggleGroup<Status>>`. One signature per mode lets each infer `T` from its own
74
+ * `value`/`onChange`/`options`; the third keeps a caller that forwards a
75
+ * {@link ToggleGroupProps} union (a wrapper component) compiling.
76
+ */
77
+ declare function ToggleGroup<T extends string>(props: ToggleGroupClearableProps<T>): ReactElement;
78
+ declare function ToggleGroup<T extends string>(props: ToggleGroupRequiredProps<T>): ReactElement;
79
+ declare function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement;
70
80
 
71
81
  export { ToggleGroup, type ToggleGroupBaseProps, type ToggleGroupClearableProps, type ToggleGroupProps, type ToggleGroupRequiredProps, type ToggleOption };
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { cn } from "../lib/cn.js";
4
+ import { horizontalStep } from "../lib/direction.js";
4
5
  function ToggleGroup(props) {
5
6
  const {
6
7
  value,
@@ -18,6 +19,23 @@ function ToggleGroup(props) {
18
19
  else props.onChange(next);
19
20
  };
20
21
  const clearable = props.allowEmpty === true;
22
+ const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;
23
+ const onRadioKey = (e, index) => {
24
+ const last = options.length - 1;
25
+ let next = null;
26
+ const step = horizontalStep(e.key, e.currentTarget);
27
+ if (step !== 0) next = index + step;
28
+ else if (e.key === "ArrowDown") next = index + 1;
29
+ else if (e.key === "ArrowUp") next = index - 1;
30
+ else if (e.key === "Home") next = 0;
31
+ else if (e.key === "End") next = last;
32
+ if (next === null) return;
33
+ e.preventDefault();
34
+ next = next < 0 ? last : next > last ? 0 : next;
35
+ const buttons = e.currentTarget.parentElement?.querySelectorAll(":scope > button");
36
+ buttons?.[next]?.focus();
37
+ choose(options[next].value);
38
+ };
21
39
  return /* @__PURE__ */ jsx(
22
40
  "div",
23
41
  {
@@ -45,7 +63,7 @@ function ToggleGroup(props) {
45
63
  disabled && "opacity-60",
46
64
  className
47
65
  ),
48
- children: options.map((opt) => {
66
+ children: options.map((opt, index) => {
49
67
  const active = opt.value === value;
50
68
  return /* @__PURE__ */ jsx(
51
69
  "button",
@@ -55,6 +73,8 @@ function ToggleGroup(props) {
55
73
  "aria-checked": clearable ? void 0 : active,
56
74
  "aria-pressed": clearable ? active : void 0,
57
75
  disabled,
76
+ tabIndex: clearable ? void 0 : opt.value === tabStop ? 0 : -1,
77
+ onKeyDown: clearable ? void 0 : (e) => onRadioKey(e, index),
58
78
  onClick: () => choose(opt.value),
59
79
  className: cn(
60
80
  // `truncate` (which carries whitespace-nowrap) rather than letting a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/toggle-group.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../lib/cn\";\n\nexport interface ToggleOption<T extends string> {\n value: T;\n label: string;\n className?: string;\n}\n\n/**\n * `onChange` is the group's own — the chosen VALUE, not a DOM event — so the div's\n * `onChange` is omitted rather than shadowed: leaving both in scope would give the\n * prop two incompatible meanings depending on which overload TypeScript picked.\n *\n * `children` is omitted too: this renders its `options` and nothing else, so a `children` the type\n * accepted and the component ignored would be a prop that silently does nothing —\n * worse than one that does not compile.\n */\nexport interface ToggleGroupBaseProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n options: ToggleOption<T>[];\n className?: string;\n /** Applied to every option button (e.g. to tune height/rounding to match\n * adjacent fields). Per-option `className` still wins over this. */\n optionClassName?: string;\n /**\n * @deprecated Pass `aria-label` instead — the DOM spelling, which every other\n * control in this kit now answers to. Kept working because three applications ship\n * this one today; it names the group only when `aria-label` is absent.\n */\n ariaLabel?: string;\n /**\n * Show, refuse the change (Keksdose live #288: a payment dated in the future has no\n * state to set).\n *\n * Whatever `value` says stays pressed and keeps its own fill rather than going grey\n * with the rest — a reader who cannot see WHICH option is chosen has been told less\n * than before it was disabled. A caller with nothing to show passes no value, and\n * the group renders dimmed with nothing pressed, which is the shape Keksdose's\n * status picker uses for a row whose status does not exist yet.\n *\n * On the whole GROUP, not per option: a segmented control where some segments are\n * live and others are not is a menu with holes in it, and no caller here wants one.\n */\n disabled?: boolean;\n}\n\n/** The group as it has always been: one option is always the answer. */\nexport interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty?: false;\n value: T;\n onChange: (value: T) => void;\n}\n\n/**\n * A group that can be emptied: clicking the active option clears it, and `onChange`\n * receives `null` (Keksdose's support-panel filters, where \"no filter\" is reached by\n * clicking the filter that is on).\n *\n * The options become TOGGLE BUTTONS (`aria-pressed`, in a `role=\"group\"`) rather than\n * radios. A radio cannot be unchecked by activating it — no screen reader user expects\n * a second press on \"Open, radio, checked\" to leave nothing checked, and nothing would\n * tell them it had. \"Open, toggle button, pressed\" says exactly what a press will do.\n */\nexport interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty: true;\n value: T | null;\n onChange: (value: T | null) => void;\n}\n\n/** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group\n * can actually emit `null` — no existing caller has a `null` to handle. */\nexport type ToggleGroupProps<T extends string> =\n | ToggleGroupRequiredProps<T>\n | ToggleGroupClearableProps<T>;\n\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>) {\n const {\n value,\n options,\n className,\n optionClassName,\n ariaLabel,\n disabled = false,\n \"aria-label\": ariaLabelAttr,\n ...restWithMode\n } = props;\n // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is\n // what lets the `onChange` below be called with `null` only in the mode that allows it.\n const { allowEmpty: _allowEmpty, onChange: _onChange, ...rest } = restWithMode;\n const choose = (next: T) => {\n if (props.allowEmpty) props.onChange(next === value ? null : next);\n else props.onChange(next);\n };\n const clearable = props.allowEmpty === true;\n return (\n <div\n // The audit's named example of a closed prop list (§\"Public API design\"): the\n // tour locates a step by CSS SELECTOR, so a component that drops every attribute\n // it was not expecting cannot be spotlighted at all — and Keksdose's rule editor\n // carries a comment explaining that it wraps this group in a bare <div> for\n // exactly that reason.\n //\n // `...rest` first, then the attributes the group cannot do without: a caller\n // hanging an anchor or a test id on the group must not be able to overwrite the\n // radiogroup role or the disabled state by accident. `className` is destructured\n // out entirely and merged through `cn`, so it is never in here.\n {...rest}\n role={clearable ? \"group\" : \"radiogroup\"}\n // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that\n // have not moved yet.\n aria-label={ariaLabelAttr ?? ariaLabel}\n // `aria-disabled` on the group as well as `disabled` on each button: a radio\n // group is what the user is being refused, and a screen reader announcing\n // three separately-disabled radios does not say that.\n aria-disabled={disabled || undefined}\n className={cn(\n // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment\n // sits in a uniform 2px moat and no two fills ever touch. Flush segments were\n // Keksdose live #268's rework: the pressed segment wears a saturated fill and\n // an unpressed neighbour wears a pale hover fill, and with a shared edge the\n // two rectangles read as one smeared shape — *\"the boundary of the selected\n // option and hovering next to it overlays the boundary of the selected\n // button\"*. A gap is what makes each segment its own chip; it cannot be\n // undone by a caller's per-option colour, which a hover-only fix could.\n //\n // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every\n // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a\n // phone does see is the focus ring — see the segment's own note below.)\n \"inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm\",\n // The whole group fades, the way every other disabled control in this\n // package does; `cursor-not-allowed` is on the buttons, which is what a\n // pointer is actually over.\n disabled && \"opacity-60\",\n className,\n )}\n >\n {options.map((opt) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n role={clearable ? undefined : \"radio\"}\n aria-checked={clearable ? undefined : active}\n aria-pressed={clearable ? active : undefined}\n disabled={disabled}\n onClick={() => choose(opt.value)}\n className={cn(\n // `truncate` (which carries whitespace-nowrap) rather than letting a\n // label wrap: a segmented control sizes its whole row to the tallest\n // option, so one two-word option — Keksdose feedback #147's \"Where I\n // am\" — silently doubles the height of every segment beside it.\n //\n // `basis-auto` is what keeps that ellipsis a LAST resort rather than\n // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a\n // shrink-to-fit group (`w-auto`) still resolves to the sum of the\n // labels' widths — and then splits it EQUALLY, so the short option\n // got 66px it did not need and \"Where I am\" got 66 of the 81 it did:\n // truncated at 1778px of free screen. Basing each segment on its own\n // content and sharing only the LEFTOVER space keeps a full-width\n // group's segments near-equal and an auto-width group's exact.\n //\n // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring\n // is a box-shadow that spreads OUTWARD, so on a flush group it painted\n // 2px of ring over both neighbours and over the container's own border\n // — and on a phone it appeared on every TAP, because a tap focuses the\n // button. That is the other half of what live #268's rework saw\n // overlaying the selected segment's boundary. Inset keeps the ring\n // inside the segment it belongs to; focus-visible keeps it for the\n // keyboard, which is the only input that needs it.\n \"min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]\",\n active\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n // No hover fill on a group that cannot be changed — a segment that\n // lights up under the pointer is an offer, and there is none here.\n disabled && \"cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent\",\n optionClassName,\n opt.className,\n )}\n >\n {opt.label}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AA4IU;AA3IV,SAAS,UAAU;AA2EZ,SAAS,YAA8B,OAA4B;AACxE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW;AAAA,IACX,cAAc;AAAA,IACd,GAAG;AAAA,EACL,IAAI;AAGJ,QAAM,EAAE,YAAY,aAAa,UAAU,WAAW,GAAG,KAAK,IAAI;AAClE,QAAM,SAAS,CAAC,SAAY;AAC1B,QAAI,MAAM,WAAY,OAAM,SAAS,SAAS,QAAQ,OAAO,IAAI;AAAA,QAC5D,OAAM,SAAS,IAAI;AAAA,EAC1B;AACA,QAAM,YAAY,MAAM,eAAe;AACvC,SACE;AAAA,IAAC;AAAA;AAAA,MAWE,GAAG;AAAA,MACJ,MAAM,YAAY,UAAU;AAAA,MAG5B,cAAY,iBAAiB;AAAA,MAI7B,iBAAe,YAAY;AAAA,MAC3B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAaT;AAAA;AAAA;AAAA;AAAA,QAIA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,QAAQ;AACpB,cAAM,SAAS,IAAI,UAAU;AAC7B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAM,YAAY,SAAY;AAAA,YAC9B,gBAAc,YAAY,SAAY;AAAA,YACtC,gBAAc,YAAY,SAAS;AAAA,YACnC;AAAA,YACA,SAAS,MAAM,OAAO,IAAI,KAAK;AAAA,YAC/B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAuBT;AAAA,cACA,SACI,sDACA;AAAA;AAAA;AAAA,cAGJ,YAAY;AAAA,cACZ;AAAA,cACA,IAAI;AAAA,YACN;AAAA,YAEC,cAAI;AAAA;AAAA,UAzCA,IAAI;AAAA,QA0CX;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/toggle-group.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\n\nexport interface ToggleOption<T extends string> {\n value: T;\n label: string;\n className?: string;\n}\n\n/**\n * `onChange` is the group's own — the chosen VALUE, not a DOM event — so the div's\n * `onChange` is omitted rather than shadowed: leaving both in scope would give the\n * prop two incompatible meanings depending on which overload TypeScript picked.\n *\n * `children` is omitted too: this renders its `options` and nothing else, so a `children` the type\n * accepted and the component ignored would be a prop that silently does nothing —\n * worse than one that does not compile.\n */\nexport interface ToggleGroupBaseProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n options: ToggleOption<T>[];\n className?: string;\n /** Applied to every option button (e.g. to tune height/rounding to match\n * adjacent fields). Per-option `className` still wins over this. */\n optionClassName?: string;\n /**\n * @deprecated Pass `aria-label` instead — the DOM spelling, which every other\n * control in this kit now answers to. Kept working because three applications ship\n * this one today; it names the group only when `aria-label` is absent.\n */\n ariaLabel?: string;\n /**\n * Show, refuse the change (Keksdose live #288: a payment dated in the future has no\n * state to set).\n *\n * Whatever `value` says stays pressed and keeps its own fill rather than going grey\n * with the rest — a reader who cannot see WHICH option is chosen has been told less\n * than before it was disabled. A caller with nothing to show passes no value, and\n * the group renders dimmed with nothing pressed, which is the shape Keksdose's\n * status picker uses for a row whose status does not exist yet.\n *\n * On the whole GROUP, not per option: a segmented control where some segments are\n * live and others are not is a menu with holes in it, and no caller here wants one.\n */\n disabled?: boolean;\n}\n\n/** The group as it has always been: one option is always the answer. */\nexport interface ToggleGroupRequiredProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty?: false;\n value: T;\n onChange: (value: T) => void;\n}\n\n/**\n * A group that can be emptied: clicking the active option clears it, and `onChange`\n * receives `null` (Keksdose's support-panel filters, where \"no filter\" is reached by\n * clicking the filter that is on).\n *\n * The options become TOGGLE BUTTONS (`aria-pressed`, in a `role=\"group\"`) rather than\n * radios. A radio cannot be unchecked by activating it — no screen reader user expects\n * a second press on \"Open, radio, checked\" to leave nothing checked, and nothing would\n * tell them it had. \"Open, toggle button, pressed\" says exactly what a press will do.\n */\nexport interface ToggleGroupClearableProps<T extends string> extends ToggleGroupBaseProps<T> {\n allowEmpty: true;\n value: T | null;\n onChange: (value: T | null) => void;\n}\n\n/** `allowEmpty` picks the shape, so `onChange` is typed `(T) => void` unless the group\n * can actually emit `null` — no existing caller has a `null` to handle. */\nexport type ToggleGroupProps<T extends string> =\n | ToggleGroupRequiredProps<T>\n | ToggleGroupClearableProps<T>;\n\n/**\n * Overloaded rather than typed by the union alone (keksdose, \"Gaps found adopting\n * 0.6.0\" #7). Inferring `T` through a union of prop shapes let TypeScript settle on\n * `string`, so `<ToggleGroup allowEmpty value={filter} onChange={setFilter} />` over a\n * `useState<Status | null>` did not compile unless the caller spelled\n * `<ToggleGroup<Status>>`. One signature per mode lets each infer `T` from its own\n * `value`/`onChange`/`options`; the third keeps a caller that forwards a\n * {@link ToggleGroupProps} union (a wrapper component) compiling.\n */\nexport function ToggleGroup<T extends string>(props: ToggleGroupClearableProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupRequiredProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement;\nexport function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): ReactElement {\n const {\n value,\n options,\n className,\n optionClassName,\n ariaLabel,\n disabled = false,\n \"aria-label\": ariaLabelAttr,\n ...restWithMode\n } = props;\n // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is\n // what lets the `onChange` below be called with `null` only in the mode that allows it.\n const { allowEmpty: _allowEmpty, onChange: _onChange, ...rest } = restWithMode;\n const choose = (next: T) => {\n if (props.allowEmpty) props.onChange(next === value ? null : next);\n else props.onChange(next);\n };\n const clearable = props.allowEmpty === true;\n // A radio group is ONE tab stop (the checked radio, else the first) and arrows move\n // the choice — the pattern `role=\"radiogroup\"` promises a screen-reader user. Before\n // 0.7.0 each segment was its own tab stop with no arrow keys. The clearable mode is a\n // row of toggle buttons, where separate tab stops are the pattern.\n const tabStop = options.some((o) => o.value === value) ? value : options[0]?.value;\n const onRadioKey = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const last = options.length - 1;\n let next: number | null = null;\n const step = horizontalStep(e.key, e.currentTarget);\n if (step !== 0) next = index + step;\n else if (e.key === \"ArrowDown\") next = index + 1;\n else if (e.key === \"ArrowUp\") next = index - 1;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = last;\n if (next === null) return;\n e.preventDefault();\n next = next < 0 ? last : next > last ? 0 : next;\n const buttons = e.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(\":scope > button\");\n buttons?.[next]?.focus();\n choose(options[next].value);\n };\n return (\n <div\n // The audit's named example of a closed prop list (§\"Public API design\"): the\n // tour locates a step by CSS SELECTOR, so a component that drops every attribute\n // it was not expecting cannot be spotlighted at all — and Keksdose's rule editor\n // carries a comment explaining that it wraps this group in a bare <div> for\n // exactly that reason.\n //\n // `...rest` first, then the attributes the group cannot do without: a caller\n // hanging an anchor or a test id on the group must not be able to overwrite the\n // radiogroup role or the disabled state by accident. `className` is destructured\n // out entirely and merged through `cn`, so it is never in here.\n {...rest}\n role={clearable ? \"group\" : \"radiogroup\"}\n // The DOM spelling wins; `ariaLabel` is the fallback for the call sites that\n // have not moved yet.\n aria-label={ariaLabelAttr ?? ariaLabel}\n // `aria-disabled` on the group as well as `disabled` on each button: a radio\n // group is what the user is being refused, and a screen reader announcing\n // three separately-disabled radios does not say that.\n aria-disabled={disabled || undefined}\n className={cn(\n // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment\n // sits in a uniform 2px moat and no two fills ever touch. Flush segments were\n // Keksdose live #268's rework: the pressed segment wears a saturated fill and\n // an unpressed neighbour wears a pale hover fill, and with a shared edge the\n // two rectangles read as one smeared shape — *\"the boundary of the selected\n // option and hovering next to it overlays the boundary of the selected\n // button\"*. A gap is what makes each segment its own chip; it cannot be\n // undone by a caller's per-option colour, which a hover-only fix could.\n //\n // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every\n // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a\n // phone does see is the focus ring — see the segment's own note below.)\n \"inline-flex w-full gap-0.5 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] p-0.5 shadow-sm\",\n // The whole group fades, the way every other disabled control in this\n // package does; `cursor-not-allowed` is on the buttons, which is what a\n // pointer is actually over.\n disabled && \"opacity-60\",\n className,\n )}\n >\n {options.map((opt, index) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n role={clearable ? undefined : \"radio\"}\n aria-checked={clearable ? undefined : active}\n aria-pressed={clearable ? active : undefined}\n disabled={disabled}\n tabIndex={clearable ? undefined : opt.value === tabStop ? 0 : -1}\n onKeyDown={clearable ? undefined : (e) => onRadioKey(e, index)}\n onClick={() => choose(opt.value)}\n className={cn(\n // `truncate` (which carries whitespace-nowrap) rather than letting a\n // label wrap: a segmented control sizes its whole row to the tallest\n // option, so one two-word option — Keksdose feedback #147's \"Where I\n // am\" — silently doubles the height of every segment beside it.\n //\n // `basis-auto` is what keeps that ellipsis a LAST resort rather than\n // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a\n // shrink-to-fit group (`w-auto`) still resolves to the sum of the\n // labels' widths — and then splits it EQUALLY, so the short option\n // got 66px it did not need and \"Where I am\" got 66 of the 81 it did:\n // truncated at 1778px of free screen. Basing each segment on its own\n // content and sharing only the LEFTOVER space keeps a full-width\n // group's segments near-equal and an auto-width group's exact.\n //\n // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring\n // is a box-shadow that spreads OUTWARD, so on a flush group it painted\n // 2px of ring over both neighbours and over the container's own border\n // — and on a phone it appeared on every TAP, because a tap focuses the\n // button. That is the other half of what live #268's rework saw\n // overlaying the selected segment's boundary. Inset keeps the ring\n // inside the segment it belongs to; focus-visible keeps it for the\n // keyboard, which is the only input that needs it.\n \"min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]\",\n active\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n // No hover fill on a group that cannot be changed — a segment that\n // lights up under the pointer is an offer, and there is none here.\n disabled && \"cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent\",\n optionClassName,\n opt.className,\n )}\n >\n {opt.label}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AA8KU;AA7KV,SAAS,UAAU;AACnB,SAAS,sBAAsB;AAuFxB,SAAS,YAA8B,OAA0C;AACtF,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW;AAAA,IACX,cAAc;AAAA,IACd,GAAG;AAAA,EACL,IAAI;AAGJ,QAAM,EAAE,YAAY,aAAa,UAAU,WAAW,GAAG,KAAK,IAAI;AAClE,QAAM,SAAS,CAAC,SAAY;AAC1B,QAAI,MAAM,WAAY,OAAM,SAAS,SAAS,QAAQ,OAAO,IAAI;AAAA,QAC5D,OAAM,SAAS,IAAI;AAAA,EAC1B;AACA,QAAM,YAAY,MAAM,eAAe;AAKvC,QAAM,UAAU,QAAQ,KAAK,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,QAAQ,QAAQ,CAAC,GAAG;AAC7E,QAAM,aAAa,CAAC,GAAqC,UAAkB;AACzE,UAAM,OAAO,QAAQ,SAAS;AAC9B,QAAI,OAAsB;AAC1B,UAAM,OAAO,eAAe,EAAE,KAAK,EAAE,aAAa;AAClD,QAAI,SAAS,EAAG,QAAO,QAAQ;AAAA,aACtB,EAAE,QAAQ,YAAa,QAAO,QAAQ;AAAA,aACtC,EAAE,QAAQ,UAAW,QAAO,QAAQ;AAAA,aACpC,EAAE,QAAQ,OAAQ,QAAO;AAAA,aACzB,EAAE,QAAQ,MAAO,QAAO;AACjC,QAAI,SAAS,KAAM;AACnB,MAAE,eAAe;AACjB,WAAO,OAAO,IAAI,OAAO,OAAO,OAAO,IAAI;AAC3C,UAAM,UAAU,EAAE,cAAc,eAAe,iBAAoC,iBAAiB;AACpG,cAAU,IAAI,GAAG,MAAM;AACvB,WAAO,QAAQ,IAAI,EAAE,KAAK;AAAA,EAC5B;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MAWE,GAAG;AAAA,MACJ,MAAM,YAAY,UAAU;AAAA,MAG5B,cAAY,iBAAiB;AAAA,MAI7B,iBAAe,YAAY;AAAA,MAC3B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAaT;AAAA;AAAA;AAAA;AAAA,QAIA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,KAAK,UAAU;AAC3B,cAAM,SAAS,IAAI,UAAU;AAC7B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAM,YAAY,SAAY;AAAA,YAC9B,gBAAc,YAAY,SAAY;AAAA,YACtC,gBAAc,YAAY,SAAS;AAAA,YACnC;AAAA,YACA,UAAU,YAAY,SAAY,IAAI,UAAU,UAAU,IAAI;AAAA,YAC9D,WAAW,YAAY,SAAY,CAAC,MAAM,WAAW,GAAG,KAAK;AAAA,YAC7D,SAAS,MAAM,OAAO,IAAI,KAAK;AAAA,YAC/B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAuBT;AAAA,cACA,SACI,sDACA;AAAA;AAAA;AAAA,cAGJ,YAAY;AAAA,cACZ;AAAA,cACA,IAAI;AAAA,YACN;AAAA,YAEC,cAAI;AAAA;AAAA,UA3CA,IAAI;AAAA,QA4CX;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
@@ -85,8 +85,7 @@ function LegendMark({ entry, off }) {
85
85
  y2: 1,
86
86
  stroke: entry.color,
87
87
  strokeWidth: 2,
88
- strokeDasharray: strokeDash(entry.dash ?? 0),
89
- opacity: off ? 0.35 : 1
88
+ strokeDasharray: strokeDash(entry.dash ?? 0)
90
89
  }
91
90
  ) });
92
91
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/toggle-legend.tsx"],"sourcesContent":["// A legend whose entries are SWITCHES — and the stroke table it shares with\n// `SeriesChart`. Lifted out of lenkbank's measurement plots, where ten legends over\n// thirteen charts took channels on and off the picture.\n//\n// No recharts in this module: a legend of switches is plain buttons, and it is as\n// useful under a chart the consumer drew by hand as under `SeriesChart`.\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from \"./series-chart-labels\";\n\n/**\n * The strokes a reader can tell apart, in the order they are handed out.\n *\n * For the charts where the colour is already spoken for: a comparison draws each\n * measurement in its own colour, so a chart carrying several CHANNELS of each has\n * nothing left but the stroke — and \"solid versus dashed\" only gets you two.\n *\n * **Five, and the fifth is the last one.** They are the draughtsman's line types, in\n * the draughtsman's order — continuous, dashed, dotted, dash-dot, dash-dot-dot — the\n * one family of five a century of drawings has already proved legible at a hair's\n * width. A sixth is not distinguishable from one of these at two pixels wide, so a\n * chart needing one is a chart that should be split.\n *\n * Kept here, beside the legend's own mark, and read by `SeriesChart` from here — so\n * the key under a chart and the lines on it cannot disagree.\n */\nexport const STROKE_PATTERNS: readonly (string | undefined)[] = [\n undefined,\n \"5 4\",\n \"1 3\",\n \"9 3 2 3\",\n \"9 3 2 3 2 3\",\n];\n\n/** The dash array for the nth pattern, wrapping past the fifth. */\nexport function strokeDash(order: number): string | undefined {\n const n = STROKE_PATTERNS.length;\n return STROKE_PATTERNS[((Math.trunc(order) % n) + n) % n];\n}\n\n/** Which of the patterns a `step` series is drawn in.\n *\n * A whole-number channel that jumps rather than travels is drawn dashed, and it has\n * to be one of {@link STROKE_PATTERNS} rather than a dash array written out at the\n * call site: a legend entry says its pattern by index, so a step line drawn in\n * anything else would be a legend promising a solid line for a dashed one. */\nexport const STEP_DASH = 1;\n\n/**\n * One key switched, as a new hidden set.\n *\n * Every caller writes `onToggle={(key) => setHidden(toggleHidden(hidden, key))}`. A\n * new set rather than a mutated one, because the state is read during render and a\n * mutation of something a component already holds is invisible to React.\n */\nexport function toggleHidden(hidden: ReadonlySet<string>, key: string): ReadonlySet<string> {\n const next = new Set(hidden);\n if (!next.delete(key)) next.add(key);\n return next;\n}\n\nexport interface LegendEntry {\n key: string;\n label: ReactNode;\n /** Any CSS colour — `paletteFor(i)` for the kit's own ramp. */\n color: string;\n /**\n * What the entry's mark looks like. `swatch` — a filled square, the default — says\n * WHICH MEASUREMENT. `stroke` says WHICH QUANTITY, for the charts that carry both at\n * once and tell them apart by colour and by dash.\n */\n marker?: \"swatch\" | \"stroke\";\n /** Which of {@link STROKE_PATTERNS}, when the marker is a stroke — the same index\n * `SeriesChartSeries.dash` takes, so a legend cannot promise a dot-dash the plot\n * draws dashed. */\n dash?: number;\n}\n\nexport interface ToggleLegendProps {\n entries: LegendEntry[];\n /** The keys currently off the chart. */\n hidden: ReadonlySet<string>;\n onToggle: (key: string) => void;\n /** `vertical` is for a legend standing BESIDE the charts rather than under them —\n * what a row of charts sharing one legend wants, since under two charts there is\n * no \"under\", and putting it under one says it belongs to that one. */\n orientation?: \"horizontal\" | \"vertical\";\n /**\n * Draw a legend of ONE entry rather than nothing.\n *\n * A single line's legend is usually noise. It is not where the legend is the only\n * SWITCH that line has — a series that starts hidden and has no entry to bring it\n * back is gone for good.\n */\n showSingle?: boolean;\n className?: string;\n /** Per-instance strings over `<UiKitProvider labels={{ seriesChart }}>`. */\n labels?: Partial<SeriesChartLabels>;\n}\n\n/**\n * A legend whose entries are switches.\n *\n * `ChartLegendContent` with `onItemClick`/`activeKey` is the other kind: clicking an\n * entry HIGHLIGHTS it and dims the rest, which is right when every series belongs on\n * the chart and one of them is momentarily interesting. This is for the opposite —\n * three coordinates on one axis where y runs to 800 and z barely moves, and the only\n * way to see z is to take y off the chart entirely. Any number may be off at once.\n *\n * A hidden entry keeps its mark and its place. Removing it would reflow the legend on\n * every click and lose the one thing it is for: saying what COULD be shown.\n *\n * Each entry is a real `<button>` with `aria-pressed` — pressed means \"on the chart\" —\n * so Tab reaches every switch and Space/Enter flips it.\n */\nexport function ToggleLegend({\n entries,\n hidden,\n onToggle,\n orientation = \"horizontal\",\n showSingle = false,\n className,\n labels: labelsProp,\n}: ToggleLegendProps) {\n const labels = useKitLabels(\"seriesChart\", DEFAULT_SERIES_CHART_LABELS, labelsProp);\n if (entries.length < (showSingle ? 1 : 2)) return null;\n return (\n <div\n role=\"group\"\n aria-label={labels.legend}\n className={cn(\n \"flex gap-x-3 gap-y-1\",\n orientation === \"vertical\"\n ? \"flex-col items-start justify-center\"\n : \"mt-2 flex-wrap items-center\",\n className,\n )}\n >\n {entries.map((entry) => {\n const off = hidden.has(entry.key);\n return (\n <button\n key={entry.key}\n type=\"button\"\n // Pressed rather than a checkbox: it is a control over what the chart\n // draws, and \"not pressed\" is the same sentence the dimmed mark says.\n aria-pressed={!off}\n onClick={() => onToggle(entry.key)}\n className={cn(\n \"flex items-center gap-1.5 rounded text-start text-[11px] text-[var(--text-secondary)] transition-opacity hover:opacity-80\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n off && \"opacity-35\",\n )}\n >\n <LegendMark entry={entry} off={off} />\n {entry.label}\n </button>\n );\n })}\n </div>\n );\n}\n\n/**\n * The column a vertical legend stands in: beside the charts, on their horizontal\n * centre line. `h-full` + `justify-center`, so it centres in a grid cell or a flex row\n * that stretches it — and stacks whatever it is given, which is what a panel showing\n * two legends at once needs. The width is the caller's: how wide a legend column is\n * is a fact about that screen's layout.\n */\nexport function LegendColumn({ children, className }: { children: ReactNode; className?: string }) {\n return (\n <div className={cn(\"flex h-full flex-col justify-center gap-3\", className)}>{children}</div>\n );\n}\n\n/**\n * A legend and what it is a legend OF, as a headed column.\n *\n * For legends that are several vocabularies at once — which measurement and which\n * channel — so the entries are read down a group and the groups across. Wrapped rows\n * would put the second half of one group on a line under the first half of the next.\n */\nexport function LegendGroup({ title, children }: { title: ReactNode; children: ReactNode }) {\n return (\n <div className=\"flex min-w-0 flex-col items-start gap-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {title}\n </span>\n <div className=\"flex flex-col items-start gap-y-0.5\">{children}</div>\n </div>\n );\n}\n\n/** The entry's mark: a square for a measurement, a stroke for a quantity. A hidden one\n * keeps its outline — a mark that vanished would leave a line of text with nothing in\n * front of it.\n *\n * A stroke is an SVG line with the chart's own `strokeDasharray`, not a CSS gradient\n * approximating it: a gradient can say \"solid or dashed\" and nothing more. */\nfunction LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {\n if (entry.marker === \"stroke\") {\n return (\n <svg aria-hidden viewBox=\"0 0 20 2\" className=\"h-0.5 w-5 shrink-0 overflow-visible\">\n <line\n x1={0}\n y1={1}\n x2={20}\n y2={1}\n stroke={entry.color}\n strokeWidth={2}\n strokeDasharray={strokeDash(entry.dash ?? 0)}\n opacity={off ? 0.35 : 1}\n />\n </svg>\n );\n }\n return (\n <span\n aria-hidden\n className=\"h-2.5 w-2.5 shrink-0 rounded-[3px]\"\n style={{\n backgroundColor: off ? \"transparent\" : entry.color,\n boxShadow: `inset 0 0 0 1.5px ${entry.color}`,\n }}\n />\n );\n}\n"],"mappings":";AA8IU,SAaE,KAbF;AAvIV,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,mCAA2D;AAkB7D,MAAM,kBAAmD;AAAA,EAC9D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,SAAS,WAAW,OAAmC;AAC5D,QAAM,IAAI,gBAAgB;AAC1B,SAAO,iBAAkB,KAAK,MAAM,KAAK,IAAI,IAAK,KAAK,CAAC;AAC1D;AAQO,MAAM,YAAY;AASlB,SAAS,aAAa,QAA6B,KAAkC;AAC1F,QAAM,OAAO,IAAI,IAAI,MAAM;AAC3B,MAAI,CAAC,KAAK,OAAO,GAAG,EAAG,MAAK,IAAI,GAAG;AACnC,SAAO;AACT;AAwDO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,aAAa;AAAA,EACb;AAAA,EACA,QAAQ;AACV,GAAsB;AACpB,QAAM,SAAS,aAAa,eAAe,6BAA6B,UAAU;AAClF,MAAI,QAAQ,UAAU,aAAa,IAAI,GAAI,QAAO;AAClD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY,OAAO;AAAA,MACnB,WAAW;AAAA,QACT;AAAA,QACA,gBAAgB,aACZ,wCACA;AAAA,QACJ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,UAAU;AACtB,cAAM,MAAM,OAAO,IAAI,MAAM,GAAG;AAChC,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YAGL,gBAAc,CAAC;AAAA,YACf,SAAS,MAAM,SAAS,MAAM,GAAG;AAAA,YACjC,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA,OAAO;AAAA,YACT;AAAA,YAEA;AAAA,kCAAC,cAAW,OAAc,KAAU;AAAA,cACnC,MAAM;AAAA;AAAA;AAAA,UAbF,MAAM;AAAA,QAcb;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;AASO,SAAS,aAAa,EAAE,UAAU,UAAU,GAAgD;AACjG,SACE,oBAAC,SAAI,WAAW,GAAG,6CAA6C,SAAS,GAAI,UAAS;AAE1F;AASO,SAAS,YAAY,EAAE,OAAO,SAAS,GAA8C;AAC1F,SACE,qBAAC,SAAI,WAAU,2CACb;AAAA,wBAAC,UAAK,WAAU,8EACb,iBACH;AAAA,IACA,oBAAC,SAAI,WAAU,uCAAuC,UAAS;AAAA,KACjE;AAEJ;AAQA,SAAS,WAAW,EAAE,OAAO,IAAI,GAAyC;AACxE,MAAI,MAAM,WAAW,UAAU;AAC7B,WACE,oBAAC,SAAI,eAAW,MAAC,SAAQ,YAAW,WAAU,uCAC5C;AAAA,MAAC;AAAA;AAAA,QACC,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,QAAQ,MAAM;AAAA,QACd,aAAa;AAAA,QACb,iBAAiB,WAAW,MAAM,QAAQ,CAAC;AAAA,QAC3C,SAAS,MAAM,OAAO;AAAA;AAAA,IACxB,GACF;AAAA,EAEJ;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,eAAW;AAAA,MACX,WAAU;AAAA,MACV,OAAO;AAAA,QACL,iBAAiB,MAAM,gBAAgB,MAAM;AAAA,QAC7C,WAAW,qBAAqB,MAAM,KAAK;AAAA,MAC7C;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/toggle-legend.tsx"],"sourcesContent":["// A legend whose entries are SWITCHES — and the stroke table it shares with\n// `SeriesChart`. Lifted out of lenkbank's measurement plots, where ten legends over\n// thirteen charts took channels on and off the picture.\n//\n// No recharts in this module: a legend of switches is plain buttons, and it is as\n// useful under a chart the consumer drew by hand as under `SeriesChart`.\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_SERIES_CHART_LABELS, type SeriesChartLabels } from \"./series-chart-labels\";\n\n/**\n * The strokes a reader can tell apart, in the order they are handed out.\n *\n * For the charts where the colour is already spoken for: a comparison draws each\n * measurement in its own colour, so a chart carrying several CHANNELS of each has\n * nothing left but the stroke — and \"solid versus dashed\" only gets you two.\n *\n * **Five, and the fifth is the last one.** They are the draughtsman's line types, in\n * the draughtsman's order — continuous, dashed, dotted, dash-dot, dash-dot-dot — the\n * one family of five a century of drawings has already proved legible at a hair's\n * width. A sixth is not distinguishable from one of these at two pixels wide, so a\n * chart needing one is a chart that should be split.\n *\n * Kept here, beside the legend's own mark, and read by `SeriesChart` from here — so\n * the key under a chart and the lines on it cannot disagree.\n */\nexport const STROKE_PATTERNS: readonly (string | undefined)[] = [\n undefined,\n \"5 4\",\n \"1 3\",\n \"9 3 2 3\",\n \"9 3 2 3 2 3\",\n];\n\n/** The dash array for the nth pattern, wrapping past the fifth. */\nexport function strokeDash(order: number): string | undefined {\n const n = STROKE_PATTERNS.length;\n return STROKE_PATTERNS[((Math.trunc(order) % n) + n) % n];\n}\n\n/** Which of the patterns a `step` series is drawn in.\n *\n * A whole-number channel that jumps rather than travels is drawn dashed, and it has\n * to be one of {@link STROKE_PATTERNS} rather than a dash array written out at the\n * call site: a legend entry says its pattern by index, so a step line drawn in\n * anything else would be a legend promising a solid line for a dashed one. */\nexport const STEP_DASH = 1;\n\n/**\n * One key switched, as a new hidden set.\n *\n * Every caller writes `onToggle={(key) => setHidden(toggleHidden(hidden, key))}`. A\n * new set rather than a mutated one, because the state is read during render and a\n * mutation of something a component already holds is invisible to React.\n */\nexport function toggleHidden(hidden: ReadonlySet<string>, key: string): ReadonlySet<string> {\n const next = new Set(hidden);\n if (!next.delete(key)) next.add(key);\n return next;\n}\n\nexport interface LegendEntry {\n key: string;\n label: ReactNode;\n /** Any CSS colour — `paletteFor(i)` for the kit's own ramp. */\n color: string;\n /**\n * What the entry's mark looks like. `swatch` — a filled square, the default — says\n * WHICH MEASUREMENT. `stroke` says WHICH QUANTITY, for the charts that carry both at\n * once and tell them apart by colour and by dash.\n */\n marker?: \"swatch\" | \"stroke\";\n /** Which of {@link STROKE_PATTERNS}, when the marker is a stroke — the same index\n * `SeriesChartSeries.dash` takes, so a legend cannot promise a dot-dash the plot\n * draws dashed. */\n dash?: number;\n}\n\nexport interface ToggleLegendProps {\n entries: LegendEntry[];\n /** The keys currently off the chart. */\n hidden: ReadonlySet<string>;\n onToggle: (key: string) => void;\n /** `vertical` is for a legend standing BESIDE the charts rather than under them —\n * what a row of charts sharing one legend wants, since under two charts there is\n * no \"under\", and putting it under one says it belongs to that one. */\n orientation?: \"horizontal\" | \"vertical\";\n /**\n * Draw a legend of ONE entry rather than nothing.\n *\n * A single line's legend is usually noise. It is not where the legend is the only\n * SWITCH that line has — a series that starts hidden and has no entry to bring it\n * back is gone for good.\n */\n showSingle?: boolean;\n className?: string;\n /** Per-instance strings over `<UiKitProvider labels={{ seriesChart }}>`. */\n labels?: Partial<SeriesChartLabels>;\n}\n\n/**\n * A legend whose entries are switches.\n *\n * `ChartLegendContent` with `onItemClick`/`activeKey` is the other kind: clicking an\n * entry HIGHLIGHTS it and dims the rest, which is right when every series belongs on\n * the chart and one of them is momentarily interesting. This is for the opposite —\n * three coordinates on one axis where y runs to 800 and z barely moves, and the only\n * way to see z is to take y off the chart entirely. Any number may be off at once.\n *\n * A hidden entry keeps its mark and its place. Removing it would reflow the legend on\n * every click and lose the one thing it is for: saying what COULD be shown.\n *\n * Each entry is a real `<button>` with `aria-pressed` — pressed means \"on the chart\" —\n * so Tab reaches every switch and Space/Enter flips it.\n */\nexport function ToggleLegend({\n entries,\n hidden,\n onToggle,\n orientation = \"horizontal\",\n showSingle = false,\n className,\n labels: labelsProp,\n}: ToggleLegendProps) {\n const labels = useKitLabels(\"seriesChart\", DEFAULT_SERIES_CHART_LABELS, labelsProp);\n if (entries.length < (showSingle ? 1 : 2)) return null;\n return (\n <div\n role=\"group\"\n aria-label={labels.legend}\n className={cn(\n \"flex gap-x-3 gap-y-1\",\n orientation === \"vertical\"\n ? \"flex-col items-start justify-center\"\n : \"mt-2 flex-wrap items-center\",\n className,\n )}\n >\n {entries.map((entry) => {\n const off = hidden.has(entry.key);\n return (\n <button\n key={entry.key}\n type=\"button\"\n // Pressed rather than a checkbox: it is a control over what the chart\n // draws, and \"not pressed\" is the same sentence the dimmed mark says.\n aria-pressed={!off}\n onClick={() => onToggle(entry.key)}\n className={cn(\n \"flex items-center gap-1.5 rounded text-start text-[11px] text-[var(--text-secondary)] transition-opacity hover:opacity-80\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n off && \"opacity-35\",\n )}\n >\n <LegendMark entry={entry} off={off} />\n {entry.label}\n </button>\n );\n })}\n </div>\n );\n}\n\n/**\n * The column a vertical legend stands in: beside the charts, on their horizontal\n * centre line. `h-full` + `justify-center`, so it centres in a grid cell or a flex row\n * that stretches it — and stacks whatever it is given, which is what a panel showing\n * two legends at once needs. The width is the caller's: how wide a legend column is\n * is a fact about that screen's layout.\n */\nexport function LegendColumn({ children, className }: { children: ReactNode; className?: string }) {\n return (\n <div className={cn(\"flex h-full flex-col justify-center gap-3\", className)}>{children}</div>\n );\n}\n\n/**\n * A legend and what it is a legend OF, as a headed column.\n *\n * For legends that are several vocabularies at once — which measurement and which\n * channel — so the entries are read down a group and the groups across. Wrapped rows\n * would put the second half of one group on a line under the first half of the next.\n */\nexport function LegendGroup({ title, children }: { title: ReactNode; children: ReactNode }) {\n return (\n <div className=\"flex min-w-0 flex-col items-start gap-1\">\n <span className=\"text-[10px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {title}\n </span>\n <div className=\"flex flex-col items-start gap-y-0.5\">{children}</div>\n </div>\n );\n}\n\n/** The entry's mark: a square for a measurement, a stroke for a quantity. A hidden one\n * keeps its outline — a mark that vanished would leave a line of text with nothing in\n * front of it.\n *\n * A stroke is an SVG line with the chart's own `strokeDasharray`, not a CSS gradient\n * approximating it: a gradient can say \"solid or dashed\" and nothing more.\n *\n * A hidden stroke is NOT faded here: the button around it already is (`opacity-35`),\n * and the line used to take its own 0.35 on top — 0.35 × 0.35, about 12 %, a mark\n * gone rather than dimmed, while the square beside it read at the full 35 %. */\nfunction LegendMark({ entry, off }: { entry: LegendEntry; off: boolean }) {\n if (entry.marker === \"stroke\") {\n return (\n <svg aria-hidden viewBox=\"0 0 20 2\" className=\"h-0.5 w-5 shrink-0 overflow-visible\">\n <line\n x1={0}\n y1={1}\n x2={20}\n y2={1}\n stroke={entry.color}\n strokeWidth={2}\n strokeDasharray={strokeDash(entry.dash ?? 0)}\n />\n </svg>\n );\n }\n return (\n <span\n aria-hidden\n className=\"h-2.5 w-2.5 shrink-0 rounded-[3px]\"\n style={{\n backgroundColor: off ? \"transparent\" : entry.color,\n boxShadow: `inset 0 0 0 1.5px ${entry.color}`,\n }}\n />\n );\n}\n"],"mappings":";AA8IU,SAaE,KAbF;AAvIV,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,mCAA2D;AAkB7D,MAAM,kBAAmD;AAAA,EAC9D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,SAAS,WAAW,OAAmC;AAC5D,QAAM,IAAI,gBAAgB;AAC1B,SAAO,iBAAkB,KAAK,MAAM,KAAK,IAAI,IAAK,KAAK,CAAC;AAC1D;AAQO,MAAM,YAAY;AASlB,SAAS,aAAa,QAA6B,KAAkC;AAC1F,QAAM,OAAO,IAAI,IAAI,MAAM;AAC3B,MAAI,CAAC,KAAK,OAAO,GAAG,EAAG,MAAK,IAAI,GAAG;AACnC,SAAO;AACT;AAwDO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,aAAa;AAAA,EACb;AAAA,EACA,QAAQ;AACV,GAAsB;AACpB,QAAM,SAAS,aAAa,eAAe,6BAA6B,UAAU;AAClF,MAAI,QAAQ,UAAU,aAAa,IAAI,GAAI,QAAO;AAClD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY,OAAO;AAAA,MACnB,WAAW;AAAA,QACT;AAAA,QACA,gBAAgB,aACZ,wCACA;AAAA,QACJ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,UAAU;AACtB,cAAM,MAAM,OAAO,IAAI,MAAM,GAAG;AAChC,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YAGL,gBAAc,CAAC;AAAA,YACf,SAAS,MAAM,SAAS,MAAM,GAAG;AAAA,YACjC,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA,OAAO;AAAA,YACT;AAAA,YAEA;AAAA,kCAAC,cAAW,OAAc,KAAU;AAAA,cACnC,MAAM;AAAA;AAAA;AAAA,UAbF,MAAM;AAAA,QAcb;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;AASO,SAAS,aAAa,EAAE,UAAU,UAAU,GAAgD;AACjG,SACE,oBAAC,SAAI,WAAW,GAAG,6CAA6C,SAAS,GAAI,UAAS;AAE1F;AASO,SAAS,YAAY,EAAE,OAAO,SAAS,GAA8C;AAC1F,SACE,qBAAC,SAAI,WAAU,2CACb;AAAA,wBAAC,UAAK,WAAU,8EACb,iBACH;AAAA,IACA,oBAAC,SAAI,WAAU,uCAAuC,UAAS;AAAA,KACjE;AAEJ;AAYA,SAAS,WAAW,EAAE,OAAO,IAAI,GAAyC;AACxE,MAAI,MAAM,WAAW,UAAU;AAC7B,WACE,oBAAC,SAAI,eAAW,MAAC,SAAQ,YAAW,WAAU,uCAC5C;AAAA,MAAC;AAAA;AAAA,QACC,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,QAAQ,MAAM;AAAA,QACd,aAAa;AAAA,QACb,iBAAiB,WAAW,MAAM,QAAQ,CAAC;AAAA;AAAA,IAC7C,GACF;AAAA,EAEJ;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,eAAW;AAAA,MACX,WAAU;AAAA,MACV,OAAO;AAAA,QACL,iBAAiB,MAAM,gBAAgB,MAAM;AAAA,QAC7C,WAAW,qBAAqB,MAAM,KAAK;AAAA,MAC7C;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -2,7 +2,13 @@ import * as react from 'react';
2
2
  import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
3
  import { AnchorRect } from '../hooks/use-anchored-rect.js';
4
4
 
5
- type TooltipSide = "top" | "bottom" | "left" | "right";
5
+ /** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the
6
+ * right in LTR and the left in RTL — and are what a layout that mirrors should ask
7
+ * for. `left` / `right` stay physical, for a bubble tied to something that does not
8
+ * mirror (a chart axis, a map). */
9
+ type TooltipSide = "top" | "bottom" | "left" | "right" | "start" | "end";
10
+ /** The placement the maths works in: a logical side resolved against the trigger. */
11
+ type PhysicalSide = "top" | "bottom" | "left" | "right";
6
12
  /**
7
13
  * `extends ComponentPropsWithoutRef<"span">` because the wrapper this renders IS a span,
8
14
  * and a tooltip is the component a caller most often needs to reach past: it sits
@@ -86,7 +92,7 @@ interface TooltipPlacement {
86
92
  left: number;
87
93
  top: number;
88
94
  /** Which side it ended up on, which need not be the one that was asked for. */
89
- side: TooltipSide;
95
+ side: PhysicalSide;
90
96
  }
91
97
  /**
92
98
  * Where the bubble actually goes, given how big it turned out to be.
@@ -110,6 +116,6 @@ interface TooltipPlacement {
110
116
  * a layout: the caller supplies the trigger's rect, the bubble's own size and
111
117
  * the window.
112
118
  */
113
- declare function placeTooltip(r: AnchorRect, side: TooltipSide, size: TooltipSize, viewport: TooltipViewport): TooltipPlacement;
119
+ declare function placeTooltip(r: AnchorRect, side: PhysicalSide, size: TooltipSize, viewport: TooltipViewport): TooltipPlacement;
114
120
 
115
- export { Tooltip, type TooltipPlacement, type TooltipProps, type TooltipSize, type TooltipViewport, placeTooltip };
121
+ export { Tooltip, type TooltipPlacement, type TooltipProps, type TooltipSide, type TooltipSize, type TooltipViewport, placeTooltip };
@@ -12,12 +12,22 @@ import { createPortal } from "react-dom";
12
12
  import { cn } from "../lib/cn.js";
13
13
  import { useEscapeKey } from "../hooks/use-dismiss.js";
14
14
  import { useAnchoredRect } from "../hooks/use-anchored-rect.js";
15
+ import { dirOf } from "../lib/direction.js";
16
+ function physicalSide(side, dir) {
17
+ if (side === "start") return dir === "rtl" ? "right" : "left";
18
+ if (side === "end") return dir === "rtl" ? "left" : "right";
19
+ return side;
20
+ }
15
21
  const TOOLTIP_SURFACE = "w-max max-w-[min(20rem,calc(100vw-1rem))] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-lg";
16
22
  const sidePositionClass = {
17
23
  top: "bottom-full left-1/2 -translate-x-1/2 mb-1",
18
24
  bottom: "top-full left-1/2 -translate-x-1/2 mt-1",
19
25
  left: "right-full top-1/2 -translate-y-1/2 mr-1",
20
- right: "left-full top-1/2 -translate-y-1/2 ml-1"
26
+ right: "left-full top-1/2 -translate-y-1/2 ml-1",
27
+ // Logical insets, so CSS resolves the side from the inherited direction with no
28
+ // JavaScript: `end-full` pins the bubble's END edge to the trigger's start.
29
+ start: "end-full top-1/2 -translate-y-1/2 me-1",
30
+ end: "start-full top-1/2 -translate-y-1/2 ms-1"
21
31
  };
22
32
  function Tooltip({
23
33
  label,
@@ -168,6 +178,11 @@ function PortalTooltip({
168
178
  const triggerRef = useRef(null);
169
179
  const bubbleRef = useRef(null);
170
180
  const [visible, setVisible] = useState(false);
181
+ const [dir, setDir] = useState("ltr");
182
+ const show = (el) => {
183
+ setDir(dirOf(el));
184
+ setVisible(true);
185
+ };
171
186
  const id = useId();
172
187
  useEscapeKey(() => setVisible(false), visible);
173
188
  const rect = useAnchoredRect(triggerRef, visible);
@@ -183,8 +198,9 @@ function PortalTooltip({
183
198
  return previous && sameRoom(previous, next) ? previous : next;
184
199
  });
185
200
  }, [visible, rect, label]);
186
- const point = rect ? tooltipAnchor(rect, side) : null;
187
- const placed = rect && room ? placeTooltip(rect, side, room.size, room.viewport) : null;
201
+ const physical = physicalSide(side, dir);
202
+ const point = rect ? tooltipAnchor(rect, physical) : null;
203
+ const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;
188
204
  return /* @__PURE__ */ jsxs(Fragment, { children: [
189
205
  /* @__PURE__ */ jsx(
190
206
  "span",
@@ -192,9 +208,9 @@ function PortalTooltip({
192
208
  ...rest,
193
209
  ref: triggerRef,
194
210
  className: cn("relative inline-flex", className),
195
- onMouseEnter: () => setVisible(true),
211
+ onMouseEnter: (e) => show(e.currentTarget),
196
212
  onMouseLeave: () => setVisible(false),
197
- onFocus: () => setVisible(true),
213
+ onFocus: (e) => show(e.currentTarget),
198
214
  onBlur: () => setVisible(false),
199
215
  children: describedBy(children, visible ? id : void 0)
200
216
  }
@@ -206,12 +222,13 @@ function PortalTooltip({
206
222
  ref: bubbleRef,
207
223
  id,
208
224
  role: "tooltip",
225
+ dir,
209
226
  "data-private": redact ? "" : void 0,
210
227
  style: placed ? { position: "fixed", left: placed.left, top: placed.top } : {
211
228
  position: "fixed",
212
229
  left: point.left,
213
230
  top: point.top,
214
- transform: portalTransformBySide[side]
231
+ transform: portalTransformBySide[physical]
215
232
  },
216
233
  className: cn(TOOLTIP_SURFACE, "pointer-events-none z-50"),
217
234
  children: label