@eifi1/ui-kit 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +5 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +5 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +19 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +56 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +26 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +5 -0
  60. package/dist/components/number-input.d.ts +5 -0
  61. package/dist/components/numpad-sheet.d.ts +5 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/settings-fields.d.ts +2 -0
  78. package/dist/components/skeleton.d.ts +36 -0
  79. package/dist/components/skeleton.js +35 -0
  80. package/dist/components/skeleton.js.map +1 -0
  81. package/dist/components/stat-tile.d.ts +60 -6
  82. package/dist/components/stat-tile.js +60 -26
  83. package/dist/components/stat-tile.js.map +1 -1
  84. package/dist/components/table.d.ts +83 -0
  85. package/dist/components/table.js +166 -0
  86. package/dist/components/table.js.map +1 -0
  87. package/dist/components/time-input.d.ts +2 -0
  88. package/dist/components/toggle-legend.d.ts +3 -2
  89. package/dist/components/toggle-legend.js +1 -1
  90. package/dist/components/toggle-legend.js.map +1 -1
  91. package/dist/components/tooltip.d.ts +10 -4
  92. package/dist/components/tooltip.js +23 -6
  93. package/dist/components/tooltip.js.map +1 -1
  94. package/dist/components/tree-view.d.ts +129 -0
  95. package/dist/components/tree-view.js +376 -0
  96. package/dist/components/tree-view.js.map +1 -0
  97. package/dist/components/treemap.d.ts +9 -2
  98. package/dist/components/treemap.js +2 -1
  99. package/dist/components/treemap.js.map +1 -1
  100. package/dist/components/ui.d.ts +92 -14
  101. package/dist/components/ui.js +112 -30
  102. package/dist/components/ui.js.map +1 -1
  103. package/dist/components/use-table-state.d.ts +1 -1
  104. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  105. package/dist/data-table.d.ts +2 -2
  106. package/dist/data-table.js.map +1 -1
  107. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  108. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  109. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  110. package/dist/hooks/use-debounce.d.ts +43 -0
  111. package/dist/hooks/use-debounce.js +56 -0
  112. package/dist/hooks/use-debounce.js.map +1 -0
  113. package/dist/hooks/use-file-drop.d.ts +72 -0
  114. package/dist/hooks/use-file-drop.js +57 -0
  115. package/dist/hooks/use-file-drop.js.map +1 -0
  116. package/dist/i18n/defaults.d.ts +5 -0
  117. package/dist/i18n/defaults.js +7 -1
  118. package/dist/i18n/defaults.js.map +1 -1
  119. package/dist/i18n/kit-labels.d.ts +17 -0
  120. package/dist/i18n/kit-labels.js +6 -2
  121. package/dist/i18n/kit-labels.js.map +1 -1
  122. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  123. package/dist/i18n/locales/de-CH-informal.js +8 -0
  124. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  125. package/dist/i18n/locales/de-CH.d.ts +5 -0
  126. package/dist/i18n/locales/de-CH.js +1 -14
  127. package/dist/i18n/locales/de-CH.js.map +1 -1
  128. package/dist/i18n/locales/de-informal.d.ts +64 -0
  129. package/dist/i18n/locales/de-informal.js +37 -0
  130. package/dist/i18n/locales/de-informal.js.map +1 -0
  131. package/dist/i18n/locales/de.d.ts +5 -0
  132. package/dist/i18n/locales/de.js +25 -0
  133. package/dist/i18n/locales/de.js.map +1 -1
  134. package/dist/i18n/locales/es.d.ts +5 -0
  135. package/dist/i18n/locales/es.js +25 -0
  136. package/dist/i18n/locales/es.js.map +1 -1
  137. package/dist/i18n/locales/fr.d.ts +5 -0
  138. package/dist/i18n/locales/fr.js +25 -0
  139. package/dist/i18n/locales/fr.js.map +1 -1
  140. package/dist/i18n/locales/hu.d.ts +5 -0
  141. package/dist/i18n/locales/hu.js +25 -0
  142. package/dist/i18n/locales/hu.js.map +1 -1
  143. package/dist/i18n/locales/it.d.ts +5 -0
  144. package/dist/i18n/locales/it.js +25 -0
  145. package/dist/i18n/locales/it.js.map +1 -1
  146. package/dist/i18n/locales/zh.d.ts +5 -0
  147. package/dist/i18n/locales/zh.js +25 -0
  148. package/dist/i18n/locales/zh.js.map +1 -1
  149. package/dist/i18n/swiss.d.ts +13 -0
  150. package/dist/i18n/swiss.js +19 -0
  151. package/dist/i18n/swiss.js.map +1 -0
  152. package/dist/index.d.ts +26 -11
  153. package/dist/index.js +22 -0
  154. package/dist/index.js.map +1 -1
  155. package/dist/lib/dates.d.ts +37 -1
  156. package/dist/lib/dates.js +19 -0
  157. package/dist/lib/dates.js.map +1 -1
  158. package/dist/rhf/form.d.ts +2 -0
  159. package/dist/rhf.d.ts +2 -0
  160. package/dist/search/command-palette.d.ts +15 -1
  161. package/dist/search/command-palette.js +10 -2
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/shell/app-shell.js +2 -2
  164. package/dist/shell/app-shell.js.map +1 -1
  165. package/dist/wizard/stepper-nav.d.ts +35 -2
  166. package/dist/wizard/stepper-nav.js +54 -22
  167. package/dist/wizard/stepper-nav.js.map +1 -1
  168. package/dist/wizard/types.d.ts +107 -5
  169. package/dist/wizard/types.js +2 -1
  170. package/dist/wizard/types.js.map +1 -1
  171. package/dist/wizard/use-wizard.js +141 -21
  172. package/dist/wizard/use-wizard.js.map +1 -1
  173. package/dist/wizard/wizard-summary.d.ts +10 -2
  174. package/dist/wizard/wizard-summary.js +3 -1
  175. package/dist/wizard/wizard-summary.js.map +1 -1
  176. package/dist/wizard.d.ts +4 -1
  177. package/dist/wizard.js.map +1 -1
  178. package/package.json +1 -1
  179. package/src/components/alert-banner.tsx +211 -20
  180. package/src/components/button-group.tsx +75 -0
  181. package/src/components/chart-zoom.tsx +158 -20
  182. package/src/components/chip.tsx +160 -9
  183. package/src/components/confirm-dialog.tsx +242 -0
  184. package/src/components/copy-button.tsx +158 -0
  185. package/src/components/data-table-pagination.tsx +19 -6
  186. package/src/components/data-table-sort.ts +49 -10
  187. package/src/components/data-table.tsx +293 -24
  188. package/src/components/date-picker.tsx +262 -57
  189. package/src/components/description-list.tsx +174 -0
  190. package/src/components/disclosure.tsx +58 -5
  191. package/src/components/file-button.tsx +22 -2
  192. package/src/components/file-dropzone.tsx +198 -100
  193. package/src/components/floating-panel.tsx +341 -0
  194. package/src/components/full-bleed-dialog.tsx +48 -5
  195. package/src/components/modal.tsx +9 -2
  196. package/src/components/progress-bar.tsx +163 -0
  197. package/src/components/scroll-area.tsx +129 -0
  198. package/src/components/separator.tsx +37 -0
  199. package/src/components/series-chart-ticks.ts +135 -0
  200. package/src/components/series-chart.tsx +934 -67
  201. package/src/components/skeleton.tsx +65 -0
  202. package/src/components/stat-tile.tsx +160 -27
  203. package/src/components/table.tsx +263 -0
  204. package/src/components/toggle-legend.tsx +4 -3
  205. package/src/components/tooltip.tsx +40 -12
  206. package/src/components/tree-view.tsx +589 -0
  207. package/src/components/treemap.tsx +10 -2
  208. package/src/components/ui.tsx +268 -42
  209. package/src/data-table.ts +2 -0
  210. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  211. package/src/hooks/use-debounce.ts +105 -0
  212. package/src/hooks/use-file-drop.ts +123 -0
  213. package/src/i18n/defaults.ts +6 -0
  214. package/src/i18n/kit-labels.tsx +19 -0
  215. package/src/i18n/locales/de-CH-informal.ts +10 -0
  216. package/src/i18n/locales/de-CH.ts +1 -16
  217. package/src/i18n/locales/de-informal.ts +61 -0
  218. package/src/i18n/locales/de.ts +25 -0
  219. package/src/i18n/locales/es.ts +25 -0
  220. package/src/i18n/locales/fr.ts +25 -0
  221. package/src/i18n/locales/hu.ts +25 -0
  222. package/src/i18n/locales/it.ts +25 -0
  223. package/src/i18n/locales/zh.ts +25 -0
  224. package/src/i18n/swiss.ts +25 -0
  225. package/src/index.ts +29 -0
  226. package/src/lib/dates.ts +48 -0
  227. package/src/search/command-palette.tsx +29 -3
  228. package/src/shell/app-shell.tsx +2 -2
  229. package/src/wizard/stepper-nav.tsx +102 -28
  230. package/src/wizard/types.ts +106 -4
  231. package/src/wizard/use-wizard.ts +211 -31
  232. package/src/wizard/wizard-summary.tsx +24 -12
  233. package/src/wizard.ts +3 -2
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/tooltip.tsx"],"sourcesContent":["import {\n cloneElement,\n isValidElement,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type ReactElement,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useAnchoredRect, type AnchorRect } from \"../hooks/use-anchored-rect\";\n\ntype TooltipSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\n/** The floating bubble itself. Uses the shared surface/border/text tokens so it\n * reads as part of the app's chrome (like the top bar and cards) rather than the\n * cold slate pill it used to be.\n *\n * `w-max` keeps a short label on one line — the old `whitespace-nowrap` did that\n * too, but it also let a sentence-length label grow without bound, and a bubble\n * wider than the space beside its trigger gets clipped by whatever overflow\n * container it sits in. So cap it and let long text wrap instead. The cap tracks\n * the viewport as well, for narrow screens where 20rem is already most of it.\n * `side` is a preference rather than an instruction for the PORTALLED variant,\n * which measures the bubble and turns it round when it would not fit\n * (Steering Design feedback #126). The CSS-only one never learns its own size,\n * so there `side` is still the whole of the placement. */\nconst TOOLTIP_SURFACE =\n \"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\";\n\nconst sidePositionClass: Record<TooltipSide, string> = {\n top: \"bottom-full left-1/2 -translate-x-1/2 mb-1\",\n bottom: \"top-full left-1/2 -translate-x-1/2 mt-1\",\n left: \"right-full top-1/2 -translate-y-1/2 mr-1\",\n right: \"left-full top-1/2 -translate-y-1/2 ml-1\",\n};\n\n/**\n * `extends ComponentPropsWithoutRef<\"span\">` because the wrapper this renders IS a span,\n * and a tooltip is the component a caller most often needs to reach past: it sits\n * between the layout and the control, so a `data-tour` anchor, a test id or an\n * `aria-label` aimed at the trigger used to be swallowed by it.\n *\n * ⚠️ On the empty-label branch there is no wrapper at all, and therefore nothing for\n * those attributes to land on — see the note in the body.\n */\nexport interface TooltipProps extends ComponentPropsWithoutRef<\"span\"> {\n label: ReactNode;\n side?: TooltipSide;\n className?: string;\n portal?: boolean;\n /** Tag the bubble `data-private`, for a label that repeats the user's own data. */\n redact?: boolean;\n children: ReactNode;\n}\n\n/** What each variant below takes: the resolved `side`, and every span attribute the\n * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */\ntype TooltipVariantProps = Omit<TooltipProps, \"side\" | \"portal\"> & { side: TooltipSide };\n\n/**\n * Hover/focus label for a control.\n *\n * Two implementations, and the choice matters more than it looks. The default is\n * CSS-only: the bubble is always mounted next to the trigger and fades in on\n * `:hover`, which costs no state and works in a plain render test. The `portal`\n * variant mounts the bubble in `document.body` only while hovered, positioned by\n * measurement.\n *\n * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs\n * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is\n * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as\n * long as the bubble is tagged. It is not tagged by default, because most labels are\n * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated\n * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,\n * the bubble spells the value out on hover.\n *\n * ⚠️ **An empty label renders nothing at all.** `title={payee ?? \"\"}` is an ordinary\n * shape at a call site that reveals truncated text, and the native attribute answers\n * it by showing no tooltip. A component that faithfully rendered an empty bubble\n * would be a worse `title`, so the emptiness check is here rather than at every call\n * site that could forget it.\n *\n * ⚠️ **The bubble describes its trigger, which means cloning it.** `role=\"tooltip\"` is\n * a name for a box, not a relationship — so for as long as nothing referenced the\n * bubble, the label reached the pointer and nobody else. That is worst on the call\n * sites that need it most: the kit's icon-only buttons, where the tooltip IS the\n * label. `aria-describedby` has to sit on the focusable element, which is the\n * caller's child and not this component's wrapper, so the child is CLONED to carry\n * it. A description the caller already set is appended to, never replaced — a field's\n * error text and its hint bubble both describe it. Children that cannot take props (a\n * fragment, a bare string, several elements) are left exactly as they were.\n *\n * ⚠️ **Escape dismisses it (WCAG 1.4.13).** Anything that appears on hover or focus has\n * to be dismissible without moving the pointer, and a bubble is opaque: it lands over\n * the row, field or figure you were reading, and the only way out of it used to be to\n * point somewhere else — which is precisely what you cannot do when what you need to\n * read is underneath it. The listener is the document's rather than the wrapper's\n * because the pointer opens this with the keyboard focus somewhere else entirely, and\n * it is subscribed only while a bubble is actually up: the CSS variant is always\n * mounted, and a table of forty tooltips must not mean forty keydown listeners.\n *\n * ⚠️ **Inside a scroll container, use `portal`.** An always-mounted bubble is\n * absolutely positioned, but an absolutely positioned descendant still counts\n * towards its scroll-container ancestor's scrollable overflow — so an invisible\n * bubble on a control near the right edge makes the container scroll sideways\n * with nothing to reveal. That is what Keksdose feedback dev#488 reported on the\n * admin roster: 66px of horizontal scroll on a table that fit, 44px of it owed to\n * tooltips nobody could see. The portalled bubble is `position: fixed` and absent\n * until hovered, so it adds no width — and, being outside the container, it also\n * cannot be clipped by it.\n */\nexport function Tooltip({\n label,\n side = \"top\",\n className,\n portal = false,\n redact = false,\n children,\n ...rest\n}: TooltipProps) {\n // No label, no bubble — and no wrapper either, so a conditional tooltip costs the\n // layout nothing on the branch where it does not apply. `...rest` goes with the\n // wrapper on this branch, which is the documented limit of the pass-through: there is\n // no element left to put an attribute on.\n if (isEmptyLabel(label)) return <>{children}</>;\n if (portal) {\n return (\n <PortalTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n {...rest}\n >\n {children}\n </PortalTooltip>\n );\n }\n // Both variants are separate components so that `Tooltip` itself can keep calling NO\n // hooks: the empty-label branch above returns before either of them, and a hook after\n // a conditional return is a hooks-order bug rather than a style violation.\n return (\n <CssTooltip label={label} side={side} className={className} redact={redact} {...rest}>\n {children}\n </CssTooltip>\n );\n}\n\n/** The always-mounted variant: the bubble sits next to the trigger and CSS fades it in.\n *\n * It holds the little state it does for the two things CSS cannot express — which\n * element to point `aria-describedby` at, and Escape — and not for the fade, which is\n * still `group-hover`/`group-focus-within` and still costs a render nothing. */\nfunction CssTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const id = useId();\n const [hovered, setHovered] = useState(false);\n const [focused, setFocused] = useState(false);\n const [dismissed, setDismissed] = useState(false);\n useEscapeKey(() => setDismissed(true), (hovered || focused) && !dismissed);\n return (\n <span\n // `...rest` first: the four handlers below are what decides whether a bubble is\n // up, and a caller passing an `onFocus` of its own must not replace them.\n {...rest}\n className={cn(\"relative inline-flex group/tooltip\", className)}\n // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing\n // here that can be activated is the caller's child, which keeps every handler it\n // arrived with — so this wrapper needs no role and no key handling of its own.\n // `jsx-a11y/no-static-element-interactions` warns about it all the same, as it\n // already does about the portal variant's identical trigger below; both are left\n // visible rather than silenced, because a rule this package ratchets should be\n // argued with in the backlog and not in a disable comment.\n //\n // Re-armed by the next hover or focus rather than by an effect watching those\n // flags: coming back to a trigger is a fresh request for its label, and an effect\n // would also re-show the bubble under a pointer that never left.\n onMouseEnter={() => {\n setHovered(true);\n setDismissed(false);\n }}\n onMouseLeave={() => setHovered(false)}\n onFocus={() => {\n setFocused(true);\n setDismissed(false);\n }}\n onBlur={() => setFocused(false)}\n >\n {describedBy(children, dismissed ? undefined : id)}\n <span\n id={id}\n role=\"tooltip\"\n // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the\n // bubble out of the accessibility tree as well as off the screen, or a screen\n // reader still reads out the description of a bubble the user just closed.\n hidden={dismissed || undefined}\n data-private={redact ? \"\" : undefined}\n className={cn(\n TOOLTIP_SURFACE,\n \"pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100\",\n sidePositionClass[side],\n )}\n >\n {label}\n </span>\n </span>\n );\n}\n\n/** Hand `children` the bubble's id as an `aria-describedby`, if it is an element that\n * can hold one. `id` is undefined while there is no bubble to point at — a dangling\n * reference describes the trigger as nothing at all, which is worse than silence. */\nfunction describedBy(children: ReactNode, id: string | undefined): ReactNode {\n if (id === undefined || !isValidElement(children)) return children;\n const child = children as ReactElement<{ \"aria-describedby\"?: string }>;\n // Fragments, Suspense and friends are symbol-typed and take no DOM props; cloning one\n // with an aria attribute warns in development and drops it in production.\n if (typeof child.type === \"symbol\") return children;\n const own = child.props[\"aria-describedby\"];\n return cloneElement(child, { \"aria-describedby\": own ? `${own} ${id}` : id });\n}\n\n/** \"Would this bubble be blank.\" Only the values a call site actually produces when\n * it has nothing to say — `\"\"`, `null`, `undefined`, `false` from a `&&` guard. A\n * numeric `0` is a real label and stays one. */\nfunction isEmptyLabel(label: ReactNode): boolean {\n return (\n label == null ||\n label === false ||\n (typeof label === \"string\" && label.trim() === \"\")\n );\n}\n\nconst TOOLTIP_GAP = 4;\n\n/** How close to the viewport edge a bubble may sit. Not zero: a label flush\n * against the glass reads as clipped even when every character is on screen. */\nconst TOOLTIP_MARGIN = 4;\n\nconst portalTransformBySide: Record<TooltipSide, string> = {\n right: \"translate(0, -50%)\",\n left: \"translate(-100%, -50%)\",\n top: \"translate(-50%, -100%)\",\n bottom: \"translate(-50%, 0)\",\n};\n\n/** Anchor point (viewport px) for the tooltip on the given side of `r`. Paired\n * with {@link portalTransformBySide}, which shifts the box onto that point. */\nfunction tooltipAnchor(\n r: AnchorRect,\n side: TooltipSide,\n): { left: number; top: number } {\n switch (side) {\n case \"right\":\n return { left: r.right + TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"left\":\n return { left: r.left - TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"top\":\n return { left: r.left + r.width / 2, top: r.top - TOOLTIP_GAP };\n case \"bottom\":\n return { left: r.left + r.width / 2, top: r.bottom + TOOLTIP_GAP };\n }\n}\n\nexport interface TooltipSize {\n width: number;\n height: number;\n}\n\nexport interface TooltipViewport {\n width: number;\n height: number;\n}\n\nexport interface TooltipPlacement {\n left: number;\n top: number;\n /** Which side it ended up on, which need not be the one that was asked for. */\n side: TooltipSide;\n}\n\nconst opposite: Record<TooltipSide, TooltipSide> = {\n left: \"right\",\n right: \"left\",\n top: \"bottom\",\n bottom: \"top\",\n};\n\n/** Whether the bubble clears the viewport edge on `side` of the trigger. */\nfunction roomOn(\n r: AnchorRect,\n side: TooltipSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): boolean {\n switch (side) {\n case \"left\":\n return r.left - TOOLTIP_GAP - size.width >= TOOLTIP_MARGIN;\n case \"right\":\n return r.right + TOOLTIP_GAP + size.width <= viewport.width - TOOLTIP_MARGIN;\n case \"top\":\n return r.top - TOOLTIP_GAP - size.height >= TOOLTIP_MARGIN;\n case \"bottom\":\n return r.bottom + TOOLTIP_GAP + size.height <= viewport.height - TOOLTIP_MARGIN;\n }\n}\n\nfunction sameRoom(\n a: { size: TooltipSize; viewport: TooltipViewport },\n b: { size: TooltipSize; viewport: TooltipViewport },\n): boolean {\n return (\n a.size.width === b.size.width &&\n a.size.height === b.size.height &&\n a.viewport.width === b.viewport.width &&\n a.viewport.height === b.viewport.height\n );\n}\n\nfunction clamp(value: number, low: number, high: number): number {\n // `high` first, so a bubble taller or wider than the viewport is pinned to the\n // top-left corner rather than to the bottom-right one — the start of a label\n // is the half worth keeping.\n return Math.max(low, Math.min(value, high));\n}\n\n/**\n * Where the bubble actually goes, given how big it turned out to be.\n *\n * Two rules, and they are separate because they fix separate failures.\n *\n * **Turn round when the preferred side has no room.** `side` says which side of\n * the trigger the label reads best on, and on a form near the left edge of the\n * window that side is off the screen — the capped bubble can only wrap, not\n * move, so what the reader gets is a sentence with its first half outside the\n * glass. Flipped only when the *other* side is genuinely better: a trigger in a\n * viewport too narrow for the bubble either way keeps the side it asked for, and\n * the clamp below does what it can.\n *\n * **Then clamp both axes.** The cross axis is the one that needs it — a `top`\n * bubble is centred on the trigger, so a trigger near the left edge pushes half\n * the label off even though the side it is on is right — and clamping the main\n * axis too costs nothing and covers the flip having nowhere to land.\n *\n * Pure, and measured in viewport pixels throughout, so it can be tested without\n * a layout: the caller supplies the trigger's rect, the bubble's own size and\n * the window.\n */\nexport function placeTooltip(\n r: AnchorRect,\n side: TooltipSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): TooltipPlacement {\n const chosen =\n roomOn(r, side, size, viewport) || !roomOn(r, opposite[side], size, viewport)\n ? side\n : opposite[side];\n const point = tooltipAnchor(r, chosen);\n const box =\n chosen === \"left\"\n ? { left: point.left - size.width, top: point.top - size.height / 2 }\n : chosen === \"right\"\n ? { left: point.left, top: point.top - size.height / 2 }\n : chosen === \"top\"\n ? { left: point.left - size.width / 2, top: point.top - size.height }\n : { left: point.left - size.width / 2, top: point.top };\n return {\n left: clamp(box.left, TOOLTIP_MARGIN, viewport.width - size.width - TOOLTIP_MARGIN),\n top: clamp(box.top, TOOLTIP_MARGIN, viewport.height - size.height - TOOLTIP_MARGIN),\n side: chosen,\n };\n}\n\nfunction PortalTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n const [visible, setVisible] = useState(false);\n const id = useId();\n // Escape closes it outright, since this variant's bubble only exists while it is\n // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG\n // 1.4.13 asks for: dismissible now, still available when you ask again.\n useEscapeKey(() => setVisible(false), visible);\n // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;\n // here we only map the rect to a side-specific anchor point.\n const rect = useAnchoredRect(triggerRef, visible);\n // The bubble's own size and the window it has to fit in — neither of which is\n // knowable in render: the width is whatever the label wrapped to inside the\n // cap, and reading `window` while rendering is not a pure thing to do. Both\n // are taken in a LAYOUT effect, so the correction lands before the browser\n // paints and there is no frame in which the label sits off the screen.\n const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);\n useLayoutEffect(() => {\n const measured = visible ? bubbleRef.current?.getBoundingClientRect() : undefined;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a measurement is the one thing a layout effect is for\n setRoom((previous) => {\n if (!measured) return null;\n const next = {\n size: { width: measured.width, height: measured.height },\n viewport: { width: window.innerWidth, height: window.innerHeight },\n };\n // Only publish what actually CHANGED: every re-measure allocates a fresh\n // object, and a new object on every scroll event would re-render the\n // bubble forever.\n return previous && sameRoom(previous, next) ? previous : next;\n });\n }, [visible, rect, label]);\n\n const point = rect ? tooltipAnchor(rect, side) : null;\n // Unmeasured on the very first pass, where the anchor point plus the side's\n // own transform is exactly what this always did. One layout effect later the\n // size is known and the placement is decided properly.\n const placed = rect && room ? placeTooltip(rect, side, room.size, room.viewport) : null;\n\n return (\n <>\n <span\n // As in `CssTooltip`: the caller's attributes first, the four handlers that run\n // this component after them. The BUBBLE is deliberately not given them — it is\n // portalled to `<body>`, and an id or a tour anchor duplicated onto a node that\n // only exists while hovered would match twice or match nothing.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", className)}\n onMouseEnter={() => setVisible(true)}\n onMouseLeave={() => setVisible(false)}\n onFocus={() => setVisible(true)}\n onBlur={() => setVisible(false)}\n >\n {describedBy(children, visible ? id : undefined)}\n </span>\n {visible &&\n point &&\n typeof document !== \"undefined\" &&\n createPortal(\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n data-private={redact ? \"\" : undefined}\n style={\n placed\n ? { position: \"fixed\", left: placed.left, top: placed.top }\n : {\n position: \"fixed\",\n left: point.left,\n top: point.top,\n transform: portalTransformBySide[side],\n }\n }\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none z-50\")}\n >\n {label}\n </span>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/tooltip.tsx"],"sourcesContent":["import {\n cloneElement,\n isValidElement,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type ReactElement,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useAnchoredRect, type AnchorRect } from \"../hooks/use-anchored-rect\";\nimport { dirOf, type Direction } from \"../lib/direction\";\n\n/** Where the bubble sits. `start` / `end` follow the reading direction — `end` is the\n * right in LTR and the left in RTL — and are what a layout that mirrors should ask\n * for. `left` / `right` stay physical, for a bubble tied to something that does not\n * mirror (a chart axis, a map). */\nexport type TooltipSide = \"top\" | \"bottom\" | \"left\" | \"right\" | \"start\" | \"end\";\n\n/** The placement the maths works in: a logical side resolved against the trigger. */\ntype PhysicalSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nfunction physicalSide(side: TooltipSide, dir: Direction): PhysicalSide {\n if (side === \"start\") return dir === \"rtl\" ? \"right\" : \"left\";\n if (side === \"end\") return dir === \"rtl\" ? \"left\" : \"right\";\n return side;\n}\n\n/** The floating bubble itself. Uses the shared surface/border/text tokens so it\n * reads as part of the app's chrome (like the top bar and cards) rather than the\n * cold slate pill it used to be.\n *\n * `w-max` keeps a short label on one line — the old `whitespace-nowrap` did that\n * too, but it also let a sentence-length label grow without bound, and a bubble\n * wider than the space beside its trigger gets clipped by whatever overflow\n * container it sits in. So cap it and let long text wrap instead. The cap tracks\n * the viewport as well, for narrow screens where 20rem is already most of it.\n * `side` is a preference rather than an instruction for the PORTALLED variant,\n * which measures the bubble and turns it round when it would not fit\n * (Steering Design feedback #126). The CSS-only one never learns its own size,\n * so there `side` is still the whole of the placement. */\nconst TOOLTIP_SURFACE =\n \"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\";\n\nconst sidePositionClass: Record<TooltipSide, string> = {\n top: \"bottom-full left-1/2 -translate-x-1/2 mb-1\",\n bottom: \"top-full left-1/2 -translate-x-1/2 mt-1\",\n left: \"right-full top-1/2 -translate-y-1/2 mr-1\",\n right: \"left-full top-1/2 -translate-y-1/2 ml-1\",\n // Logical insets, so CSS resolves the side from the inherited direction with no\n // JavaScript: `end-full` pins the bubble's END edge to the trigger's start.\n start: \"end-full top-1/2 -translate-y-1/2 me-1\",\n end: \"start-full top-1/2 -translate-y-1/2 ms-1\",\n};\n\n/**\n * `extends ComponentPropsWithoutRef<\"span\">` because the wrapper this renders IS a span,\n * and a tooltip is the component a caller most often needs to reach past: it sits\n * between the layout and the control, so a `data-tour` anchor, a test id or an\n * `aria-label` aimed at the trigger used to be swallowed by it.\n *\n * ⚠️ On the empty-label branch there is no wrapper at all, and therefore nothing for\n * those attributes to land on — see the note in the body.\n */\nexport interface TooltipProps extends ComponentPropsWithoutRef<\"span\"> {\n label: ReactNode;\n side?: TooltipSide;\n className?: string;\n portal?: boolean;\n /** Tag the bubble `data-private`, for a label that repeats the user's own data. */\n redact?: boolean;\n children: ReactNode;\n}\n\n/** What each variant below takes: the resolved `side`, and every span attribute the\n * caller handed {@link Tooltip}, forwarded to that variant's own wrapper. */\ntype TooltipVariantProps = Omit<TooltipProps, \"side\" | \"portal\"> & { side: TooltipSide };\n\n/**\n * Hover/focus label for a control.\n *\n * Two implementations, and the choice matters more than it looks. The default is\n * CSS-only: the bubble is always mounted next to the trigger and fades in on\n * `:hover`, which costs no state and works in a plain render test. The `portal`\n * variant mounts the bubble in `document.body` only while hovered, positioned by\n * measurement.\n *\n * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs\n * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is\n * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as\n * long as the bubble is tagged. It is not tagged by default, because most labels are\n * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated\n * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,\n * the bubble spells the value out on hover.\n *\n * ⚠️ **An empty label renders nothing at all.** `title={payee ?? \"\"}` is an ordinary\n * shape at a call site that reveals truncated text, and the native attribute answers\n * it by showing no tooltip. A component that faithfully rendered an empty bubble\n * would be a worse `title`, so the emptiness check is here rather than at every call\n * site that could forget it.\n *\n * ⚠️ **The bubble describes its trigger, which means cloning it.** `role=\"tooltip\"` is\n * a name for a box, not a relationship — so for as long as nothing referenced the\n * bubble, the label reached the pointer and nobody else. That is worst on the call\n * sites that need it most: the kit's icon-only buttons, where the tooltip IS the\n * label. `aria-describedby` has to sit on the focusable element, which is the\n * caller's child and not this component's wrapper, so the child is CLONED to carry\n * it. A description the caller already set is appended to, never replaced — a field's\n * error text and its hint bubble both describe it. Children that cannot take props (a\n * fragment, a bare string, several elements) are left exactly as they were.\n *\n * ⚠️ **Escape dismisses it (WCAG 1.4.13).** Anything that appears on hover or focus has\n * to be dismissible without moving the pointer, and a bubble is opaque: it lands over\n * the row, field or figure you were reading, and the only way out of it used to be to\n * point somewhere else — which is precisely what you cannot do when what you need to\n * read is underneath it. The listener is the document's rather than the wrapper's\n * because the pointer opens this with the keyboard focus somewhere else entirely, and\n * it is subscribed only while a bubble is actually up: the CSS variant is always\n * mounted, and a table of forty tooltips must not mean forty keydown listeners.\n *\n * ⚠️ **Inside a scroll container, use `portal`.** An always-mounted bubble is\n * absolutely positioned, but an absolutely positioned descendant still counts\n * towards its scroll-container ancestor's scrollable overflow — so an invisible\n * bubble on a control near the right edge makes the container scroll sideways\n * with nothing to reveal. That is what Keksdose feedback dev#488 reported on the\n * admin roster: 66px of horizontal scroll on a table that fit, 44px of it owed to\n * tooltips nobody could see. The portalled bubble is `position: fixed` and absent\n * until hovered, so it adds no width — and, being outside the container, it also\n * cannot be clipped by it.\n */\nexport function Tooltip({\n label,\n side = \"top\",\n className,\n portal = false,\n redact = false,\n children,\n ...rest\n}: TooltipProps) {\n // No label, no bubble — and no wrapper either, so a conditional tooltip costs the\n // layout nothing on the branch where it does not apply. `...rest` goes with the\n // wrapper on this branch, which is the documented limit of the pass-through: there is\n // no element left to put an attribute on.\n if (isEmptyLabel(label)) return <>{children}</>;\n if (portal) {\n return (\n <PortalTooltip\n label={label}\n side={side}\n className={className}\n redact={redact}\n {...rest}\n >\n {children}\n </PortalTooltip>\n );\n }\n // Both variants are separate components so that `Tooltip` itself can keep calling NO\n // hooks: the empty-label branch above returns before either of them, and a hook after\n // a conditional return is a hooks-order bug rather than a style violation.\n return (\n <CssTooltip label={label} side={side} className={className} redact={redact} {...rest}>\n {children}\n </CssTooltip>\n );\n}\n\n/** The always-mounted variant: the bubble sits next to the trigger and CSS fades it in.\n *\n * It holds the little state it does for the two things CSS cannot express — which\n * element to point `aria-describedby` at, and Escape — and not for the fade, which is\n * still `group-hover`/`group-focus-within` and still costs a render nothing. */\nfunction CssTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const id = useId();\n const [hovered, setHovered] = useState(false);\n const [focused, setFocused] = useState(false);\n const [dismissed, setDismissed] = useState(false);\n useEscapeKey(() => setDismissed(true), (hovered || focused) && !dismissed);\n return (\n <span\n // `...rest` first: the four handlers below are what decides whether a bubble is\n // up, and a caller passing an `onFocus` of its own must not replace them.\n {...rest}\n className={cn(\"relative inline-flex group/tooltip\", className)}\n // These four track WHETHER A BUBBLE IS UP. They activate nothing — the only thing\n // here that can be activated is the caller's child, which keeps every handler it\n // arrived with — so this wrapper needs no role and no key handling of its own.\n // `jsx-a11y/no-static-element-interactions` warns about it all the same, as it\n // already does about the portal variant's identical trigger below; both are left\n // visible rather than silenced, because a rule this package ratchets should be\n // argued with in the backlog and not in a disable comment.\n //\n // Re-armed by the next hover or focus rather than by an effect watching those\n // flags: coming back to a trigger is a fresh request for its label, and an effect\n // would also re-show the bubble under a pointer that never left.\n onMouseEnter={() => {\n setHovered(true);\n setDismissed(false);\n }}\n onMouseLeave={() => setHovered(false)}\n onFocus={() => {\n setFocused(true);\n setDismissed(false);\n }}\n onBlur={() => setFocused(false)}\n >\n {describedBy(children, dismissed ? undefined : id)}\n <span\n id={id}\n role=\"tooltip\"\n // The `hidden` ATTRIBUTE, not an opacity class: dismissing has to take the\n // bubble out of the accessibility tree as well as off the screen, or a screen\n // reader still reads out the description of a bubble the user just closed.\n hidden={dismissed || undefined}\n data-private={redact ? \"\" : undefined}\n className={cn(\n TOOLTIP_SURFACE,\n \"pointer-events-none absolute z-50 opacity-0 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100\",\n sidePositionClass[side],\n )}\n >\n {label}\n </span>\n </span>\n );\n}\n\n/** Hand `children` the bubble's id as an `aria-describedby`, if it is an element that\n * can hold one. `id` is undefined while there is no bubble to point at — a dangling\n * reference describes the trigger as nothing at all, which is worse than silence. */\nfunction describedBy(children: ReactNode, id: string | undefined): ReactNode {\n if (id === undefined || !isValidElement(children)) return children;\n const child = children as ReactElement<{ \"aria-describedby\"?: string }>;\n // Fragments, Suspense and friends are symbol-typed and take no DOM props; cloning one\n // with an aria attribute warns in development and drops it in production.\n if (typeof child.type === \"symbol\") return children;\n const own = child.props[\"aria-describedby\"];\n return cloneElement(child, { \"aria-describedby\": own ? `${own} ${id}` : id });\n}\n\n/** \"Would this bubble be blank.\" Only the values a call site actually produces when\n * it has nothing to say — `\"\"`, `null`, `undefined`, `false` from a `&&` guard. A\n * numeric `0` is a real label and stays one. */\nfunction isEmptyLabel(label: ReactNode): boolean {\n return (\n label == null ||\n label === false ||\n (typeof label === \"string\" && label.trim() === \"\")\n );\n}\n\nconst TOOLTIP_GAP = 4;\n\n/** How close to the viewport edge a bubble may sit. Not zero: a label flush\n * against the glass reads as clipped even when every character is on screen. */\nconst TOOLTIP_MARGIN = 4;\n\nconst portalTransformBySide: Record<PhysicalSide, string> = {\n right: \"translate(0, -50%)\",\n left: \"translate(-100%, -50%)\",\n top: \"translate(-50%, -100%)\",\n bottom: \"translate(-50%, 0)\",\n};\n\n/** Anchor point (viewport px) for the tooltip on the given side of `r`. Paired\n * with {@link portalTransformBySide}, which shifts the box onto that point. */\nfunction tooltipAnchor(\n r: AnchorRect,\n side: PhysicalSide,\n): { left: number; top: number } {\n switch (side) {\n case \"right\":\n return { left: r.right + TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"left\":\n return { left: r.left - TOOLTIP_GAP, top: r.top + r.height / 2 };\n case \"top\":\n return { left: r.left + r.width / 2, top: r.top - TOOLTIP_GAP };\n case \"bottom\":\n return { left: r.left + r.width / 2, top: r.bottom + TOOLTIP_GAP };\n }\n}\n\nexport interface TooltipSize {\n width: number;\n height: number;\n}\n\nexport interface TooltipViewport {\n width: number;\n height: number;\n}\n\nexport interface TooltipPlacement {\n left: number;\n top: number;\n /** Which side it ended up on, which need not be the one that was asked for. */\n side: PhysicalSide;\n}\n\nconst opposite: Record<PhysicalSide, PhysicalSide> = {\n left: \"right\",\n right: \"left\",\n top: \"bottom\",\n bottom: \"top\",\n};\n\n/** Whether the bubble clears the viewport edge on `side` of the trigger. */\nfunction roomOn(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): boolean {\n switch (side) {\n case \"left\":\n return r.left - TOOLTIP_GAP - size.width >= TOOLTIP_MARGIN;\n case \"right\":\n return r.right + TOOLTIP_GAP + size.width <= viewport.width - TOOLTIP_MARGIN;\n case \"top\":\n return r.top - TOOLTIP_GAP - size.height >= TOOLTIP_MARGIN;\n case \"bottom\":\n return r.bottom + TOOLTIP_GAP + size.height <= viewport.height - TOOLTIP_MARGIN;\n }\n}\n\nfunction sameRoom(\n a: { size: TooltipSize; viewport: TooltipViewport },\n b: { size: TooltipSize; viewport: TooltipViewport },\n): boolean {\n return (\n a.size.width === b.size.width &&\n a.size.height === b.size.height &&\n a.viewport.width === b.viewport.width &&\n a.viewport.height === b.viewport.height\n );\n}\n\nfunction clamp(value: number, low: number, high: number): number {\n // `high` first, so a bubble taller or wider than the viewport is pinned to the\n // top-left corner rather than to the bottom-right one — the start of a label\n // is the half worth keeping.\n return Math.max(low, Math.min(value, high));\n}\n\n/**\n * Where the bubble actually goes, given how big it turned out to be.\n *\n * Two rules, and they are separate because they fix separate failures.\n *\n * **Turn round when the preferred side has no room.** `side` says which side of\n * the trigger the label reads best on, and on a form near the left edge of the\n * window that side is off the screen — the capped bubble can only wrap, not\n * move, so what the reader gets is a sentence with its first half outside the\n * glass. Flipped only when the *other* side is genuinely better: a trigger in a\n * viewport too narrow for the bubble either way keeps the side it asked for, and\n * the clamp below does what it can.\n *\n * **Then clamp both axes.** The cross axis is the one that needs it — a `top`\n * bubble is centred on the trigger, so a trigger near the left edge pushes half\n * the label off even though the side it is on is right — and clamping the main\n * axis too costs nothing and covers the flip having nowhere to land.\n *\n * Pure, and measured in viewport pixels throughout, so it can be tested without\n * a layout: the caller supplies the trigger's rect, the bubble's own size and\n * the window.\n */\nexport function placeTooltip(\n r: AnchorRect,\n side: PhysicalSide,\n size: TooltipSize,\n viewport: TooltipViewport,\n): TooltipPlacement {\n const chosen =\n roomOn(r, side, size, viewport) || !roomOn(r, opposite[side], size, viewport)\n ? side\n : opposite[side];\n const point = tooltipAnchor(r, chosen);\n const box =\n chosen === \"left\"\n ? { left: point.left - size.width, top: point.top - size.height / 2 }\n : chosen === \"right\"\n ? { left: point.left, top: point.top - size.height / 2 }\n : chosen === \"top\"\n ? { left: point.left - size.width / 2, top: point.top - size.height }\n : { left: point.left - size.width / 2, top: point.top };\n return {\n left: clamp(box.left, TOOLTIP_MARGIN, viewport.width - size.width - TOOLTIP_MARGIN),\n top: clamp(box.top, TOOLTIP_MARGIN, viewport.height - size.height - TOOLTIP_MARGIN),\n side: chosen,\n };\n}\n\nfunction PortalTooltip({\n label,\n side,\n className,\n redact,\n children,\n ...rest\n}: TooltipVariantProps) {\n const triggerRef = useRef<HTMLSpanElement | null>(null);\n const bubbleRef = useRef<HTMLSpanElement | null>(null);\n const [visible, setVisible] = useState(false);\n // The trigger's reading direction, read when the bubble is asked for (an event, not a\n // render): it resolves `start` / `end`, and the portalled bubble — which has left the\n // subtree it would have inherited `dir` from — carries it too.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const show = (el: Element) => {\n setDir(dirOf(el));\n setVisible(true);\n };\n const id = useId();\n // Escape closes it outright, since this variant's bubble only exists while it is\n // shown. The next mouseenter/focus brings it back, which is the behaviour WCAG\n // 1.4.13 asks for: dismissible now, still available when you ask again.\n useEscapeKey(() => setVisible(false), visible);\n // The measure + scroll/resize-tracking lifecycle is owned by useAnchoredRect;\n // here we only map the rect to a side-specific anchor point.\n const rect = useAnchoredRect(triggerRef, visible);\n // The bubble's own size and the window it has to fit in — neither of which is\n // knowable in render: the width is whatever the label wrapped to inside the\n // cap, and reading `window` while rendering is not a pure thing to do. Both\n // are taken in a LAYOUT effect, so the correction lands before the browser\n // paints and there is no frame in which the label sits off the screen.\n const [room, setRoom] = useState<{ size: TooltipSize; viewport: TooltipViewport } | null>(null);\n useLayoutEffect(() => {\n const measured = visible ? bubbleRef.current?.getBoundingClientRect() : undefined;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a measurement is the one thing a layout effect is for\n setRoom((previous) => {\n if (!measured) return null;\n const next = {\n size: { width: measured.width, height: measured.height },\n viewport: { width: window.innerWidth, height: window.innerHeight },\n };\n // Only publish what actually CHANGED: every re-measure allocates a fresh\n // object, and a new object on every scroll event would re-render the\n // bubble forever.\n return previous && sameRoom(previous, next) ? previous : next;\n });\n }, [visible, rect, label]);\n\n const physical = physicalSide(side, dir);\n const point = rect ? tooltipAnchor(rect, physical) : null;\n // Unmeasured on the very first pass, where the anchor point plus the side's\n // own transform is exactly what this always did. One layout effect later the\n // size is known and the placement is decided properly.\n const placed = rect && room ? placeTooltip(rect, physical, room.size, room.viewport) : null;\n\n return (\n <>\n <span\n // As in `CssTooltip`: the caller's attributes first, the four handlers that run\n // this component after them. The BUBBLE is deliberately not given them — it is\n // portalled to `<body>`, and an id or a tour anchor duplicated onto a node that\n // only exists while hovered would match twice or match nothing.\n {...rest}\n ref={triggerRef}\n className={cn(\"relative inline-flex\", className)}\n onMouseEnter={(e) => show(e.currentTarget)}\n onMouseLeave={() => setVisible(false)}\n onFocus={(e) => show(e.currentTarget)}\n onBlur={() => setVisible(false)}\n >\n {describedBy(children, visible ? id : undefined)}\n </span>\n {visible &&\n point &&\n typeof document !== \"undefined\" &&\n createPortal(\n <span\n ref={bubbleRef}\n id={id}\n role=\"tooltip\"\n dir={dir}\n data-private={redact ? \"\" : undefined}\n style={\n placed\n ? { position: \"fixed\", left: placed.left, top: placed.top }\n : {\n position: \"fixed\",\n left: point.left,\n top: point.top,\n transform: portalTransformBySide[physical],\n }\n }\n className={cn(TOOLTIP_SURFACE, \"pointer-events-none z-50\")}\n >\n {label}\n </span>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,129 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ComponentPropsWithoutRef } from 'react';
3
+
4
+ /**
5
+ * A hierarchy you walk with the keyboard: kastlan's chart of accounts (Assets › Current
6
+ * assets › Bank › 1020 Operating account), and — through {@link TreeRow} alone — the
7
+ * row labels of its gantt, which indent and fold like a tree but sit beside a timeline
8
+ * and so cannot be one.
9
+ *
10
+ * Both apps had written it as nested `<div>`s with a chevron button per row: every
11
+ * row a Tab stop (a 300-account chart was 300 presses to get past), no way to say
12
+ * "level 3 of 5, collapsed" to a screen reader, and no ←/→ at all. This is the WAI-ARIA
13
+ * tree pattern instead:
14
+ *
15
+ * - `role="tree"` › `treeitem` (with `aria-level`, `aria-expanded` on a parent,
16
+ * `aria-selected` when selectable) › `role="group"` for its children — so the
17
+ * reader announces the position and the state it needs.
18
+ * - ONE Tab stop (roving tabindex). ↑/↓ walk the visible rows, → opens a closed
19
+ * parent and then steps into it, ← closes an open one and then steps out to the
20
+ * parent, Home/End jump to the ends, `*` opens every sibling, and typing a few
21
+ * letters jumps to the next row that starts with them. ←/→ follow the reading
22
+ * direction (`horizontalStep`): in an RTL tree the children hang off to the left,
23
+ * and ← is what opens them.
24
+ * - Enter / Space, or a click, select. The chevron alone toggles on click.
25
+ *
26
+ * `expanded` and `selected` each work controlled or uncontrolled.
27
+ */
28
+ interface TreeNode<T = unknown> {
29
+ /** Unique across the whole tree — it is what `expanded` and `selected` hold. */
30
+ id: string;
31
+ label: ReactNode;
32
+ /** The text type-ahead matches. Defaults to the row's rendered text. */
33
+ textValue?: string;
34
+ /** Before the label: an account-type icon, a colour dot. */
35
+ icon?: ReactNode;
36
+ /** After the label, pushed to the end of the row: a balance, a count, an action. */
37
+ trailing?: ReactNode;
38
+ children?: readonly TreeNode<T>[];
39
+ /**
40
+ * The node has children that are not loaded yet — it gets a chevron, and opening it
41
+ * calls `loadChildren`. Pass the children in `items` once they arrive.
42
+ */
43
+ hasChildren?: boolean;
44
+ /** Focusable and announced as unavailable (`aria-disabled`), but not selectable. */
45
+ disabled?: boolean;
46
+ /** Anything of the host's own, handed back to `renderItem` and `onSelectedChange`. */
47
+ data?: T;
48
+ }
49
+ /** What {@link TreeViewProps.renderItem} is told about a row. */
50
+ interface TreeItemState {
51
+ level: number;
52
+ expanded: boolean;
53
+ selected: boolean;
54
+ /** It has children, loaded or not — it shows a chevron. */
55
+ expandable: boolean;
56
+ loading: boolean;
57
+ disabled: boolean;
58
+ }
59
+ interface TreeRowProps extends ComponentPropsWithoutRef<"div"> {
60
+ /** 1 for a top-level row. Sets the indent and the number of guides. */
61
+ level: number;
62
+ /** `true`/`false` for a row that can fold; leave it `undefined` for a leaf, which
63
+ * gets a chevron-sized gap instead so labels at one level still line up. */
64
+ expanded?: boolean;
65
+ /**
66
+ * Make the chevron a real button (with `aria-expanded`, named by the label) that
67
+ * calls this. For a row used ON ITS OWN — kastlan's gantt labels, where each row is
68
+ * a line of a timeline and not an item of a tree widget. Inside {@link TreeView} it
69
+ * is omitted: the tree owns the keys, and a button per row would be a Tab stop per
70
+ * row, which is what the tree exists to remove.
71
+ */
72
+ onToggle?: () => void;
73
+ /** The toggle button's name. By default it is named by the row's label (the state
74
+ * is `aria-expanded`'s to say); required when `children` replaces the label. */
75
+ toggleLabel?: string;
76
+ label: ReactNode;
77
+ icon?: ReactNode;
78
+ trailing?: ReactNode;
79
+ /** Pixels per level. Default 16. */
80
+ indent?: number;
81
+ /** Draw a vertical hairline per ancestor level. Default `true`. */
82
+ guides?: boolean;
83
+ selected?: boolean;
84
+ /** A spinner in place of the chevron, while lazy children load. */
85
+ loading?: boolean;
86
+ /** Replace the label + trailing part of the row (the indent and chevron stay). */
87
+ children?: ReactNode;
88
+ }
89
+ /**
90
+ * One row: indent, guides, chevron, icon, label, trailing — no tree semantics of its
91
+ * own. {@link TreeView} is built from it, and a host that needs the look without the
92
+ * widget (a gantt's label column) uses it directly.
93
+ *
94
+ * Laid out with logical properties throughout (`padding-inline-start`,
95
+ * `inset-inline-start`), so the indent, the guides and the chevron all hang from the
96
+ * reading start in an RTL layout without a second code path.
97
+ */
98
+ declare function TreeRow({ level, expanded, onToggle, toggleLabel, label, icon, trailing, indent, guides, selected, loading, children, className, style, ...rest }: TreeRowProps): react.JSX.Element;
99
+ interface TreeViewProps<T = unknown> extends Omit<ComponentPropsWithoutRef<"ul">, "onSelect" | "children" | "defaultValue"> {
100
+ items: readonly TreeNode<T>[];
101
+ /** Open nodes, controlled. */
102
+ expanded?: readonly string[];
103
+ /** Open nodes on first render, uncontrolled. */
104
+ defaultExpanded?: readonly string[];
105
+ onExpandedChange?: (expanded: string[]) => void;
106
+ /** The selected node's id, controlled; `null` for none. */
107
+ selected?: string | null;
108
+ defaultSelected?: string | null;
109
+ onSelectedChange?: (id: string, node: TreeNode<T>) => void;
110
+ /** `"none"` makes it a navigation-only tree: no `aria-selected`, Enter toggles. */
111
+ selectionMode?: "single" | "none";
112
+ /** The whole row content after the chevron, per node. Wins over `label`/`trailing`. */
113
+ renderItem?: (node: TreeNode<T>, state: TreeItemState) => ReactNode;
114
+ /**
115
+ * Called when a node with `hasChildren` and no `children` is opened. While the
116
+ * promise is pending the row shows a spinner and is `aria-busy`; put the children
117
+ * into `items` before it resolves (or after — the row opens either way).
118
+ */
119
+ loadChildren?: (node: TreeNode<T>) => Promise<void> | void;
120
+ /** Pixels per level. Default 16. */
121
+ indent?: number;
122
+ /** Vertical hairlines per level. Default `true`. */
123
+ indentGuides?: boolean;
124
+ /** Extra classes for each row (the `TreeRow`), e.g. a denser height. */
125
+ rowClassName?: string;
126
+ }
127
+ declare function TreeView<T = unknown>({ items, expanded: expandedProp, defaultExpanded, onExpandedChange, selected: selectedProp, defaultSelected, onSelectedChange, selectionMode, renderItem, loadChildren, indent, indentGuides, rowClassName, className, onKeyDown, onClick, ...rest }: TreeViewProps<T>): react.JSX.Element;
128
+
129
+ export { type TreeItemState, type TreeNode, TreeRow, type TreeRowProps, TreeView, type TreeViewProps };
@@ -0,0 +1,376 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
4
+ import { ChevronRight } from "lucide-react";
5
+ import { cn } from "../lib/cn.js";
6
+ import { horizontalStep } from "../lib/direction.js";
7
+ import { Spinner } from "./ui.js";
8
+ const DEFAULT_INDENT = 16;
9
+ const CHEVRON = 16;
10
+ const ROW_PAD = 4;
11
+ function TreeRow({
12
+ level,
13
+ expanded,
14
+ onToggle,
15
+ toggleLabel,
16
+ label,
17
+ icon,
18
+ trailing,
19
+ indent = DEFAULT_INDENT,
20
+ guides = true,
21
+ selected,
22
+ loading,
23
+ children,
24
+ className,
25
+ style,
26
+ ...rest
27
+ }) {
28
+ const labelId = useId();
29
+ const depth = Math.max(0, level - 1);
30
+ const expandable = expanded !== void 0;
31
+ const chevron = /* @__PURE__ */ jsx(
32
+ ChevronRight,
33
+ {
34
+ "aria-hidden": true,
35
+ className: cn(
36
+ "size-4 transition-transform motion-reduce:transition-none rtl:-scale-x-100",
37
+ expanded && "rotate-90 rtl:-rotate-90"
38
+ )
39
+ }
40
+ );
41
+ return /* @__PURE__ */ jsxs(
42
+ "div",
43
+ {
44
+ ...rest,
45
+ "data-tree-row": "",
46
+ "data-selected": selected || void 0,
47
+ style: { paddingInlineStart: ROW_PAD + depth * indent, ...style },
48
+ className: cn(
49
+ "relative flex min-h-8 items-center gap-1.5 rounded pe-2 text-sm",
50
+ selected ? "bg-[var(--bg-active)] font-medium text-[var(--text-primary)]" : "text-[var(--text-secondary)]",
51
+ className
52
+ ),
53
+ children: [
54
+ guides && Array.from({ length: depth }, (_, i) => /* @__PURE__ */ jsx(
55
+ "span",
56
+ {
57
+ "aria-hidden": true,
58
+ "data-tree-guide": "",
59
+ className: "pointer-events-none absolute inset-y-0 w-px bg-[var(--border)]",
60
+ style: { insetInlineStart: ROW_PAD + i * indent + CHEVRON / 2 }
61
+ },
62
+ i
63
+ )),
64
+ loading ? /* @__PURE__ */ jsx("span", { className: "flex size-4 shrink-0 items-center justify-center", children: /* @__PURE__ */ jsx(Spinner, { label: null, className: "size-3 border" }) }) : expandable && onToggle ? /* @__PURE__ */ jsx(
65
+ "button",
66
+ {
67
+ type: "button",
68
+ "aria-expanded": expanded,
69
+ "aria-label": toggleLabel,
70
+ "aria-labelledby": toggleLabel ? void 0 : labelId,
71
+ onClick: (e) => {
72
+ e.stopPropagation();
73
+ onToggle();
74
+ },
75
+ className: "flex size-4 shrink-0 items-center justify-center rounded text-[var(--text-muted)] outline-none hover:text-[var(--text-primary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
76
+ children: chevron
77
+ }
78
+ ) : expandable ? /* @__PURE__ */ jsx("span", { "data-tree-toggle": "", className: "flex size-4 shrink-0 cursor-pointer items-center justify-center text-[var(--text-muted)]", children: chevron }) : /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "size-4 shrink-0" }),
79
+ children ?? /* @__PURE__ */ jsxs(Fragment, { children: [
80
+ icon && /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center", children: icon }),
81
+ /* @__PURE__ */ jsx("span", { id: labelId, "data-tree-label": "", className: "min-w-0 flex-1 truncate", children: label }),
82
+ trailing !== void 0 && trailing !== null && /* @__PURE__ */ jsx("span", { className: "ms-auto flex shrink-0 items-center gap-1 text-xs tabular-nums text-[var(--text-muted)]", children: trailing })
83
+ ] })
84
+ ]
85
+ }
86
+ );
87
+ }
88
+ const isExpandable = (n) => Boolean(n.children?.length || n.hasChildren);
89
+ function useControllable(value, initial) {
90
+ const [own, setOwn] = useState(initial);
91
+ const controlled = value !== void 0;
92
+ const set = (next) => {
93
+ if (!controlled) setOwn(next);
94
+ };
95
+ return [controlled ? value : own, set];
96
+ }
97
+ const TYPEAHEAD_MS = 500;
98
+ function TreeView({
99
+ items,
100
+ expanded: expandedProp,
101
+ defaultExpanded,
102
+ onExpandedChange,
103
+ selected: selectedProp,
104
+ defaultSelected,
105
+ onSelectedChange,
106
+ selectionMode = "single",
107
+ renderItem,
108
+ loadChildren,
109
+ indent = DEFAULT_INDENT,
110
+ indentGuides = true,
111
+ rowClassName,
112
+ className,
113
+ onKeyDown,
114
+ onClick,
115
+ ...rest
116
+ }) {
117
+ const [expandedList, setExpandedOwn] = useControllable(
118
+ expandedProp,
119
+ defaultExpanded ?? []
120
+ );
121
+ const [selected, setSelectedOwn] = useControllable(
122
+ selectedProp,
123
+ defaultSelected ?? null
124
+ );
125
+ const expandedSet = useMemo(() => new Set(expandedList), [expandedList]);
126
+ const [loading, setLoading] = useState(() => /* @__PURE__ */ new Set());
127
+ const [focusedId, setFocusedId] = useState(null);
128
+ const itemRefs = useRef(/* @__PURE__ */ new Map());
129
+ const pendingFocus = useRef(null);
130
+ const typeahead = useRef({ text: "", at: 0 });
131
+ const baseId = useId();
132
+ const { byId, visible } = useMemo(() => {
133
+ const map = /* @__PURE__ */ new Map();
134
+ const rows = [];
135
+ const walk = (nodes, level, parentId, shown) => {
136
+ for (const node of nodes) {
137
+ const flat = { node, level, parentId };
138
+ map.set(node.id, flat);
139
+ if (shown) rows.push(flat);
140
+ if (node.children) walk(node.children, level + 1, node.id, shown && expandedSet.has(node.id));
141
+ }
142
+ };
143
+ walk(items, 1, null, true);
144
+ return { byId: map, visible: rows };
145
+ }, [items, expandedSet]);
146
+ const visibleIds = useMemo(() => new Set(visible.map((f) => f.node.id)), [visible]);
147
+ const nearestVisible = (id) => {
148
+ let cur = id;
149
+ while (cur && !visibleIds.has(cur)) cur = byId.get(cur)?.parentId ?? null;
150
+ return cur;
151
+ };
152
+ const tabbable = nearestVisible(focusedId) ?? nearestVisible(selected) ?? visible[0]?.node.id ?? null;
153
+ useEffect(() => {
154
+ const id = pendingFocus.current;
155
+ if (!id) return;
156
+ pendingFocus.current = null;
157
+ itemRefs.current.get(id)?.focus();
158
+ });
159
+ const focusRow = (id) => {
160
+ setFocusedId(id);
161
+ pendingFocus.current = id;
162
+ itemRefs.current.get(id)?.focus();
163
+ };
164
+ const setExpanded = (next) => {
165
+ const list = [...next];
166
+ setExpandedOwn(list);
167
+ onExpandedChange?.(list);
168
+ };
169
+ const open = (node) => {
170
+ if (expandedSet.has(node.id) || !isExpandable(node)) return;
171
+ const next = new Set(expandedSet);
172
+ next.add(node.id);
173
+ setExpanded(next);
174
+ if (loadChildren && node.hasChildren && !node.children?.length) {
175
+ const pending = loadChildren(node);
176
+ if (pending && typeof pending.then === "function") {
177
+ setLoading((s) => new Set(s).add(node.id));
178
+ const done = () => setLoading((s) => {
179
+ const n = new Set(s);
180
+ n.delete(node.id);
181
+ return n;
182
+ });
183
+ pending.then(done, done);
184
+ }
185
+ }
186
+ };
187
+ const close = (node) => {
188
+ if (!expandedSet.has(node.id)) return;
189
+ const next = new Set(expandedSet);
190
+ next.delete(node.id);
191
+ setExpanded(next);
192
+ };
193
+ const toggle = (node) => expandedSet.has(node.id) ? close(node) : open(node);
194
+ const select = (node) => {
195
+ if (node.disabled) return;
196
+ if (selectionMode === "none") {
197
+ toggle(node);
198
+ return;
199
+ }
200
+ setSelectedOwn(node.id);
201
+ onSelectedChange?.(node.id, node);
202
+ };
203
+ const idOf = (target) => target?.closest?.('[role="treeitem"]')?.dataset.treeId ?? null;
204
+ const textOf = (flat) => {
205
+ if (flat.node.textValue !== void 0) return flat.node.textValue;
206
+ if (typeof flat.node.label === "string") return flat.node.label;
207
+ const row = itemRefs.current.get(flat.node.id)?.querySelector("[data-tree-row]");
208
+ return row?.textContent ?? "";
209
+ };
210
+ const findByText = (fromIndex, text) => {
211
+ const needle = text.toLocaleLowerCase();
212
+ for (let i = 1; i <= visible.length; i++) {
213
+ const flat = visible[(fromIndex + i) % visible.length];
214
+ if (textOf(flat).trim().toLocaleLowerCase().startsWith(needle)) return flat.node.id;
215
+ }
216
+ return null;
217
+ };
218
+ const handleKeyDown = (e) => {
219
+ onKeyDown?.(e);
220
+ if (e.defaultPrevented) return;
221
+ const id = idOf(e.target);
222
+ const flat = id ? byId.get(id) : void 0;
223
+ if (!flat) return;
224
+ const { node } = flat;
225
+ const index = visible.findIndex((f) => f.node.id === node.id);
226
+ const expandedNow = expandedSet.has(node.id);
227
+ const step = horizontalStep(e.key, e.target);
228
+ const inRun = Date.now() - typeahead.current.at < TYPEAHEAD_MS && typeahead.current.text !== "";
229
+ const key = e.key === " " && inRun ? "typeahead" : e.key;
230
+ let handled = true;
231
+ if (step === 1) {
232
+ if (!isExpandable(node)) handled = false;
233
+ else if (!expandedNow) open(node);
234
+ else if (node.children?.length) focusRow(node.children[0].id);
235
+ } else if (step === -1) {
236
+ if (expandedNow) close(node);
237
+ else if (flat.parentId) focusRow(flat.parentId);
238
+ else handled = false;
239
+ } else {
240
+ switch (key) {
241
+ case "ArrowDown":
242
+ if (index < visible.length - 1) focusRow(visible[index + 1].node.id);
243
+ break;
244
+ case "ArrowUp":
245
+ if (index > 0) focusRow(visible[index - 1].node.id);
246
+ break;
247
+ case "Home":
248
+ if (visible.length) focusRow(visible[0].node.id);
249
+ break;
250
+ case "End":
251
+ if (visible.length) focusRow(visible[visible.length - 1].node.id);
252
+ break;
253
+ case "Enter":
254
+ case " ":
255
+ select(node);
256
+ break;
257
+ case "*": {
258
+ const siblings = flat.parentId ? byId.get(flat.parentId)?.node.children ?? [] : items;
259
+ const next = new Set(expandedSet);
260
+ for (const s of siblings) if (isExpandable(s)) next.add(s.id);
261
+ setExpanded(next);
262
+ break;
263
+ }
264
+ default: {
265
+ const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
266
+ if (!printable) {
267
+ handled = false;
268
+ break;
269
+ }
270
+ const now = Date.now();
271
+ const run = now - typeahead.current.at < TYPEAHEAD_MS ? typeahead.current.text : "";
272
+ typeahead.current = { text: run + e.key, at: now };
273
+ const typed = typeahead.current.text;
274
+ const repeated = typed.length > 1 && [...typed].every((c) => c === typed[0]);
275
+ const target = repeated ? findByText(index, typed[0]) : typed.length > 1 && textOf(flat).trim().toLocaleLowerCase().startsWith(typed.toLocaleLowerCase()) ? node.id : findByText(index, typed);
276
+ if (target) focusRow(target);
277
+ }
278
+ }
279
+ }
280
+ if (handled) {
281
+ e.preventDefault();
282
+ e.stopPropagation();
283
+ }
284
+ };
285
+ const handleClick = (e) => {
286
+ onClick?.(e);
287
+ const id = idOf(e.target);
288
+ const flat = id ? byId.get(id) : void 0;
289
+ if (!flat) return;
290
+ const control = e.target.closest("button, a[href], input, select, textarea");
291
+ if (control && itemRefs.current.get(flat.node.id)?.contains(control)) return;
292
+ setFocusedId(flat.node.id);
293
+ if (e.target.closest("[data-tree-toggle]")) {
294
+ toggle(flat.node);
295
+ return;
296
+ }
297
+ select(flat.node);
298
+ };
299
+ const renderNodes = (nodes, level, path) => nodes.map((node, i) => {
300
+ const rowId = `${baseId}-${path}${i}`;
301
+ const expandable = isExpandable(node);
302
+ const isOpen = expandable && expandedSet.has(node.id);
303
+ const isSelected = selectionMode !== "none" && selected === node.id;
304
+ const isLoading = loading.has(node.id);
305
+ const state = {
306
+ level,
307
+ expanded: isOpen,
308
+ selected: isSelected,
309
+ expandable,
310
+ loading: isLoading,
311
+ disabled: Boolean(node.disabled)
312
+ };
313
+ return /* @__PURE__ */ jsxs(
314
+ "li",
315
+ {
316
+ ref: (el) => {
317
+ if (el) itemRefs.current.set(node.id, el);
318
+ else itemRefs.current.delete(node.id);
319
+ },
320
+ role: "treeitem",
321
+ "data-tree-id": node.id,
322
+ "aria-labelledby": rowId,
323
+ "aria-level": level,
324
+ "aria-expanded": expandable ? isOpen : void 0,
325
+ "aria-selected": selectionMode === "none" ? void 0 : isSelected,
326
+ "aria-disabled": node.disabled || void 0,
327
+ "aria-busy": isLoading || void 0,
328
+ tabIndex: node.id === tabbable ? 0 : -1,
329
+ onFocus: (e) => {
330
+ if (e.target === e.currentTarget) setFocusedId(node.id);
331
+ },
332
+ className: "outline-none [&:focus-visible>[data-tree-row]]:ring-2 [&:focus-visible>[data-tree-row]]:ring-[var(--brand)]",
333
+ children: [
334
+ /* @__PURE__ */ jsx(
335
+ TreeRow,
336
+ {
337
+ id: rowId,
338
+ level,
339
+ expanded: expandable ? isOpen : void 0,
340
+ label: node.label,
341
+ icon: node.icon,
342
+ trailing: node.trailing,
343
+ indent,
344
+ guides: indentGuides,
345
+ selected: isSelected,
346
+ loading: isLoading,
347
+ className: cn(
348
+ node.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[var(--bg-hover)]",
349
+ rowClassName
350
+ ),
351
+ children: renderItem ? renderItem(node, state) : void 0
352
+ }
353
+ ),
354
+ isOpen && /* @__PURE__ */ jsx("ul", { role: "group", children: node.children?.length ? renderNodes(node.children, level + 1, `${path}${i}-`) : null })
355
+ ]
356
+ },
357
+ node.id
358
+ );
359
+ });
360
+ return /* @__PURE__ */ jsx(
361
+ "ul",
362
+ {
363
+ ...rest,
364
+ role: "tree",
365
+ onKeyDown: handleKeyDown,
366
+ onClick: handleClick,
367
+ className: cn("flex flex-col text-sm", className),
368
+ children: renderNodes(items, 1, "")
369
+ }
370
+ );
371
+ }
372
+ export {
373
+ TreeRow,
374
+ TreeView
375
+ };
376
+ //# sourceMappingURL=tree-view.js.map