@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,525 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { FileTextIcon, FolderIcon, SearchIcon } from "lucide-react"
5
+
6
+ import { RAIL_COLLAPSED_WIDTH } from "../constants"
7
+ import { Tree } from "../index"
8
+ import type { TreeNode } from "../type"
9
+ import { moveNode } from "../utils"
10
+
11
+ /** 3-level data — `navigation` promotes the top level to section names. */
12
+ const SECTIONED: TreeNode[] = [
13
+ {
14
+ id: "research",
15
+ label: "Research",
16
+ children: [
17
+ { id: "search", label: "Search", icon: <SearchIcon />, href: "/search" },
18
+ {
19
+ id: "library",
20
+ label: "Library",
21
+ children: [
22
+ { id: "manuscripts", label: "Manuscripts", href: "/m" },
23
+ { id: "codices", label: "Codices", href: "/c" },
24
+ ],
25
+ },
26
+ ],
27
+ },
28
+ {
29
+ id: "workspace",
30
+ label: "Workspace",
31
+ children: [{ id: "notes", label: "Notes", href: "/notes" }],
32
+ },
33
+ ]
34
+
35
+ /** 2-level data — the top level stays plain link rows. */
36
+ const FLAT_NAV: TreeNode[] = [
37
+ { id: "search", label: "Search", href: "/search" },
38
+ {
39
+ id: "library",
40
+ label: "Library",
41
+ children: [{ id: "manuscripts", label: "Manuscripts", href: "/m" }],
42
+ },
43
+ ]
44
+
45
+ const TOC: TreeNode[] = [
46
+ {
47
+ id: "guide",
48
+ label: "Guide",
49
+ href: "/guide",
50
+ children: [
51
+ {
52
+ id: "setup",
53
+ label: "Setup",
54
+ children: [{ id: "install", label: "Install" }],
55
+ },
56
+ ],
57
+ },
58
+ { id: "api", label: "API", href: "/api" },
59
+ ]
60
+
61
+ const RAIL: TreeNode[] = [
62
+ { id: "search", label: "Search", icon: <SearchIcon />, href: "/search" },
63
+ { id: "notes", label: "Notes", icon: <FileTextIcon />, href: "/notes" },
64
+ ]
65
+
66
+ const FILES: TreeNode[] = [
67
+ {
68
+ id: "src",
69
+ label: "src",
70
+ icon: <FolderIcon />,
71
+ defaultOpen: true,
72
+ children: [
73
+ { id: "index-ts", label: "index.ts" },
74
+ { id: "utils-ts", label: "utils.ts" },
75
+ ],
76
+ },
77
+ { id: "readme", label: "README.md" },
78
+ ]
79
+
80
+ /** Branch exits run through AnimatePresence — let them settle on real
81
+ * timers, then query once (happy-dom serves stale waitFor queries while
82
+ * an exit is in flight). Wrapped in act so the presence teardown that lands
83
+ * during the wait is a tracked React update, not an "outside act" warning. */
84
+ const settleExit = () =>
85
+ act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)))
86
+
87
+ describe("Tree · navigation", () => {
88
+ test("3-level data renders top level as collapsible sections above button rows", async () => {
89
+ const user = userEvent.setup()
90
+ render(<Tree items={SECTIONED} variant="navigation" />)
91
+
92
+ const section = screen.getByRole("button", { name: "Research" })
93
+ expect(section.getAttribute("aria-expanded")).toBe("true")
94
+ // Sections are plain heading buttons; item rows are the shared Button.
95
+ // Neither is an anchor — `href` is metadata for onNavigate now.
96
+ expect(document.querySelector("a")).toBeNull()
97
+ const search = screen.getByRole("button", { name: "Search" })
98
+ expect(search.getAttribute("data-slot")).toBe("tree-row")
99
+ expect(search.getAttribute("type")).toBe("button")
100
+
101
+ // Collapsing the section folds its items away.
102
+ await user.click(section)
103
+ await settleExit()
104
+ expect(screen.queryByRole("button", { name: "Search" })).toBeNull()
105
+ })
106
+
107
+ test("2-level data keeps top level as leaf rows and parents as toggles", async () => {
108
+ const user = userEvent.setup()
109
+ render(<Tree items={FLAT_NAV} variant="navigation" />)
110
+
111
+ // A leaf row carries no aria-expanded; a parent row does.
112
+ const search = screen.getByRole("button", { name: "Search" })
113
+ expect(search.getAttribute("aria-expanded")).toBeNull()
114
+ const parent = screen.getByRole("button", { name: "Library" })
115
+ expect(parent.getAttribute("aria-expanded")).toBe("false")
116
+
117
+ await user.click(parent)
118
+ expect(
119
+ await screen.findByRole("button", { name: "Manuscripts" })
120
+ ).toBeDefined()
121
+ })
122
+
123
+ test("read-only variants render no drag or rename affordances", () => {
124
+ render(<Tree items={SECTIONED} variant="navigation" />)
125
+ const rows = document.querySelectorAll('[data-slot="tree-row"]')
126
+ expect(rows.length).toBeGreaterThan(0)
127
+ for (const row of rows) expect(row.getAttribute("draggable")).toBeNull()
128
+ })
129
+
130
+ test("activeId marks the row current and expands collapsed ancestors", async () => {
131
+ const { rerender } = render(
132
+ <Tree items={SECTIONED} variant="navigation" />
133
+ )
134
+ // Library starts collapsed — its children are absent.
135
+ expect(screen.queryByRole("button", { name: "Codices" })).toBeNull()
136
+
137
+ rerender(<Tree activeId="codices" items={SECTIONED} variant="navigation" />)
138
+ const active = await screen.findByRole("button", { name: "Codices" })
139
+ expect(active.getAttribute("aria-current")).toBe("page")
140
+ expect(active.hasAttribute("data-active")).toBe(true)
141
+ })
142
+
143
+ test("onNavigate fires for leaf rows after the node's own onSelect", async () => {
144
+ const user = userEvent.setup()
145
+ const order: string[] = []
146
+ const items: TreeNode[] = [
147
+ {
148
+ id: "a",
149
+ label: "Alpha",
150
+ href: "/a",
151
+ onSelect: () => order.push("onSelect"),
152
+ },
153
+ ]
154
+ render(
155
+ <Tree
156
+ items={items}
157
+ onNavigate={() => order.push("onNavigate")}
158
+ variant="navigation"
159
+ />
160
+ )
161
+ await user.click(screen.getByRole("button", { name: "Alpha" }))
162
+ expect(order).toEqual(["onSelect", "onNavigate"])
163
+ })
164
+
165
+ test("href is passed through to onNavigate, never rendered as an anchor", async () => {
166
+ const user = userEvent.setup()
167
+ const onNavigate = mock((_node: TreeNode) => {})
168
+ render(<Tree items={FLAT_NAV} onNavigate={onNavigate} variant="navigation" />)
169
+
170
+ const search = screen.getByRole("button", { name: "Search" })
171
+ expect(search.tagName).toBe("BUTTON")
172
+ expect(search.getAttribute("href")).toBeNull()
173
+
174
+ await user.click(search)
175
+ expect(onNavigate).toHaveBeenCalledTimes(1)
176
+ expect(onNavigate.mock.calls[0]?.[0]?.href).toBe("/search")
177
+ })
178
+
179
+ test("sound=false strips the cuelume attributes from every row", () => {
180
+ render(<Tree items={FLAT_NAV} sound={false} variant="navigation" />)
181
+ for (const row of document.querySelectorAll('[data-slot="tree-row"]')) {
182
+ expect(row.hasAttribute("data-cuelume-press")).toBe(false)
183
+ expect(row.hasAttribute("data-cuelume-release")).toBe(false)
184
+ }
185
+ })
186
+ })
187
+
188
+ describe("Tree · toc", () => {
189
+ test("parents select on the row and expand from the overlay toggle", async () => {
190
+ const user = userEvent.setup()
191
+ const onNavigate = mock((_node: TreeNode) => {})
192
+ render(<Tree items={TOC} onNavigate={onNavigate} variant="toc" />)
193
+
194
+ // Guide is a parent, but toc parents are selectable rows — the row
195
+ // itself carries no aria-expanded; the overlay chevron does.
196
+ const guide = screen.getByRole("button", { name: "Guide" })
197
+ expect(guide.getAttribute("aria-expanded")).toBeNull()
198
+ expect(guide.tagName).toBe("BUTTON")
199
+ const toggle = screen.getByRole("button", { name: "Expand Guide" })
200
+ expect(toggle.getAttribute("aria-expanded")).toBe("false")
201
+
202
+ // Pressing the row selects (and reports the node with its href) without
203
+ // expanding it.
204
+ await user.click(guide)
205
+ expect(onNavigate).toHaveBeenCalledTimes(1)
206
+ expect(onNavigate.mock.calls[0]?.[0]?.href).toBe("/guide")
207
+ expect(screen.queryByRole("button", { name: "Setup" })).toBeNull()
208
+
209
+ // The overlay toggle expands without selecting.
210
+ await user.click(toggle)
211
+ expect(await screen.findByRole("button", { name: "Setup" })).toBeDefined()
212
+ expect(onNavigate).toHaveBeenCalledTimes(1)
213
+ expect(toggle.getAttribute("aria-expanded")).toBe("true")
214
+
215
+ // Nested parents get their own overlay toggle; leaves get none.
216
+ await user.click(screen.getByRole("button", { name: "Expand Setup" }))
217
+ expect(await screen.findByRole("button", { name: "Install" })).toBeDefined()
218
+ expect(screen.queryByRole("button", { name: "Expand Install" })).toBeNull()
219
+
220
+ // Nothing in a toc renders as an anchor any more.
221
+ expect(document.querySelector("a")).toBeNull()
222
+ })
223
+
224
+ test("rows below the route level keep the native #{id} jump after select", async () => {
225
+ const user = userEvent.setup()
226
+ const order: string[] = []
227
+ window.location.hash = ""
228
+ render(
229
+ <Tree
230
+ items={TOC}
231
+ onNavigate={(node) => order.push(`navigate:${node.id}:${window.location.hash}`)}
232
+ variant="toc"
233
+ />
234
+ )
235
+
236
+ // Depth 0 is a route (href "/guide"): no hash is written — routing is
237
+ // the consumer's job in onNavigate.
238
+ await user.click(screen.getByRole("button", { name: "Guide" }))
239
+ expect(window.location.hash).toBe("")
240
+
241
+ await user.click(screen.getByRole("button", { name: "Expand Guide" }))
242
+ await user.click(await screen.findByRole("button", { name: "Setup" }))
243
+ // onNavigate ran first (hash still empty inside it), then the jump —
244
+ // the order the old anchor default gave.
245
+ expect(order).toEqual(["navigate:guide:", "navigate:setup:"])
246
+ expect(window.location.hash).toBe("#setup")
247
+
248
+ await user.click(screen.getByRole("button", { name: "Expand Setup" }))
249
+ await user.click(await screen.findByRole("button", { name: "Install" }))
250
+ expect(window.location.hash).toBe("#install")
251
+
252
+ // An explicit hash href wins over the id.
253
+ window.location.hash = ""
254
+ render(
255
+ <Tree
256
+ items={[
257
+ {
258
+ id: "top",
259
+ label: "Top",
260
+ children: [{ id: "leaf", label: "Leaf", href: "#custom" }],
261
+ },
262
+ ]}
263
+ variant="toc"
264
+ />
265
+ )
266
+ await user.click(screen.getByRole("button", { name: "Expand Top" }))
267
+ await user.click(await screen.findByRole("button", { name: "Leaf" }))
268
+ expect(window.location.hash).toBe("#custom")
269
+ window.location.hash = ""
270
+ })
271
+ })
272
+
273
+ describe("Tree · sidebar", () => {
274
+ test("the rail's width class tracks collapsed in both directions", async () => {
275
+ // Locks the resting widths to the class, so the rail is right even when
276
+ // the animation never applies. NB this does NOT reproduce the bug it was
277
+ // written alongside — that was an inline width pinned at 44px by a
278
+ // px-against-"100%" motion target, and motion sets no inline styles
279
+ // under happy-dom. Only a real browser catches that one.
280
+ const { rerender } = render(<Tree items={RAIL} variant="sidebar" />)
281
+ const rail = () => document.querySelector('[data-slot="tree"]')
282
+
283
+ expect(rail()?.className).toContain("w-full")
284
+ expect(rail()?.getAttribute("data-collapsed")).toBeNull()
285
+
286
+ rerender(<Tree collapsed items={RAIL} variant="sidebar" />)
287
+ await settleExit()
288
+ expect(rail()?.className).toContain("w-10")
289
+ expect(rail()?.getAttribute("data-collapsed")).toBe("")
290
+ // Collapsed rows are square-ish tiles that fill the 40px rail.
291
+ const row = screen.getByRole("button", { name: "Search" })
292
+ expect(row.className).toContain("h-10!")
293
+ expect(row.className).toContain("rounded-xl!")
294
+ expect(row.className).toContain("justify-center")
295
+
296
+ // Back open — the direction that was broken.
297
+ rerender(<Tree items={RAIL} variant="sidebar" />)
298
+ await settleExit()
299
+ expect(rail()?.className).toContain("w-full")
300
+ expect(rail()?.className).not.toContain("w-10")
301
+ expect(rail()?.getAttribute("data-collapsed")).toBeNull()
302
+ expect(screen.getByRole("button", { name: "Search" }).className).not.toContain(
303
+ "h-10!"
304
+ )
305
+ })
306
+
307
+ test("the collapsed px target matches the resting width class", () => {
308
+ // tree.tsx animates the rail to RAIL_COLLAPSED_WIDTH once measured, and
309
+ // falls back to the class before that. Tailwind's w-10 is 2.5rem = 40px;
310
+ // the two must agree or the rail jumps on its first collapse.
311
+ expect(RAIL_COLLAPSED_WIDTH).toBe(40)
312
+ })
313
+
314
+ test("collapsed shows icon-only rows that keep an accessible name", async () => {
315
+ const { rerender } = render(<Tree items={RAIL} variant="sidebar" />)
316
+ expect(screen.getByRole("button", { name: "Search" }).textContent).toContain(
317
+ "Search"
318
+ )
319
+
320
+ rerender(<Tree collapsed items={RAIL} variant="sidebar" />)
321
+ await settleExit()
322
+ const row = screen.getByRole("button", { name: "Search" })
323
+ // The label folds to nothing rather than unmounting — keeping it in the
324
+ // DOM is what lets the fold animate — so assert the fold, not absence.
325
+ const label = row.querySelector('[data-slot="tree-row-label"]')
326
+ expect(label?.textContent).toBe("Search")
327
+ expect(label?.getAttribute("style")).toContain("display: none")
328
+ // With the label hidden, aria-label is what names the row.
329
+ expect(row.getAttribute("aria-label")).toBe("Search")
330
+ })
331
+
332
+ test("rail rows are tooltip triggers and keep identity across collapse", async () => {
333
+ // happy-dom can't drive Base UI's hover/focus-visible open logic, so
334
+ // assert the wiring: Base UI stamps its trigger attribute on the row.
335
+ const { rerender } = render(<Tree collapsed items={RAIL} variant="sidebar" />)
336
+ const collapsedRow = screen.getByRole("button", { name: "Search" })
337
+ expect(collapsedRow.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true)
338
+
339
+ // The tooltip wrapper stays mounted when the rail expands (it is only
340
+ // disabled) — a remounted row would cut the label fold animation short.
341
+ // Let the label unfold before querying: mid-animation it is still
342
+ // display:none, and the row has no aria-label once expanded, so it has
343
+ // no accessible name to match on until the fold-out finishes.
344
+ // NB this asserts identity, not the fold-out itself — motion settles
345
+ // instantly under happy-dom, while in a real browser the rail currently
346
+ // does not reanimate back open. That defect is tracked separately; do
347
+ // not read this test as covering it.
348
+ rerender(<Tree items={RAIL} variant="sidebar" />)
349
+ await settleExit()
350
+ expect(screen.getByRole("button", { name: "Search" })).toBe(collapsedRow)
351
+ })
352
+
353
+ test("the active rail row swaps to the secondary Button variant", () => {
354
+ render(<Tree activeId="search" items={RAIL} variant="sidebar" />)
355
+ const active = screen.getByRole("button", { name: "Search" })
356
+ const idle = screen.getByRole("button", { name: "Notes" })
357
+ expect(active.getAttribute("aria-current")).toBe("page")
358
+ // Button's cva emits bg-secondary for "secondary" and nothing of the
359
+ // sort for "ghost" — the cheapest observable proxy for the variant.
360
+ expect(active.className).toContain("bg-secondary")
361
+ expect(idle.className).not.toContain("bg-secondary")
362
+ })
363
+
364
+ test("nested children are ignored — the rail is single-level", () => {
365
+ const items: TreeNode[] = [
366
+ {
367
+ id: "top",
368
+ label: "Top",
369
+ icon: <SearchIcon />,
370
+ children: [{ id: "nested", label: "Nested" }],
371
+ },
372
+ ]
373
+ render(<Tree items={items} variant="sidebar" />)
374
+ expect(screen.queryByText("Nested")).toBeNull()
375
+ // No chevron either — the row is a plain button.
376
+ expect(
377
+ screen.getByRole("button", { name: "Top" }).getAttribute("aria-expanded")
378
+ ).toBeNull()
379
+ })
380
+ })
381
+
382
+ describe("Tree · files", () => {
383
+ test("folders toggle on row press; only leaves navigate", async () => {
384
+ const user = userEvent.setup()
385
+ const onNavigate = mock(() => {})
386
+ render(<Tree items={FILES} onNavigate={onNavigate} variant="files" />)
387
+
388
+ await user.click(screen.getByText("index.ts"))
389
+ expect(onNavigate).toHaveBeenCalledTimes(1)
390
+
391
+ const folder = screen.getByRole("button", { name: /^src/ })
392
+ await user.click(folder)
393
+ expect(onNavigate).toHaveBeenCalledTimes(1)
394
+ await settleExit()
395
+ expect(screen.queryByText("index.ts")).toBeNull()
396
+ })
397
+
398
+ test("trailing actions sit beside the row, not inside it", async () => {
399
+ const user = userEvent.setup()
400
+ const onDelete = mock(() => {})
401
+ render(
402
+ <Tree
403
+ items={FILES}
404
+ renderTrailing={(node) => (
405
+ <button onClick={onDelete} type="button">
406
+ {`Delete ${node.label}`}
407
+ </button>
408
+ )}
409
+ variant="files"
410
+ />
411
+ )
412
+
413
+ // Every row is a <button> — one nesting the trailing button would be
414
+ // invalid markup React warns about.
415
+ for (const row of document.querySelectorAll('[data-slot="tree-row"]')) {
416
+ expect(row.querySelector("button, a")).toBeNull()
417
+ }
418
+
419
+ // Still wired: pressing it does not also toggle or navigate the row.
420
+ const folder = screen.getByRole("button", { name: /^src/ })
421
+ await user.click(screen.getByRole("button", { name: "Delete src" }))
422
+ expect(onDelete).toHaveBeenCalledTimes(1)
423
+ expect(folder.getAttribute("aria-expanded")).toBe("true")
424
+ })
425
+
426
+ test("double-click renames inline; Enter commits, Escape cancels", async () => {
427
+ const user = userEvent.setup()
428
+ const onRename = mock(() => {})
429
+ render(<Tree items={FILES} onRename={onRename} variant="files" />)
430
+
431
+ await user.dblClick(screen.getByText("README.md"))
432
+ const input = document.querySelector<HTMLInputElement>(
433
+ '[data-slot="tree-rename-input"]'
434
+ )
435
+ expect(input).not.toBeNull()
436
+ await user.clear(input as HTMLInputElement)
437
+ await user.type(input as HTMLInputElement, "NOTES.md{Enter}")
438
+ expect(onRename).toHaveBeenCalledWith("readme", "NOTES.md")
439
+
440
+ // Escape leaves the label untouched.
441
+ await user.dblClick(screen.getByText("README.md"))
442
+ await user.keyboard("{Escape}")
443
+ expect(onRename).toHaveBeenCalledTimes(1)
444
+ })
445
+
446
+ test("drag reorders around a row and drops into a folder", () => {
447
+ const onMove = mock(() => {})
448
+ render(<Tree items={FILES} onMove={onMove} variant="files" />)
449
+ const rows = Array.from(
450
+ document.querySelectorAll<HTMLElement>('[data-slot="tree-row"]')
451
+ )
452
+ const src = rows.find((row) => row.dataset.id === "src") as HTMLElement
453
+ const readme = rows.find(
454
+ (row) => row.dataset.id === "readme"
455
+ ) as HTMLElement
456
+ expect(src.getAttribute("draggable")).toBe("true")
457
+
458
+ // happy-dom rects are zero-height, so the hook falls back to the
459
+ // 0.5 midpoint → "inside" for folders, "after" for leaves.
460
+ fireEvent.dragStart(readme)
461
+ fireEvent.dragOver(src)
462
+ fireEvent.drop(src)
463
+ expect(onMove).toHaveBeenCalledWith("readme", "src", 2)
464
+
465
+ // A folder never drops into its own subtree.
466
+ onMove.mockClear()
467
+ fireEvent.dragStart(src)
468
+ const indexRow = rows.find(
469
+ (row) => row.dataset.id === "index-ts"
470
+ ) as HTMLElement
471
+ fireEvent.dragOver(indexRow)
472
+ fireEvent.drop(indexRow)
473
+ expect(onMove).not.toHaveBeenCalled()
474
+ })
475
+
476
+ test("moveNode reducer lifts and reinserts immutably", () => {
477
+ const next = moveNode(FILES, "readme", "src", 2)
478
+ const src = next.find((node) => node.id === "src")
479
+ expect(src?.children?.map((child) => child.id)).toEqual([
480
+ "index-ts",
481
+ "utils-ts",
482
+ "readme",
483
+ ])
484
+ // Refuses a drop into the moved node's own subtree.
485
+ expect(moveNode(FILES, "src", "index-ts", 0)).toEqual(FILES)
486
+ // The input is untouched.
487
+ expect(FILES.find((node) => node.id === "src")?.children?.length).toBe(2)
488
+ })
489
+ })
490
+
491
+ describe("Tree · keyboard", () => {
492
+ test("arrows walk visible rows; right expands, left collapses", async () => {
493
+ const user = userEvent.setup()
494
+ render(<Tree items={FLAT_NAV} variant="navigation" />)
495
+
496
+ const search = screen.getByRole("button", { name: "Search" })
497
+ search.focus()
498
+ await user.keyboard("{ArrowDown}")
499
+ const library = screen.getByRole("button", { name: "Library" })
500
+ expect(document.activeElement).toBe(library)
501
+
502
+ await user.keyboard("{ArrowRight}")
503
+ expect(
504
+ await screen.findByRole("button", { name: "Manuscripts" })
505
+ ).toBeDefined()
506
+ await waitFor(() =>
507
+ expect(library.getAttribute("aria-expanded")).toBe("true")
508
+ )
509
+
510
+ await user.keyboard("{ArrowLeft}")
511
+ await settleExit()
512
+ expect(screen.queryByRole("button", { name: "Manuscripts" })).toBeNull()
513
+ expect(document.activeElement).toBe(library)
514
+ })
515
+
516
+ test("F2 starts a rename on the focused files row", async () => {
517
+ const user = userEvent.setup()
518
+ render(<Tree items={FILES} onRename={() => {}} variant="files" />)
519
+ screen.getByText("README.md").closest("button")?.focus()
520
+ await user.keyboard("{F2}")
521
+ expect(
522
+ document.querySelector('[data-slot="tree-rename-input"]')
523
+ ).not.toBeNull()
524
+ })
525
+ })