@exegia/corpora-ui 0.19.0 → 0.21.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 (162) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
  9. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  10. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  11. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  13. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  14. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
  15. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  16. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
  17. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
  18. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  19. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  20. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  21. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  22. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  23. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  24. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  25. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  26. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  27. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  28. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  29. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  30. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  31. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  32. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  33. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  34. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  35. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  36. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  37. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  38. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  39. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  40. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  41. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  42. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  43. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  44. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  45. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  46. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  47. package/dist-lib/components/composed/tree/index.d.ts +6 -0
  48. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  49. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  50. package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
  51. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  52. package/dist-lib/components/composed/tree/type.d.ts +290 -0
  53. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
  54. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  55. package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
  56. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  57. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  58. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  59. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  60. package/dist-lib/components/user-avatar/component.d.ts +3 -0
  61. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  62. package/dist-lib/components/user-avatar/index.d.ts +18 -0
  63. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  64. package/dist-lib/components/user-avatar/type.d.ts +71 -0
  65. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  66. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  67. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  68. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  69. package/dist-lib/index.d.ts +6 -2
  70. package/dist-lib/index.js +3576 -1366
  71. package/dist-lib/index.js.map +1 -1
  72. package/dist-lib/state/exegia-provider.d.ts +51 -0
  73. package/dist-lib/state/index.d.ts +4 -0
  74. package/dist-lib/state/store.d.ts +17 -0
  75. package/package.json +16 -12
  76. package/src/components/beste/piece/browser-frame.tsx +9 -6
  77. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  78. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  79. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  80. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  81. package/src/components/blocks/auth/auth-state.ts +52 -0
  82. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  83. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  84. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  85. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  86. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
  87. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  88. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  89. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  90. package/src/components/blocks/nav/sidebar/type.ts +228 -29
  91. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  92. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
  93. package/src/components/blocks/nav/sidebar/utils.ts +29 -1
  94. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  95. package/src/components/blocks/profile/index.ts +28 -0
  96. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  97. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  98. package/src/components/blocks/profile/type.ts +95 -0
  99. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  100. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  101. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  102. package/src/components/blocks/scaffold/constants.ts +7 -0
  103. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  104. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  105. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  106. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  107. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  108. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  109. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  110. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  111. package/src/components/blocks/scaffold/type.ts +28 -0
  112. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  113. package/src/components/blocks/scaffold/utils.ts +19 -0
  114. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  115. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  116. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  117. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  118. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  119. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  120. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  121. package/src/components/blocks/shell/index.ts +18 -0
  122. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  123. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  124. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  125. package/src/components/blocks/shell/type.ts +130 -3
  126. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  127. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  128. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  129. package/src/components/blocks/shell/utils.ts +44 -4
  130. package/src/components/composed/tree/CLAUDE.md +132 -0
  131. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  132. package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
  133. package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
  134. package/src/components/composed/tree/constants.ts +60 -0
  135. package/src/components/composed/tree/index.ts +51 -0
  136. package/src/components/composed/tree/tree-atom.ts +590 -0
  137. package/src/components/composed/tree/tree-context.ts +13 -0
  138. package/src/components/composed/tree/tree-node.tsx +490 -0
  139. package/src/components/composed/tree/tree.tsx +286 -0
  140. package/src/components/composed/tree/type.ts +322 -0
  141. package/src/components/composed/tree/use-tree-dnd.ts +179 -0
  142. package/src/components/composed/tree/use-tree-state.ts +105 -0
  143. package/src/components/composed/tree/use-tree.ts +307 -0
  144. package/src/components/composed/tree/utils.ts +172 -0
  145. package/src/components/composed/user-avatar.tsx +11 -96
  146. package/src/components/docs/component-preview.tsx +11 -11
  147. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  148. package/src/components/user-avatar/audio-wave.tsx +22 -0
  149. package/src/components/user-avatar/component.tsx +152 -0
  150. package/src/components/user-avatar/fallback.tsx +26 -0
  151. package/src/components/user-avatar/index.ts +34 -0
  152. package/src/components/user-avatar/presence-badge.tsx +56 -0
  153. package/src/components/user-avatar/type.ts +85 -0
  154. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  155. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  156. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  157. package/src/components/user-avatar/utils.ts +8 -0
  158. package/src/index.css +211 -153
  159. package/src/index.ts +35 -2
  160. package/src/state/exegia-provider.tsx +79 -0
  161. package/src/state/index.ts +4 -0
  162. package/src/state/store.ts +19 -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
+ })