@exegia/corpora-ui 0.20.0 → 0.22.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/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/layout.d.ts +4 -1
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -12
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/__tests__/logo.test.d.ts +1 -0
- package/dist-lib/components/composed/logo.d.ts +34 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +4 -4
- package/dist-lib/components/composed/tree/index.d.ts +3 -1
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
- package/dist-lib/components/composed/tree/type.d.ts +116 -18
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/component.d.ts +1 -8
- package/dist-lib/components/user-avatar/index.d.ts +5 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +53 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
- package/dist-lib/components/user-avatar/utils.d.ts +22 -0
- package/dist-lib/index.d.ts +6 -3
- package/dist-lib/index.js +3732 -2670
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +15 -12
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/layout.ts +30 -2
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +125 -30
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
- package/src/components/blocks/nav/sidebar/utils.ts +1 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -0
- package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -13
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/__tests__/logo.test.tsx +60 -0
- package/src/components/composed/logo.tsx +159 -0
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
- package/src/components/composed/tree/constants.ts +4 -4
- package/src/components/composed/tree/index.ts +38 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-node.tsx +117 -78
- package/src/components/composed/tree/tree.tsx +24 -16
- package/src/components/composed/tree/type.ts +125 -15
- package/src/components/composed/tree/use-tree-dnd.ts +82 -51
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +185 -184
- package/src/components/composed/user-avatar.tsx +11 -99
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
- package/src/components/user-avatar/component.tsx +151 -22
- package/src/components/user-avatar/fallback.tsx +3 -1
- package/src/components/user-avatar/index.ts +19 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +58 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +180 -0
- package/src/components/user-avatar/user-avatar-atom.ts +218 -0
- package/src/components/user-avatar/utils.ts +94 -0
- package/src/index.ts +35 -3
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
- package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
- package/dist-lib/components/blocks/nav/types.d.ts +0 -60
- package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
- package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
- package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
- package/src/components/blocks/nav/sidebar-block.tsx +0 -142
- package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
- package/src/components/blocks/nav/types.ts +0 -65
- package/src/components/blocks/nav/utils.ts +0 -4
- package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
- package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
- package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
- package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
- package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
- package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
- /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# composed/tree
|
|
2
|
+
|
|
3
|
+
Nested item tree in four shapes — `navigation`, `toc`, `sidebar` (icon rail),
|
|
4
|
+
`files` (explorer). It is the **reference implementation** of the Jotai
|
|
5
|
+
state pattern described in `react/CLAUDE.md` ("State" section) — read that
|
|
6
|
+
first; this file only holds what is specific to the tree.
|
|
7
|
+
|
|
8
|
+
## File map
|
|
9
|
+
|
|
10
|
+
| File | Owns |
|
|
11
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
12
|
+
| `type.ts` | `TreeNode`, `TreeProps` (data form × controller form), `TreeState`/`TreeActions`, `@internal` config/handlers/seed. No imports besides React types. |
|
|
13
|
+
| `tree-atom.ts` | Every atom, keyed by `treeId` (`keyed()` families) and per node (`nodeFamily`). All mutations are write-only action atoms. `debugLabel` = `tree/<id>/<name>`. |
|
|
14
|
+
| `use-tree.ts` | `useTree(options)` — mounts an instance, projects controlled props into the store, returns the `TreeController`. |
|
|
15
|
+
| `use-tree-state.ts` | `useTreeState(id)` (reads, re-renders on any change) and `useTreeActions(id)` (writes only, never re-renders). |
|
|
16
|
+
| `use-tree-dnd.ts` | Stable drag handlers for `files`; they read `draggedId`/`dropTarget` out of the store at call time. |
|
|
17
|
+
| `tree-context.ts` | Context value = `{ treeId, renderTrailing, dnd }` only. Never the controller. |
|
|
18
|
+
| `tree.tsx` | `Tree` (props form → `UncontrolledTree`, controller form → `TreeView`), rail width measurement, roving keyboard nav. |
|
|
19
|
+
| `tree-node.tsx` | `TreeRow` (memoized). Per-node atom subscriptions, row kind resolution, rename input, tooltip, toc overlay toggle, trailing slot, branch animation. |
|
|
20
|
+
| `constants.ts` | Durations, easings, `RAIL_COLLAPSED_WIDTH`, motion variants. |
|
|
21
|
+
| `utils.ts` | Pure tree helpers (`findNode`, `moveNode`, `renameNode`, ancestor walks). |
|
|
22
|
+
| `index.ts` | Barrel. Public atoms are listed explicitly — never `export *`. |
|
|
23
|
+
| `__tests__/` | `tree.test.tsx` (rendering per variant), `use-tree.test.tsx` (controller/hook), `tree-atom.test.tsx` (store + memo guard). |
|
|
24
|
+
|
|
25
|
+
## Row anatomy (as of 2026-08-18)
|
|
26
|
+
|
|
27
|
+
- **Every non-section row is the shared `ui/button` `Button`** (`variant="ghost"`,
|
|
28
|
+
or `"secondary"` for the active sidebar row). Section headings in a 3-level
|
|
29
|
+
`navigation` tree stay a plain `<button>`. **Nothing renders an `<a>` any
|
|
30
|
+
more.**
|
|
31
|
+
- `TreeNode.href` / `target` are **metadata** — they ride along on the node
|
|
32
|
+
handed to `onNavigate`. Routing has exactly one path:
|
|
33
|
+
`selectTreeNodeAtom` → `node.onSelect?.()` → `handlers.onNavigate?.(node)`.
|
|
34
|
+
One native behaviour survives the anchor removal: `toc` rows below the
|
|
35
|
+
route level (depth > 0, or any toc row with a `#hash` href) set
|
|
36
|
+
`window.location.hash` **after** `select` in `handlePress` — same order the
|
|
37
|
+
old `<a>` default gave, same `:target`/history semantics. A non-hash href
|
|
38
|
+
on a toc row disables the jump (it is a route). Controller `select()` from
|
|
39
|
+
outside does NOT jump — the hash write lives in the row, not the atom,
|
|
40
|
+
because it is a DOM concern.
|
|
41
|
+
- Row-level `data-*` (`data-slot="tree-row"`, `data-id`, `data-branch`,
|
|
42
|
+
`data-expanded`, `data-active`, `data-cuelume-press`) come from
|
|
43
|
+
`rowInteractionProps`, spread onto the Button; Base UI's `mergeProps` lets
|
|
44
|
+
them override Button's own `data-slot="button"`. `sound` is forwarded to the
|
|
45
|
+
Button so its press/release cues follow the tree's `sound` prop.
|
|
46
|
+
- Collapsed sidebar rows are `h-10! rounded-xl! justify-center px-0` tiles;
|
|
47
|
+
the rail is `w-10` (40px). `RAIL_COLLAPSED_WIDTH` **must equal** the class
|
|
48
|
+
(a test pins it to 40) — the motion target and the resting class must agree
|
|
49
|
+
or the rail jumps on its first fold.
|
|
50
|
+
- Row kinds (`rowKindOf`): `link` (selectable, `aria-expanded` absent),
|
|
51
|
+
`toggle` (parent in navigation/files, `aria-expanded` on the row),
|
|
52
|
+
`section` (top level of a 3-level navigation tree). `toc` parents are
|
|
53
|
+
`link` rows with a separate overlay `<button aria-label="Expand X">`.
|
|
54
|
+
- The trailing slot (`renderTrailing`, files only) is a **sibling** of the row
|
|
55
|
+
inside `.group/row-actions`, never a child — a button inside a button is
|
|
56
|
+
invalid HTML and React warns.
|
|
57
|
+
|
|
58
|
+
## Lessons learned (chronological)
|
|
59
|
+
|
|
60
|
+
1. **Rows read per-node atoms, not the controller.** Reading the whole
|
|
61
|
+
controller off context re-rendered every row on every toggle.
|
|
62
|
+
`tree-atom.test.tsx` counts `renderTrailing` calls per node to guard the
|
|
63
|
+
memo + per-node subscriptions. (0021dee)
|
|
64
|
+
2. **Controlled props are projected, not mirrored.** `useTree` writes
|
|
65
|
+
controlled `items`/`activeId`/`collapsed` into the store in a layout effect
|
|
66
|
+
with write gates. `treeOwnedItemsAtom` reads empty while a prop owns the
|
|
67
|
+
data — without it an inline `items={[…]}` array loops forever.
|
|
68
|
+
3. **Keep the rail label mounted.** Unmounting the label on collapse killed
|
|
69
|
+
the fold animation; it animates `width/opacity/x/display` in place and the
|
|
70
|
+
test asserts `display: none`, not absence. (12b6a1f)
|
|
71
|
+
4. **Both rail width endpoints must be px.** Motion cannot tween a number
|
|
72
|
+
against `"100%"`; doing so pinned the inline width at the collapsed value
|
|
73
|
+
and the rail never reopened. The expanded width is measured off the
|
|
74
|
+
container, only while expanded, with the list's inline width cleared for
|
|
75
|
+
the sample. (a69072d, 2f7539f)
|
|
76
|
+
5. **Tooltip is disabled, not unmounted, when the rail expands.** Swapping the
|
|
77
|
+
wrapper remounts the row and cuts the label fold short. Test asserts row
|
|
78
|
+
identity across collapse.
|
|
79
|
+
6. **Trailing actions live beside the row.** See "Row anatomy". (2c59e53)
|
|
80
|
+
7. **Rows became `Button`; anchors were removed** (2026-08-18, this branch).
|
|
81
|
+
Consequences that had to follow: tests query `role: "button"` everywhere,
|
|
82
|
+
`href` documentation flipped from "renders an anchor" to "metadata for
|
|
83
|
+
`onNavigate`", `RAIL_COLLAPSED_WIDTH` 44 → 40 to match `w-10`, `sound`
|
|
84
|
+
forwarded to Button, and the toc `#{id}` anchor test was rewritten as
|
|
85
|
+
"row selects, overlay toggle expands". The native `#{id}` jump for toc
|
|
86
|
+
rows was then restored via `location.hash` in `handlePress` (see "Row
|
|
87
|
+
anatomy") — dropping it silently was a regression, not a simplification.
|
|
88
|
+
8. **`settleExit()` must run inside `act`.** AnimatePresence exits finishing
|
|
89
|
+
during a bare `setTimeout` wait produce "not wrapped in act" warnings;
|
|
90
|
+
`act(() => new Promise(r => setTimeout(r, 400)))` keeps them tracked. Query
|
|
91
|
+
**once** after settling — happy-dom serves stale `waitFor` results while an
|
|
92
|
+
exit is in flight (see memory `corpora-ui-happy-dom-exit-animations`).
|
|
93
|
+
|
|
94
|
+
## Testing notes
|
|
95
|
+
|
|
96
|
+
- Run from `react/`: `bun test src/components/composed/tree`.
|
|
97
|
+
- happy-dom cannot drive Base UI hover/focus-visible, so tooltip tests assert
|
|
98
|
+
the wiring (`data-base-ui-tooltip-trigger`) not the popup.
|
|
99
|
+
- happy-dom rects are zero-height, so DnD tests hit the 0.5-midpoint fallback
|
|
100
|
+
("inside" for folders, "after" for leaves).
|
|
101
|
+
- Motion sets no inline styles under happy-dom — the rail width tests lock
|
|
102
|
+
the **class**; the px tween is only verifiable in a real browser.
|
|
103
|
+
- Browser check via the docs demo (`/components/tree`, dev server config
|
|
104
|
+
`corpora-ui-docs`): the demo's Base UI `Select` only takes clicks on the
|
|
105
|
+
option's real viewport rect (screenshot frame is scaled — convert), and if
|
|
106
|
+
the Browser pane tab is hidden (`document.hidden`) both WAAPI and motion's
|
|
107
|
+
frameloop pause, so animated end-states can't be observed there — front the
|
|
108
|
+
tab or use a real browser for the fold.
|
|
109
|
+
|
|
110
|
+
## Change log
|
|
111
|
+
|
|
112
|
+
- **2026-08-18 — fix/tree-collapsed** (on `release/v0.21.0`)
|
|
113
|
+
- `tree-node.tsx`: link/toggle rows render the shared `Button` (ghost /
|
|
114
|
+
secondary when active on sidebar); anchor branch removed; collapsed rail
|
|
115
|
+
rows `h-10! rounded-xl!`; rename input borderless; `cursor-pointer` on
|
|
116
|
+
rows/toggles; `sound` forwarded to Button.
|
|
117
|
+
- `tree.tsx`: rail resting class `w-11` → `w-10`, `gap-y-2!` when collapsed.
|
|
118
|
+
- `constants.ts`: `RAIL_COLLAPSED_WIDTH` 44 → 40.
|
|
119
|
+
- `type.ts` / `registry/components.ts`: `href`, `onNavigate`, `navigation`
|
|
120
|
+
and `toc` docs describe button rows + `onNavigate`-only navigation.
|
|
121
|
+
- `__tests__/tree.test.tsx`: link → button queries; new tests for `href`
|
|
122
|
+
passthrough, `sound={false}`, `RAIL_COLLAPSED_WIDTH` ↔ `w-10`, collapsed
|
|
123
|
+
tile classes, active-row `secondary` variant, toc row-vs-toggle split,
|
|
124
|
+
toc `#{id}` / `#hash` jump after `onNavigate` (route rows leave the hash
|
|
125
|
+
alone).
|
|
126
|
+
- `__tests__/*.test.tsx`: `settleExit` wrapped in `act`.
|
|
127
|
+
- **2026-08-17** — Jotai migration (`ExegiaProvider`, atom families,
|
|
128
|
+
`useTree`/`useTreeState`/`useTreeActions`), rail reopen + measurement fixes,
|
|
129
|
+
label kept mounted, trailing actions moved out of the row, headless
|
|
130
|
+
controller API.
|
|
131
|
+
- **2026-08-16** — Initial four-variant tree, tooltip on collapsed rail rows,
|
|
132
|
+
motion + a11y pass.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import userEvent from "@testing-library/user-event"
|
|
4
|
+
import { createStore } from "jotai"
|
|
5
|
+
|
|
6
|
+
import { ExegiaProvider } from "@/state"
|
|
7
|
+
import { Tree, useTreeActions, useTreeState } from "../index"
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_TREE_CONFIG,
|
|
10
|
+
expandTreeNodeAtom,
|
|
11
|
+
mountTreeAtom,
|
|
12
|
+
removeTreeInstance,
|
|
13
|
+
renameTreeNodeAtom,
|
|
14
|
+
resetTreeAtom,
|
|
15
|
+
treeCanRenameAtom,
|
|
16
|
+
treeCollapsedAtom,
|
|
17
|
+
treeExpandedIdsAtom,
|
|
18
|
+
treeItemsAtom,
|
|
19
|
+
treeRailCollapsedAtom,
|
|
20
|
+
} from "../tree-atom"
|
|
21
|
+
import type { TreeConfig, TreeNode } from "../type"
|
|
22
|
+
|
|
23
|
+
type Store = ReturnType<typeof createStore>
|
|
24
|
+
|
|
25
|
+
const FILES: TreeNode[] = [
|
|
26
|
+
{
|
|
27
|
+
id: "src",
|
|
28
|
+
label: "src",
|
|
29
|
+
children: [{ id: "index-ts", label: "index.ts" }],
|
|
30
|
+
},
|
|
31
|
+
{ id: "readme", label: "README.md" },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function mount(
|
|
35
|
+
store: Store,
|
|
36
|
+
id: string,
|
|
37
|
+
config: Partial<TreeConfig>,
|
|
38
|
+
items: TreeNode[] = FILES
|
|
39
|
+
) {
|
|
40
|
+
store.set(
|
|
41
|
+
mountTreeAtom(id),
|
|
42
|
+
{ ...DEFAULT_TREE_CONFIG, ...config },
|
|
43
|
+
{ items, collapsed: false }
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("tree atoms · instance isolation", () => {
|
|
48
|
+
test("two ids never share state", () => {
|
|
49
|
+
const store = createStore()
|
|
50
|
+
mount(store, "a", { variant: "files" })
|
|
51
|
+
mount(store, "b", { variant: "files" })
|
|
52
|
+
|
|
53
|
+
store.set(expandTreeNodeAtom("a"), "src")
|
|
54
|
+
expect(store.get(treeExpandedIdsAtom("a")).has("src")).toBe(true)
|
|
55
|
+
expect(store.get(treeExpandedIdsAtom("b")).has("src")).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("two stores never share state under the same id", () => {
|
|
59
|
+
const one = createStore()
|
|
60
|
+
const two = createStore()
|
|
61
|
+
mount(one, "shared", { variant: "files" })
|
|
62
|
+
mount(two, "shared", { variant: "files" })
|
|
63
|
+
|
|
64
|
+
one.set(expandTreeNodeAtom("shared"), "src")
|
|
65
|
+
expect(one.get(treeExpandedIdsAtom("shared")).has("src")).toBe(true)
|
|
66
|
+
expect(two.get(treeExpandedIdsAtom("shared")).has("src")).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("removeTreeInstance drops the slice back to its defaults", () => {
|
|
70
|
+
const store = createStore()
|
|
71
|
+
mount(store, "gone", { variant: "files" })
|
|
72
|
+
store.set(expandTreeNodeAtom("gone"), "src")
|
|
73
|
+
|
|
74
|
+
removeTreeInstance("gone")
|
|
75
|
+
expect(store.get(treeExpandedIdsAtom("gone")).size).toBe(0)
|
|
76
|
+
expect(store.get(treeItemsAtom("gone"))).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("tree atoms · write gates", () => {
|
|
81
|
+
test("hook-owned data renames itself", () => {
|
|
82
|
+
const store = createStore()
|
|
83
|
+
mount(store, "owned", { variant: "files", managesItems: true })
|
|
84
|
+
expect(store.get(treeCanRenameAtom("owned"))).toBe(true)
|
|
85
|
+
|
|
86
|
+
store.set(renameTreeNodeAtom("owned"), "readme", "GUIDE.md")
|
|
87
|
+
expect(store.get(treeItemsAtom("owned"))[1]?.label).toBe("GUIDE.md")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("a controlled items prop is never overwritten, only reported", () => {
|
|
91
|
+
const store = createStore()
|
|
92
|
+
const onItemsChange = mock((items: TreeNode[]) => void items)
|
|
93
|
+
mount(store, "controlled", {
|
|
94
|
+
variant: "files",
|
|
95
|
+
controlsItems: true,
|
|
96
|
+
managesItems: true,
|
|
97
|
+
})
|
|
98
|
+
store.set(
|
|
99
|
+
// Handlers ride along with the options in real use; set directly here.
|
|
100
|
+
mountTreeAtom("controlled"),
|
|
101
|
+
{
|
|
102
|
+
...DEFAULT_TREE_CONFIG,
|
|
103
|
+
variant: "files",
|
|
104
|
+
controlsItems: true,
|
|
105
|
+
managesItems: true,
|
|
106
|
+
},
|
|
107
|
+
{ items: FILES, collapsed: false }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
store.set(renameTreeNodeAtom("controlled"), "readme", "GUIDE.md")
|
|
111
|
+
expect(store.get(treeItemsAtom("controlled"))[1]?.label).toBe("README.md")
|
|
112
|
+
expect(onItemsChange).not.toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("the rail fold reads as false outside sidebar", () => {
|
|
116
|
+
const store = createStore()
|
|
117
|
+
mount(store, "files-rail", { variant: "files" })
|
|
118
|
+
store.set(treeRailCollapsedAtom("files-rail"), true)
|
|
119
|
+
expect(store.get(treeCollapsedAtom("files-rail")).valueOf()).toBe(false)
|
|
120
|
+
|
|
121
|
+
mount(store, "sidebar-rail", { variant: "sidebar" })
|
|
122
|
+
store.set(treeRailCollapsedAtom("sidebar-rail"), true)
|
|
123
|
+
expect(store.get(treeCollapsedAtom("sidebar-rail")).valueOf()).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("reset replays the mount seed", () => {
|
|
127
|
+
const store = createStore()
|
|
128
|
+
mount(store, "replay", { variant: "files" })
|
|
129
|
+
store.set(expandTreeNodeAtom("replay"), "src")
|
|
130
|
+
store.set(renameTreeNodeAtom("replay"), "readme", "GUIDE.md")
|
|
131
|
+
|
|
132
|
+
store.set(resetTreeAtom("replay"))
|
|
133
|
+
expect(store.get(treeExpandedIdsAtom("replay")).size).toBe(0)
|
|
134
|
+
expect(store.get(treeItemsAtom("replay"))[1]?.label).toBe("README.md")
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe("ExegiaProvider · driving a tree by id", () => {
|
|
139
|
+
/** A component with no relationship to the Tree beyond its id. */
|
|
140
|
+
function Remote() {
|
|
141
|
+
const actions = useTreeActions("app-files")
|
|
142
|
+
const { expandedIds } = useTreeState("app-files")
|
|
143
|
+
return (
|
|
144
|
+
<button onClick={() => actions.expand("src")} type="button">
|
|
145
|
+
{`expand (${expandedIds.size})`}
|
|
146
|
+
</button>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
test("a sibling component expands the tree through one provider", async () => {
|
|
151
|
+
const user = userEvent.setup()
|
|
152
|
+
render(
|
|
153
|
+
<ExegiaProvider store={createStore()}>
|
|
154
|
+
<Tree items={FILES} treeId="app-files" variant="files" />
|
|
155
|
+
<Remote />
|
|
156
|
+
</ExegiaProvider>
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// src has no defaultOpen, so its child starts unmounted.
|
|
160
|
+
expect(screen.queryByText("index.ts")).toBeNull()
|
|
161
|
+
|
|
162
|
+
await user.click(screen.getByRole("button", { name: /^expand/ }))
|
|
163
|
+
expect(await screen.findByText("index.ts")).toBeDefined()
|
|
164
|
+
// The remote reader saw the change too.
|
|
165
|
+
expect(screen.getByRole("button", { name: "expand (1)" })).toBeDefined()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe("Tree · render granularity", () => {
|
|
170
|
+
test("expanding one branch leaves its sibling's row untouched", async () => {
|
|
171
|
+
const user = userEvent.setup()
|
|
172
|
+
// `renderTrailing` runs inside a row's render, so counting calls per id is
|
|
173
|
+
// a proxy for counting that row's renders.
|
|
174
|
+
const renders = new Map<string, number>()
|
|
175
|
+
const items: TreeNode[] = [
|
|
176
|
+
{ id: "a", label: "a", children: [{ id: "a-1", label: "a-1" }] },
|
|
177
|
+
{ id: "b", label: "b", children: [{ id: "b-1", label: "b-1" }] },
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
function Toggle() {
|
|
181
|
+
const actions = useTreeActions("granular")
|
|
182
|
+
return (
|
|
183
|
+
<button onClick={() => actions.expand("a")} type="button">
|
|
184
|
+
go
|
|
185
|
+
</button>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
render(
|
|
190
|
+
<ExegiaProvider store={createStore()}>
|
|
191
|
+
<Tree
|
|
192
|
+
items={items}
|
|
193
|
+
renderTrailing={(node) => {
|
|
194
|
+
renders.set(node.id, (renders.get(node.id) ?? 0) + 1)
|
|
195
|
+
return null
|
|
196
|
+
}}
|
|
197
|
+
treeId="granular"
|
|
198
|
+
variant="files"
|
|
199
|
+
/>
|
|
200
|
+
<Toggle />
|
|
201
|
+
</ExegiaProvider>
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const beforeA = renders.get("a") ?? 0
|
|
205
|
+
const beforeB = renders.get("b") ?? 0
|
|
206
|
+
expect(beforeA).toBeGreaterThan(0)
|
|
207
|
+
expect(beforeB).toBeGreaterThan(0)
|
|
208
|
+
|
|
209
|
+
await user.click(screen.getByRole("button", { name: "go" }))
|
|
210
|
+
await screen.findByText("a-1")
|
|
211
|
+
|
|
212
|
+
// a's branch opened, so a's row re-rendered…
|
|
213
|
+
expect(renders.get("a")).toBeGreaterThan(beforeA)
|
|
214
|
+
// …and b, whose atoms did not change, did not.
|
|
215
|
+
expect(renders.get("b")).toBe(beforeB)
|
|
216
|
+
})
|
|
217
|
+
})
|