@recursica/mantine-adapter 0.36.0 → 0.38.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 (63) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -3
  3. package/dist/mantine-adapter.cjs +2 -2
  4. package/dist/mantine-adapter.cjs.map +1 -1
  5. package/dist/mantine-adapter.css +1 -1
  6. package/dist/mantine-adapter.js +2352 -2099
  7. package/dist/mantine-adapter.js.map +1 -1
  8. package/dist/src/components/Dropdown/BareDropdown.d.ts +19 -0
  9. package/dist/src/components/TimePicker/TimePicker.d.ts +9 -3
  10. package/dist/src/components/Tree/Tree.d.ts +5 -0
  11. package/dist/src/index.d.ts +1 -1
  12. package/docs/PHILOSOPHY.md +39 -0
  13. package/package.json +3 -2
  14. package/src/components/Accordion/USAGE.md +2 -41
  15. package/src/components/AutoComplete/USAGE.md +2 -18
  16. package/src/components/Avatar/USAGE.md +0 -27
  17. package/src/components/Badge/USAGE.md +3 -6
  18. package/src/components/Breadcrumb/USAGE.md +1 -5
  19. package/src/components/Button/Button.tsx +5 -0
  20. package/src/components/Button/IMPLEMENTATION_NOTES.md +8 -0
  21. package/src/components/Button/USAGE.md +5 -25
  22. package/src/components/Card/USAGE.md +4 -12
  23. package/src/components/Checkbox/USAGE.md +4 -20
  24. package/src/components/Chip/USAGE.md +3 -32
  25. package/src/components/DatePicker/USAGE.md +2 -12
  26. package/src/components/Dropdown/BareDropdown.tsx +85 -0
  27. package/src/components/Dropdown/Dropdown.tsx +12 -3
  28. package/src/components/Dropdown/USAGE.md +2 -6
  29. package/src/components/Flex/USAGE.md +1 -1
  30. package/src/components/FormControlWrapper/USAGE.md +3 -31
  31. package/src/components/Grid/USAGE.md +1 -1
  32. package/src/components/Group/USAGE.md +1 -1
  33. package/src/components/HoverCard/USAGE.md +3 -72
  34. package/src/components/Label/USAGE.md +8 -48
  35. package/src/components/Link/USAGE.md +4 -10
  36. package/src/components/Loader/USAGE.md +4 -23
  37. package/src/components/Menu/USAGE.md +3 -73
  38. package/src/components/Modal/USAGE.md +3 -3
  39. package/src/components/NumberInput/USAGE.md +5 -8
  40. package/src/components/Pagination/USAGE.md +0 -19
  41. package/src/components/Panel/USAGE.md +6 -95
  42. package/src/components/Popover/USAGE.md +6 -66
  43. package/src/components/ReadOnlyField/USAGE.md +2 -10
  44. package/src/components/SegmentedControl/USAGE.md +1 -15
  45. package/src/components/Slider/USAGE.md +1 -45
  46. package/src/components/Stack/USAGE.md +1 -1
  47. package/src/components/Switch/USAGE.md +1 -24
  48. package/src/components/TextArea/USAGE.md +2 -2
  49. package/src/components/TextField/USAGE.md +1 -17
  50. package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +72 -0
  51. package/src/components/TimePicker/TimePicker.module.css +225 -41
  52. package/src/components/TimePicker/TimePicker.stories.tsx +105 -4
  53. package/src/components/TimePicker/TimePicker.tsx +287 -7
  54. package/src/components/TimePicker/USAGE.md +23 -2
  55. package/src/components/Timeline/USAGE.md +2 -10
  56. package/src/components/Toast/USAGE.md +3 -33
  57. package/src/components/Tooltip/USAGE.md +7 -51
  58. package/src/components/Tree/IMPLEMENTATION_NOTES.md +31 -3
  59. package/src/components/Tree/Tree.module.css +84 -40
  60. package/src/components/Tree/Tree.stories.tsx +13 -0
  61. package/src/components/Tree/Tree.tsx +116 -27
  62. package/src/components/Tree/USAGE.md +18 -1
  63. package/src/index.ts +1 -0
@@ -4,14 +4,42 @@
4
4
 
5
5
  - **`elementProps` already carries the important behavioral wiring.** Mantine's `renderNode` payload includes an `elementProps` object with `onClick` (combined expand+select handler, respecting `expandOnClick`/`selectOnClick`), `data-selected`, `data-value`, and `data-hovered` (set on keyboard/mouse hover via Mantine's internal `hoveredNode` state). We spread these directly onto our own `.row` div rather than reimplementing click/keyboard behavior — only the `className` is overridden with our own token-driven class.
6
6
 
7
- - **The chevron is a decorative element inside the same clickable row, not a separate `<button>`.** The design tokens include a `button-node-gap` token (implying a "button" next to the node label in Figma), but structurally the whole row is already the click target via `elementProps.onClick` (which correctly bundles Mantine's `expandOnClick`/`selectOnClick` logic). Making the chevron its own nested interactive `<button>` inside an `<li>` that Mantine already treats as keyboard-focusable (`tabIndex`, arrow-key handling in `TreeNode.tsx`) would create nested-focusable-element accessibility problems for no real benefit. `button-node-gap` is used as the CSS `gap` between the (decorative) chevron and the label inside `.row` instead.
7
+ - **UPDATED (Matt Massey, 2026-08-08): the chevron is now a real `Button`, superseding the two notes below.** Forge's real design shows the chevron following Button's own styling rules (`variant="text"` `size="small"`) — see the new entry near the end of this file for the full swap and why it doesn't reopen the nested-focusable-element concern the original decision (kept below for history) was worried about.
8
+ - ~~The chevron is a decorative element inside the same clickable row, not a separate `<button>`.~~ The design tokens include a `button-node-gap` token (implying a "button" next to the node label in Figma), but structurally the whole row is already the click target via `elementProps.onClick` (which correctly bundles Mantine's `expandOnClick`/`selectOnClick` logic). Making the chevron its own nested interactive `<button>` inside an `<li>` that Mantine already treats as keyboard-focusable (`tabIndex`, arrow-key handling in `TreeNode.tsx`) would create nested-focusable-element accessibility problems for no real benefit. `button-node-gap` is used as the CSS `gap` between the (decorative) chevron and the label inside `.row` instead.
8
9
 
9
- - **No dedicated icon-color/icon-size token exists for Tree.** The expand chevron is an inline SVG using `stroke="currentColor"`, sized at `1em` (relative to the row's own tokened `font-size`). This means it automatically tracks whichever text-color token (selected/unselected) is active on `.row` — no new token was invented, and no hardcoded color was used.
10
+ - ~~No dedicated icon-color/icon-size token exists for Tree.~~ The expand chevron is an inline SVG using `stroke="currentColor"`, sized at `1em` (relative to the row's own tokened `font-size`). This means it automatically tracks whichever text-color token (selected/unselected) is active on `.row` — no new token was invented, and no hardcoded color was used.
10
11
 
11
- - **KNOWN GAP (2026-08-06): Forge's own preview shows a red chevron with more chevron-to-label spacing than what's currently implemented here.** Re-verified against both the compiled `recursica_variables_scoped.css` and the raw `recursica_ui-kit.json` (synced the same day) — the `tree` schema genuinely has no `icon-color` property anywhere (only `background-color`/`border-color`/`text-color` per selection state, and neither references any red/`core-colors.alert` palette), and `button-node-gap` resolves to `brand.dimensions.general.sm` = 4px, which is exactly what `.row`'s `gap` uses. This was confirmed _not_ to be a mapping bug on our side. Most likely explanation: the chevron in the Figma source isn't bound to a variable at all (Forge previews commonly flag unbound properties in a warning color), and/or its real icon asset has different intrinsic spacing than our hand-drawn placeholder glyph. Needs a Forge/design follow-up — a bound icon-color variable and a confirmed intended gap value — before this can be fixed with a real token rather than a hardcoded guess.
12
+ - **RESOLVED (2026-08-08) — KNOWN GAP (2026-08-06): Forge's own preview shows a red chevron with more chevron-to-label spacing than what's currently implemented here.** Explained by the Button swap below: Forge's chevron is Button's own "text" variant color (Recursica's red/alert-adjacent brand color), not a `tree`-namespace icon-color token — there never was a missing token to find. Re-verified against both the compiled `recursica_variables_scoped.css` and the raw `recursica_ui-kit.json` at the time: the `tree` schema genuinely has no `icon-color` property anywhere, and `button-node-gap` resolves to `brand.dimensions.general.sm` = 4px, exactly what `.row`'s `gap` uses — confirmed not a mapping bug on our side, just the wrong mental model (a hand-drawn glyph instead of an actual Button).
12
13
 
13
14
  - **`--level-offset` override needs `!important`.** Mantine's `Tree` computes each node's indent via a `--level-offset` CSS variable, resolved internally through its `createVarsResolver` mechanism and applied as a scoped rule on the root `<ul>` (same category of override as `Timeline`'s `--mantine-spacing-xl` / `--tl-line-width`). We override it on `.root` with `!important` to reliably beat Mantine's own generated rule regardless of stylesheet insertion order, rather than passing a `levelOffset` prop from TSX (which would mean setting a design-token value from TSX, against `COMPONENT_DEV_GUIDE.md`'s "no custom properties set from TSX for styling" rule).
14
15
 
15
16
  - **`onSelectedChange` is a Recursica addition, not a Mantine API.** Mantine's `useTree()` controller tracks `selectedState` but has no change callback of its own. We watch `tree.selectedState` with a `useEffect` and fire `onSelectedChange` when it changes. This is a behavioral callback, not a design-token reactivity concern, so it doesn't conflict with `COMPONENT_DEV_GUIDE.md §7`'s rule against using `useEffect` for token/styling reactivity.
16
17
 
17
18
  - **Deliberately not implemented (no tokens back them):** checkbox/indeterminate node state (Mantine's `Tree` supports `checkNode`/`isNodeIndeterminate`, but the Figma UI Kit's `tree` tokens only define `selected`/`unselected`, not a checked state) and per-node `disabled` (no token, and no `disabled` field in Mantine's `TreeNodeData` either). If either is needed later, it needs a token/schema addition first, not a component-level workaround.
19
+
20
+ - **Keyboard nav (Matt Massey, 2026-08-08): arrow up/down already worked — only the focus ring was missing.** Mantine's own `TreeNode` already implements full roving-tabindex keyboard navigation (`ArrowUp`/`ArrowDown` move focus between rows, `ArrowLeft`/`ArrowRight` collapse/expand, `Space` toggles) — confirmed via reading `TreeNode.mjs` directly and verifying with Playwright that focus genuinely moves between rows on `ArrowDown`. The reason it looked broken: DOM focus lands on `.node` (the `<li role="treeitem">`), and neither `.node` nor `.row` had any focus style at all (`outline: none`, no box-shadow) — so keyboard navigation was invisible, and only `Space`/`ArrowLeft`/`ArrowRight` were noticeable at all, since those also cause a visible expand/collapse side effect. Fixed by adding a `:focus-visible` box-shadow ring (same tokens as every other focusable component), keyed off `.node:focus-visible` since that's where DOM focus actually lands. Originally drawn on `.row` (the whole button+label box); see the next entry for why it now targets `.label` only.
21
+
22
+ - **Chevron → `Button` (Matt Massey, 2026-08-08).** Forge's real design uses an actual `Button` (`variant="text"` `size="small"`, icon-only) for the expand/collapse chevron, not a hand-drawn glyph — see the resolved "KNOWN GAP" above. Swapped `ExpandGlyph`'s plain `<span>` wrapper for a real `<Button icon={<ExpandGlyph/>} tabIndex={-1} aria-hidden="true" .../>`:
23
+
24
+ - **Doesn't reopen the nested-focusable-element concern** the original (now-struck-through) decision above was worried about: the embedded Button is never independently focusable or tab-stoppable (`tabIndex={-1}`) and is hidden from assistive tech (`aria-hidden`) — the row (`.node`) stays the single focusable/interactive element. Confirmed `Button`/`UnstyledButton` don't call `stopPropagation()` on click, so clicking the chevron still bubbles up to the row's existing `elementProps.onClick` and triggers expand/collapse exactly as before — no `onClick` needed on the embedded Button itself.
25
+ - **Rendered for every row, including leaves**, so every row reserves identical layout space; `.row:not([data-has-children]) .expandButton { visibility: hidden; }` hides it on leaf rows without needing to duplicate Button's own size tokens for a placeholder.
26
+ - **Rotation targets the glyph directly, not Button's internals**: `ExpandGlyph`'s `<svg>` carries its own `styles.expandGlyph` class, targeted via `.row[data-expanded] .expandGlyph` — a plain descendant-combinator selector that reaches the glyph regardless of how deeply `Button` nests it internally (`Button`'s own `.iconWrapper` isn't reachable from `Tree.module.css` at all — CSS Modules don't expose a stable cross-file class name for it, unlike Mantine's own `mantine-*` global classes).
27
+ - **Focus ring narrowed to `.label` only, excluding the Button** (Matt: "the focus ring for an item should be around just the node, not including the chevron button" / "the chevron button should not have a focus state"). `.label` was given `flex: 1 1 auto; height: 100%` so the ring still reads as a clean box covering the row's remaining width, not a tight text-only outline.
28
+ - **Found and fixed a real, separate bug while embedding this**: `Button.tsx` (both adapters) explicitly sets `className={finalClass}` (merging `styles.root` with any caller className) but then spreads `{...sanitizedProps}` _after_ it — and `sanitizedProps` still contains the original, unmodified `className` key, since it was only _read_, never deleted. The later spread silently overwrote `finalClass` with just the caller's own class whenever one was passed (e.g. `styles.expandButton` here) — exact same bug class as `Dropdown.tsx`/`BareDropdown.tsx` had. In mui-adapter this was fully visible (the chevron rendered in MUI's own default blue, since `Button-module__root` — and therefore every `[data-variant]` color rule — was missing entirely). In mantine-adapter it happened to be masked: Mantine's `classNames={{root: ...}}` object prop is separate from the plain `className` string and unaffected by the bug, so `Button-module__root` still applied via that path — but the underlying bug was there too, and is now fixed in both.
29
+
30
+ - **Expand/collapse and select made fully independent (Matt Massey, 2026-08-10), superseding the click-bubbling behavior described above.** Previously the chevron was purely decorative and let clicks bubble up to the row's combined expand+select handler; Matt clarified the real requirement: the button must be the _only_ way to expand/collapse on click, a row click must _only_ select, `Enter`/`Space` must _only_ select (never expand, even on a node with children), and `ArrowLeft`/`ArrowRight` must _only_ expand/collapse (never select) — letting a user toggle a subtree open without ever changing selection, and vice versa. Removed the now ill-fitting `expandOnClick`/`selectOnClick` props entirely (per point 5 of that request) — the pattern is fixed, not configurable.
31
+
32
+ - **Click**: `<MantineTree>` is now called with fixed `expandOnClick={false}` `selectOnClick={true}` (so `elementProps.onClick`, spread onto `.row`, only ever selects), and the embedded chevron `Button` now has its own `onClick` that calls `event.stopPropagation()` (so the click never also reaches `.row`'s select handler) and `tree.toggleExpanded(node.value)` directly via the controller — using `tree`/`node`, both already present in `renderNode`'s payload.
33
+ - **Enter/Space**: Mantine's `TreeNode` has no `Enter` handling and only wires `Space` to `toggleExpanded` (`expandOnSpace`, now passed as `false`) — no select-on-key behavior exists in the library at all, and the key handler lives on the `<li role="treeitem">` itself, unreachable through `renderNode`'s `elementProps`. Added a native `keydown` listener on the tree's root `<ul>` (merged via `useMergedRef` with the forwarded `ref`) that calls `tree.select(value)` on `Enter`/`Space`, reading the focused node's value off `event.target`'s own `data-value` (already set there by Mantine). `ArrowLeft`/`ArrowRight` needed no changes — Mantine's own handling already only calls `controller.expand`/`collapse`, never `select`.
34
+ - **Background/focus scoped to the label, not the whole node** (Matt: "The focus/background color should be on the node's label, not the entire node"): moved every visual property (padding, border, font, unselected/selected colors, hover overlay) off `.row` and onto `.label` — `.row` is now a plain flex layout container (indentation + `button-node-gap` only). `data-selected` is read directly off `renderNode`'s own `selected` payload field and applied to `.label` itself (no need to also spread `elementProps`'s copy there); `data-hovered`/`:hover` are still only observable on `.row` (from `elementProps`), so those states reach `.label` via `.row:hover .label`/`.row[data-hovered] .label` descendant selectors instead. The focus ring (added in the entry above) already targeted `.label` only, so it needed no further change.
35
+
36
+ - **Three follow-on fixes to the label-scoping work above (Matt Massey, 2026-08-10):**
37
+
38
+ - **Selected/hover chip was full-row width, not label width.** `.label` had `flex: 1 1 auto` — the `1` flex-grow stretched it to fill `.row`'s entire remaining width (button aside), so the highlighted chip visually covered the whole row even though the _properties_ were correctly scoped to `.label`. Changed to `flex: 0 1 auto` (no grow, same shrink/basis) so the chip sizes to its own content, matching Forge.
39
+ - **Focusing an expanded parent drew the ring on every descendant label too.** `.node:focus-visible .label` is a plain descendant selector — every child node's `.label` (inside the nested `.subtree` `<ul>`) is _also_ a descendant of the focused parent's `.node` `<li>`, at any depth, so the ring matched all of them at once. Fixed by scoping to `.node:focus-visible > .row .label`: `.row` is always a direct child of its own `.node`, but never of an ancestor `.node` (those reach it through the intervening `.subtree` `<ul>` and nested `<li>` instead) — the `>` combinator excludes every one of them.
40
+ - No MUI-style leftover "default selected background" issue existed here — Mantine's own `Tree` has no default row styling of its own once a custom `renderNode` is supplied (see the very first entry in this file), so there was nothing to neutralize on this side.
41
+
42
+ - **Whole-tree `disabled` (Matt Massey, 2026-08-10), added to support a `Disabled` story.** Mantine's `Tree`/`useTree`/`TreeNode` have no `disabled` concept anywhere in their API, unlike `@mui/x-tree-view` (which already had per-item `disabled` plumbing sitting mostly unused — see mui-adapter's own note on this). Per-node disabling still isn't exposed (no token, no `disabled` field on `RecursicaTreeNode` — same reasoning as the existing "Deliberately not implemented" entry above), only a single whole-tree toggle.
43
+ - **Mouse**: `selectOnClick={!disabled}` reuses Mantine's own flag for row clicks. The chevron `Button`'s `onClick` bypasses that flag entirely (calls `tree.toggleExpanded` directly), so it needs its own explicit `if (disabled) return;` guard. `.root[data-disabled] { pointer-events: none; }` is a second, CSS-only backstop covering both at once — belt-and-suspenders, not strictly required given the two guards above, but consistent with how little the library gives us to rely on here.
44
+ - **Keyboard**: our own `Enter`/`Space` → `select` listener (added in the entry above) just checks `disabled` at the top now. `ArrowLeft`/`ArrowRight` expand/collapse is baked into Mantine's own `TreeNode.handleKeyDown`, on the `<li>` itself, with no prop to disable it — the only reachable way to block it is a _second_, capture-phase `keydown` listener on the tree root that unconditionally calls `stopPropagation()` when disabled. Since capture fires before the event ever reaches its target (the focused `<li>`), this keeps Mantine's internal handler — and our own bubble-phase listener on the same root — from ever running, without needing to fork `TreeNode`.
45
+ - **Visual**: `opacity: var(--recursica_brand_states_disabled)` on `.root` — the generic disabled token, same convention used everywhere else in the design system for components without a dedicated disabled token (no `tree`-specific one exists). A selected node's chip stays visible underneath, just dimmed along with everything else, per Matt's ask to verify that combination looks right.
@@ -1,11 +1,15 @@
1
1
  /* HARDCODED VALUES:
2
2
  * - list-style/margin/padding resets on .root/.subtree: layout resets, no corresponding
3
3
  * design tokens (structural, not visual design values).
4
- * - .expandIcon size (1em) and rotation transition: no dedicated icon-size/icon-color token
5
- * exists for Tree; the glyph is drawn with `currentColor` so it always tracks the row's
6
- * already-tokened text color, and sized relative to the row's own font-size.
4
+ * - .expandGlyph's rotation transition (150ms ease): no dedicated timing token exists for Tree;
5
+ * the glyph itself is sized/colored entirely by Button's own "text"/"small" tokens now, not a
6
+ * hand-drawn size/color like the previous plain-SVG implementation.
7
7
  * - Hover overlay uses the generic --recursica_brand_states_hover_* tokens (same technique as
8
8
  * Menu/Accordion/Button): Tree has no component-specific hover tokens of its own.
9
+ *
10
+ * NOTE: the row's box model (padding/border/font/color/background) lives entirely on `.label`,
11
+ * not `.row` — the highlighted "chip" (hover/selected/focus) covers only the label, never the
12
+ * expand button. `.row` itself is a plain flex layout container (indentation + button-node-gap).
9
13
  */
10
14
 
11
15
  .root {
@@ -25,6 +29,16 @@
25
29
  ) !important;
26
30
  }
27
31
 
32
+ /* Whole-tree `disabled` (no per-node concept — see RecursicaTreeProps.ts). `pointer-events: none`
33
+ blocks every mouse interaction tree-wide in one rule (row clicks, chevron clicks); keyboard is
34
+ separately blocked in Tree.tsx, since pointer-events has no effect on Tab/Enter/Space/arrows.
35
+ Uses the generic disabled opacity token, same convention as every other disabled component in
36
+ the design system (no tree-specific disabled token exists). */
37
+ .root[data-disabled] {
38
+ opacity: var(--recursica_brand_states_disabled);
39
+ pointer-events: none;
40
+ }
41
+
28
42
  .subtree {
29
43
  margin: 0;
30
44
  padding: 0;
@@ -36,11 +50,33 @@
36
50
 
37
51
  .node {
38
52
  /* Mantine's own base class already resets margin/padding/list-style and sets cursor:pointer
39
- on this <li>; it also holds the subtree as a second child, so all row-level visual styling
40
- (box model, colors, typography) lives on .row instead, not here. */
53
+ on this <li>; it also holds the subtree as a second child, so all node-level visual styling
54
+ (box model, colors, typography) lives on .label instead, not here. */
41
55
  position: relative;
42
56
  }
43
57
 
58
+ /* Mantine's roving-tabindex keyboard navigation (arrow up/down moves focus between rows, left/
59
+ right expand/collapse, matching the WAI-ARIA treeitem pattern) already works out of the box —
60
+ but with no visible indicator, it looked broken. DOM focus lands on this <li> (.node), not the
61
+ visible row box, so the ring is drawn on .label instead — the "node" content area, deliberately
62
+ excluding the expand Button, which never shows a focus state of its own (it's not independently
63
+ focusable at all: tabIndex={-1}). */
64
+ .node:focus-visible {
65
+ outline: none;
66
+ }
67
+ /* `> .row` (child combinator), not a plain descendant selector: a node's own `.row` is always a
68
+ direct child of its `.node` <li>, while every descendant node's `.row` (inside the nested
69
+ `.subtree` <ul>) is not — without the `>`, focusing a parent node drew the ring on every
70
+ expanded descendant's label too, since they're all still descendants of the focused `.node`. */
71
+ .node:focus-visible > .row .label {
72
+ box-shadow:
73
+ 0 0 0 var(--recursica_brand_states_focus_border-size)
74
+ var(--recursica_brand_states_focus_color),
75
+ 0 0 var(--recursica_brand_states_focus_blur)
76
+ var(--recursica_brand_states_focus_margin)
77
+ var(--recursica_brand_states_focus_color);
78
+ }
79
+
44
80
  .row {
45
81
  position: relative;
46
82
  box-sizing: border-box;
@@ -49,6 +85,39 @@
49
85
  cursor: pointer;
50
86
  margin-left: var(--label-offset, 0px);
51
87
  gap: var(--recursica_ui-kit_components_tree_properties_button-node-gap);
88
+ }
89
+
90
+ /* The expand Button is always rendered (even for leaf nodes, which have nothing to toggle) so
91
+ every row reserves identical layout space — hidden via visibility (not display) on leaf rows so
92
+ the label stays aligned across the whole tree without duplicating Button's own size tokens. */
93
+ .expandButton {
94
+ position: relative;
95
+ z-index: 1;
96
+ flex-shrink: 0;
97
+ }
98
+ .row:not([data-has-children]) .expandButton {
99
+ visibility: hidden;
100
+ }
101
+
102
+ .expandGlyph {
103
+ color: inherit;
104
+ }
105
+ .row[data-has-children] .expandGlyph {
106
+ transition: transform 150ms ease;
107
+ }
108
+ .row[data-expanded] .expandGlyph {
109
+ transform: rotate(90deg);
110
+ }
111
+
112
+ .label {
113
+ position: relative;
114
+ z-index: 1;
115
+ /* Sized to its own content, not stretched to fill the row's remaining width — the
116
+ hover/selected/focus "chip" should cover just the label, not the full row width. */
117
+ flex: 0 1 auto;
118
+ box-sizing: border-box;
119
+ display: flex;
120
+ align-items: center;
52
121
  padding: var(--recursica_ui-kit_components_tree_properties_vertical-padding)
53
122
  var(--recursica_ui-kit_components_tree_properties_horizontal-padding);
54
123
  border-style: solid; /* HARDCODE: structural border rule; thickness/color are tokened */
@@ -93,24 +162,28 @@
93
162
  );
94
163
  }
95
164
 
96
- /* Hover overlay via ::after pseudo-element (same technique as Menu/Accordion) */
97
- .row::after {
165
+ /* Hover overlay via ::after pseudo-element (same technique as Menu/Accordion), scoped to the
166
+ label's own box so hover never visually covers the expand button. `.label`'s `position:
167
+ relative` + `z-index: 1` above already forms a stacking context, so `z-index: -1` here paints
168
+ the overlay behind the label's real text content within that context, not behind `.label`
169
+ itself. */
170
+ .label::after {
98
171
  content: "";
99
172
  position: absolute;
100
173
  inset: 0;
101
174
  border-radius: inherit;
102
- z-index: 0;
175
+ z-index: -1;
103
176
  pointer-events: none;
104
177
  background-color: var(--recursica_brand_states_hover_color);
105
178
  opacity: 0;
106
179
  transition: opacity 150ms ease;
107
180
  }
108
- .row:hover::after,
109
- .row[data-hovered]::after {
181
+ .row:hover .label::after,
182
+ .row[data-hovered] .label::after {
110
183
  opacity: var(--recursica_brand_states_hover_opacity);
111
184
  }
112
185
 
113
- .row[data-selected] {
186
+ .label[data-selected] {
114
187
  font-family: var(
115
188
  --recursica_ui-kit_components_tree_variants_selection-states_selected_properties_text_font-family
116
189
  );
@@ -146,32 +219,3 @@
146
219
  --recursica_ui-kit_components_tree_variants_selection-states_selected_properties_colors_border-color
147
220
  );
148
221
  }
149
-
150
- .expandIcon {
151
- position: relative;
152
- z-index: 1;
153
- display: flex;
154
- align-items: center;
155
- justify-content: center;
156
- flex-shrink: 0;
157
- width: 1em;
158
- height: 1em;
159
- color: inherit;
160
- }
161
-
162
- .row[data-has-children] .expandIcon {
163
- transition: transform 150ms ease;
164
- }
165
- .row[data-expanded] .expandIcon {
166
- transform: rotate(90deg);
167
- }
168
-
169
- .label {
170
- position: relative;
171
- z-index: 1;
172
- color: inherit;
173
- font: inherit;
174
- letter-spacing: inherit;
175
- text-decoration: inherit;
176
- text-transform: inherit;
177
- }
@@ -80,6 +80,19 @@ export const MultipleSelection: StoryObj<typeof Tree> = {
80
80
  ),
81
81
  };
82
82
 
83
+ /** Whole tree disabled, with a node pre-selected so the selected chip's styling under the
84
+ * disabled dimming can be checked alongside unselected rows. */
85
+ export const Disabled: StoryObj<typeof Tree> = {
86
+ render: () => (
87
+ <Tree
88
+ data={sampleData}
89
+ initialExpandedValues={["documents"]}
90
+ initialSelectedValues={["documents/resume.pdf"]}
91
+ disabled
92
+ />
93
+ ),
94
+ };
95
+
83
96
  /** Demonstrates the component nested inside a non-default layer — the one case where an
84
97
  * explicit `<Layer>` wrap belongs in a story (see COMPONENT_STORYBOOK_GUIDE.md §9). */
85
98
  export const LayerOne: StoryObj<typeof Tree> = {
@@ -5,22 +5,27 @@ import {
5
5
  getTreeExpandedState,
6
6
  type RenderTreeNodePayload,
7
7
  } from "@mantine/core";
8
+ import { useMergedRef } from "@mantine/hooks";
8
9
  import {
9
10
  filterStylingProps,
10
11
  type RecursicaOverStyled,
11
12
  } from "../../utils/filterStylingProps";
12
13
  import { type RecursicaTreeProps } from "@recursica/adapter-common";
14
+ import { Button } from "../Button/Button";
13
15
  import styles from "./Tree.module.css";
14
16
 
15
17
  export type TreeProps = RecursicaOverStyled<
16
18
  RecursicaTreeProps & Omit<React.ComponentPropsWithoutRef<"ul">, "children">
17
19
  >;
18
20
 
19
- /** Simple chevron glyph; rotates via `[data-expanded]` in CSS. Uses `currentColor` so it
20
- * always matches the row's tokened text color — Tree has no dedicated icon-color token. */
21
+ /** Simple chevron glyph; rotates via the row's `[data-expanded]` in CSS (`.row[data-expanded]
22
+ * .expandGlyph`) — this reaches the glyph regardless of how deeply `Button` nests it internally,
23
+ * since it's a plain descendant-combinator selector, not dependent on Button's own DOM structure.
24
+ * Uses `currentColor` so it always matches Button's own tokened icon color for the "text" variant. */
21
25
  function ExpandGlyph() {
22
26
  return (
23
27
  <svg
28
+ className={styles.expandGlyph}
24
29
  viewBox="0 0 16 16"
25
30
  width="1em"
26
31
  height="1em"
@@ -36,25 +41,56 @@ function ExpandGlyph() {
36
41
  );
37
42
  }
38
43
 
39
- function renderTreeNode({
40
- node,
41
- hasChildren,
42
- expanded,
43
- elementProps,
44
- }: RenderTreeNodePayload) {
45
- return (
46
- <div
47
- {...elementProps}
48
- className={styles.row}
49
- data-has-children={hasChildren || undefined}
50
- data-expanded={hasChildren && expanded ? true : undefined}
51
- >
52
- <span className={styles.expandIcon} aria-hidden="true">
53
- {hasChildren && <ExpandGlyph />}
54
- </span>
55
- <span className={styles.label}>{node.label}</span>
56
- </div>
57
- );
44
+ /** Curried so the fixed-shape `renderNode` callback (Mantine controls its signature) can still
45
+ * close over `disabled`, which isn't part of `RenderTreeNodePayload`. */
46
+ function createRenderTreeNode(disabled: boolean) {
47
+ return function renderTreeNode({
48
+ node,
49
+ hasChildren,
50
+ expanded,
51
+ selected,
52
+ tree,
53
+ elementProps,
54
+ }: RenderTreeNodePayload) {
55
+ return (
56
+ <div
57
+ {...elementProps}
58
+ className={styles.row}
59
+ data-has-children={hasChildren || undefined}
60
+ data-expanded={hasChildren && expanded ? true : undefined}
61
+ >
62
+ {/* The only element that toggles expand/collapse on click — independent of selection,
63
+ which `elementProps.onClick` (spread onto `.row` above) handles for the rest of the
64
+ row. Stops propagation so the click never also reaches `.row`'s own handler and
65
+ selects the node. Never independently focusable/tab-stoppable (tabIndex={-1},
66
+ aria-hidden), so the row stays the only focusable element and this button never
67
+ shows its own focus state. Always rendered, even for leaf nodes, so every row
68
+ reserves the same layout space; CSS hides it (visibility, not display) when there
69
+ are no children to toggle. Guards `disabled` itself (not just via the CSS
70
+ `pointer-events: none` on `.root`) since this handler bypasses Mantine's own
71
+ `expandOnClick`/`selectOnClick` flags entirely by calling `tree.toggleExpanded`
72
+ directly. */}
73
+ <Button
74
+ overStyled
75
+ variant="text"
76
+ size="small"
77
+ icon={<ExpandGlyph />}
78
+ aria-label="Toggle subtree"
79
+ aria-hidden="true"
80
+ tabIndex={-1}
81
+ className={styles.expandButton}
82
+ onClick={(event) => {
83
+ event.stopPropagation();
84
+ if (disabled) return;
85
+ tree.toggleExpanded(node.value);
86
+ }}
87
+ />
88
+ <span className={styles.label} data-selected={selected || undefined}>
89
+ {node.label}
90
+ </span>
91
+ </div>
92
+ );
93
+ };
58
94
  }
59
95
 
60
96
  /**
@@ -64,6 +100,11 @@ function renderTreeNode({
64
100
  * Wraps Mantine's `Tree` with a fully custom node renderer so every visual aspect (row box
65
101
  * model, selected/unselected colors and typography, indent, item spacing) comes from
66
102
  * Recursica's `tree` design tokens rather than Mantine's defaults.
103
+ *
104
+ * **Interaction pattern (fixed, not prop-configurable):** expand/collapse and select are
105
+ * independent — the chevron button toggles a node's subtree only, clicking the rest of a row
106
+ * (or pressing `Enter`/`Space`) selects it only, and `ArrowLeft`/`ArrowRight` toggle expansion
107
+ * only. See `RecursicaTreeProps` for the full breakdown.
67
108
  */
68
109
  export const Tree = forwardRef<HTMLUListElement, TreeProps>(function Tree(
69
110
  {
@@ -72,8 +113,7 @@ export const Tree = forwardRef<HTMLUListElement, TreeProps>(function Tree(
72
113
  initialExpandedValues,
73
114
  initialSelectedValues,
74
115
  multiple = false,
75
- expandOnClick = true,
76
- selectOnClick = true,
116
+ disabled = false,
77
117
  onNodeExpand,
78
118
  onNodeCollapse,
79
119
  onSelectedChange,
@@ -99,6 +139,53 @@ export const Tree = forwardRef<HTMLUListElement, TreeProps>(function Tree(
99
139
  onSelectedChange?.(tree.selectedState);
100
140
  }, [tree.selectedState, onSelectedChange]);
101
141
 
142
+ // `Enter`/`Space` select the focused node. Mantine's own `TreeNode` only wires this for
143
+ // `Space`, and only as an expand toggle (`expandOnSpace`, disabled below) — it has no
144
+ // select-on-key behavior and no `Enter` handling at all, and its key handler lives on the
145
+ // `<li role="treeitem">` itself (not reachable through `renderNode`'s `elementProps`), so this
146
+ // is added as a native listener on the tree root instead. `tree.select` is referentially
147
+ // stable (Mantine wraps it in `useCallback` with no deps), so this effect only re-runs when
148
+ // `disabled` actually changes.
149
+ const rootRef = React.useRef<HTMLUListElement>(null);
150
+ useEffect(() => {
151
+ const root = rootRef.current;
152
+ if (!root) return;
153
+ const handleKeyDown = (event: KeyboardEvent) => {
154
+ if (disabled) return;
155
+ if (event.key !== "Enter" && event.key !== " ") return;
156
+ const treeItem = (event.target as HTMLElement).closest<HTMLElement>(
157
+ '[role="treeitem"]',
158
+ );
159
+ const value = treeItem?.dataset.value;
160
+ if (!value) return;
161
+ event.preventDefault();
162
+ tree.select(value);
163
+ };
164
+ root.addEventListener("keydown", handleKeyDown);
165
+ return () => root.removeEventListener("keydown", handleKeyDown);
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, [tree.select, disabled]);
168
+
169
+ // When disabled, block every keydown at the capture phase — before it can reach either the
170
+ // listener above or Mantine's own internal per-node keydown handling (`ArrowLeft`/`ArrowRight`
171
+ // expand/collapse, baked into `TreeNode` itself with no prop to disable it). A capture-phase
172
+ // `stopPropagation` here keeps the event from ever reaching its target, so neither listener
173
+ // fires — this is the only reachable way to block Mantine's internal handling for a component
174
+ // with no `disabled` concept of its own. Mouse clicks are separately blocked via `pointer-
175
+ // events: none` in CSS (`.root[data-disabled]`); this effect only needs to cover keyboard.
176
+ useEffect(() => {
177
+ const root = rootRef.current;
178
+ if (!root || !disabled) return;
179
+ const blockAllKeydown = (event: KeyboardEvent) => {
180
+ event.preventDefault();
181
+ event.stopPropagation();
182
+ };
183
+ root.addEventListener("keydown", blockAllKeydown, { capture: true });
184
+ return () =>
185
+ root.removeEventListener("keydown", blockAllKeydown, { capture: true });
186
+ }, [disabled]);
187
+ const mergedRef = useMergedRef(ref, rootRef);
188
+
102
189
  const classNameProp = (sanitizedProps as Record<string, unknown>)
103
190
  .className as string | undefined;
104
191
  const rootClass = classNameProp
@@ -107,12 +194,14 @@ export const Tree = forwardRef<HTMLUListElement, TreeProps>(function Tree(
107
194
 
108
195
  return (
109
196
  <MantineTree
110
- ref={ref}
197
+ ref={mergedRef}
111
198
  data={data}
112
199
  tree={tree}
113
- expandOnClick={expandOnClick}
114
- selectOnClick={selectOnClick}
115
- renderNode={renderTreeNode}
200
+ expandOnClick={false}
201
+ selectOnClick={!disabled}
202
+ expandOnSpace={false}
203
+ renderNode={createRenderTreeNode(disabled)}
204
+ data-disabled={disabled || undefined}
116
205
  classNames={{
117
206
  root: rootClass,
118
207
  node: styles.node,
@@ -57,7 +57,24 @@ Each node needs a unique `value` and a `label`. Any node with a `children` array
57
57
 
58
58
  - `initialExpandedValues` accepts an array of node values, or `"*"` to start with every node expanded.
59
59
  - `multiple` allows more than one node to be selected at once (default: single-select).
60
- - `expandOnClick` / `selectOnClick` (both default `true`) control whether clicking a row toggles its expansion and/or selection.
60
+
61
+ ### Interaction pattern
62
+
63
+ Expanding/collapsing and selecting are independent, fixed interactions (not prop-configurable):
64
+
65
+ - Clicking the expand/collapse chevron toggles that node's subtree only — it never selects.
66
+ - Clicking anywhere else on a row selects it — it never toggles expansion.
67
+ - `Enter`/`Space` on a focused node selects it, even if the node has children.
68
+ - `ArrowLeft`/`ArrowRight` expand/collapse the focused node only, without changing selection.
69
+ - `ArrowUp`/`ArrowDown` move focus between rows.
70
+
71
+ ### Disabled
72
+
73
+ ```tsx
74
+ <Tree data={data} initialSelectedValues={["1.1"]} disabled />
75
+ ```
76
+
77
+ `disabled` disables the whole tree — no expand/collapse or select via click or keyboard, dimmed to the standard disabled opacity. A pre-selected node stays visibly selected, just dimmed along with the rest. There's no per-node disabled state (see **Known Constraints** below).
61
78
 
62
79
  ---
63
80
 
package/src/index.ts CHANGED
@@ -175,4 +175,5 @@ export type {
175
175
  RecursicaSwitchGroupProps,
176
176
  RecursicaTextAreaProps,
177
177
  RecursicaTextFieldProps,
178
+ RecursicaTimePickerProps,
178
179
  } from "./components";