@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.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -3
- package/dist/mantine-adapter.cjs +2 -2
- package/dist/mantine-adapter.cjs.map +1 -1
- package/dist/mantine-adapter.css +1 -1
- package/dist/mantine-adapter.js +2352 -2099
- package/dist/mantine-adapter.js.map +1 -1
- package/dist/src/components/Dropdown/BareDropdown.d.ts +19 -0
- package/dist/src/components/TimePicker/TimePicker.d.ts +9 -3
- package/dist/src/components/Tree/Tree.d.ts +5 -0
- package/dist/src/index.d.ts +1 -1
- package/docs/PHILOSOPHY.md +39 -0
- package/package.json +3 -2
- package/src/components/Accordion/USAGE.md +2 -41
- package/src/components/AutoComplete/USAGE.md +2 -18
- package/src/components/Avatar/USAGE.md +0 -27
- package/src/components/Badge/USAGE.md +3 -6
- package/src/components/Breadcrumb/USAGE.md +1 -5
- package/src/components/Button/Button.tsx +5 -0
- package/src/components/Button/IMPLEMENTATION_NOTES.md +8 -0
- package/src/components/Button/USAGE.md +5 -25
- package/src/components/Card/USAGE.md +4 -12
- package/src/components/Checkbox/USAGE.md +4 -20
- package/src/components/Chip/USAGE.md +3 -32
- package/src/components/DatePicker/USAGE.md +2 -12
- package/src/components/Dropdown/BareDropdown.tsx +85 -0
- package/src/components/Dropdown/Dropdown.tsx +12 -3
- package/src/components/Dropdown/USAGE.md +2 -6
- package/src/components/Flex/USAGE.md +1 -1
- package/src/components/FormControlWrapper/USAGE.md +3 -31
- package/src/components/Grid/USAGE.md +1 -1
- package/src/components/Group/USAGE.md +1 -1
- package/src/components/HoverCard/USAGE.md +3 -72
- package/src/components/Label/USAGE.md +8 -48
- package/src/components/Link/USAGE.md +4 -10
- package/src/components/Loader/USAGE.md +4 -23
- package/src/components/Menu/USAGE.md +3 -73
- package/src/components/Modal/USAGE.md +3 -3
- package/src/components/NumberInput/USAGE.md +5 -8
- package/src/components/Pagination/USAGE.md +0 -19
- package/src/components/Panel/USAGE.md +6 -95
- package/src/components/Popover/USAGE.md +6 -66
- package/src/components/ReadOnlyField/USAGE.md +2 -10
- package/src/components/SegmentedControl/USAGE.md +1 -15
- package/src/components/Slider/USAGE.md +1 -45
- package/src/components/Stack/USAGE.md +1 -1
- package/src/components/Switch/USAGE.md +1 -24
- package/src/components/TextArea/USAGE.md +2 -2
- package/src/components/TextField/USAGE.md +1 -17
- package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +72 -0
- package/src/components/TimePicker/TimePicker.module.css +225 -41
- package/src/components/TimePicker/TimePicker.stories.tsx +105 -4
- package/src/components/TimePicker/TimePicker.tsx +287 -7
- package/src/components/TimePicker/USAGE.md +23 -2
- package/src/components/Timeline/USAGE.md +2 -10
- package/src/components/Toast/USAGE.md +3 -33
- package/src/components/Tooltip/USAGE.md +7 -51
- package/src/components/Tree/IMPLEMENTATION_NOTES.md +31 -3
- package/src/components/Tree/Tree.module.css +84 -40
- package/src/components/Tree/Tree.stories.tsx +13 -0
- package/src/components/Tree/Tree.tsx +116 -27
- package/src/components/Tree/USAGE.md +18 -1
- 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
|
-
- **
|
|
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
|
-
-
|
|
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`
|
|
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
|
-
* - .
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
40
|
-
(box model, colors, typography) lives on .
|
|
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
|
-
.
|
|
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:
|
|
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
|
-
.
|
|
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
|
|
20
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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={
|
|
197
|
+
ref={mergedRef}
|
|
111
198
|
data={data}
|
|
112
199
|
tree={tree}
|
|
113
|
-
expandOnClick={
|
|
114
|
-
selectOnClick={
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|