@exegia/corpora-ui 0.19.0 → 0.20.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 (73) hide show
  1. package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  3. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +83 -1
  4. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +22 -31
  5. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +5 -0
  6. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  7. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  8. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  9. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  10. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  11. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  12. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  13. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  14. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  15. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  16. package/dist-lib/components/composed/tree/index.d.ts +4 -0
  17. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  18. package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
  19. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  20. package/dist-lib/components/composed/tree/type.d.ts +192 -0
  21. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
  22. package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
  23. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  24. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  25. package/dist-lib/components/user-avatar/component.d.ts +10 -0
  26. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  27. package/dist-lib/components/user-avatar/index.d.ts +13 -0
  28. package/dist-lib/components/user-avatar/type.d.ts +26 -0
  29. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  30. package/dist-lib/index.d.ts +1 -0
  31. package/dist-lib/index.js +2141 -1139
  32. package/dist-lib/index.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/components/beste/piece/browser-frame.tsx +9 -6
  35. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  36. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
  37. package/src/components/blocks/nav/sidebar/type.ts +105 -1
  38. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
  39. package/src/components/blocks/nav/sidebar/utils.ts +28 -0
  40. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  41. package/src/components/blocks/scaffold/constants.ts +7 -0
  42. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  43. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  44. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  45. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  46. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  47. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  48. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  49. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  50. package/src/components/blocks/scaffold/type.ts +28 -0
  51. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  52. package/src/components/blocks/scaffold/utils.ts +19 -0
  53. package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
  54. package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
  55. package/src/components/composed/tree/constants.ts +60 -0
  56. package/src/components/composed/tree/index.ts +13 -0
  57. package/src/components/composed/tree/tree-context.ts +13 -0
  58. package/src/components/composed/tree/tree-node.tsx +451 -0
  59. package/src/components/composed/tree/tree.tsx +278 -0
  60. package/src/components/composed/tree/type.ts +212 -0
  61. package/src/components/composed/tree/use-tree-dnd.ts +148 -0
  62. package/src/components/composed/tree/use-tree.ts +306 -0
  63. package/src/components/composed/tree/utils.ts +172 -0
  64. package/src/components/composed/user-avatar.tsx +4 -1
  65. package/src/components/docs/component-preview.tsx +11 -11
  66. package/src/components/user-avatar/audio-wave.tsx +22 -0
  67. package/src/components/user-avatar/component.tsx +53 -0
  68. package/src/components/user-avatar/fallback.tsx +26 -0
  69. package/src/components/user-avatar/index.ts +15 -0
  70. package/src/components/user-avatar/type.ts +35 -0
  71. package/src/components/user-avatar/utils.ts +8 -0
  72. package/src/index.css +211 -153
  73. package/src/index.ts +1 -0
@@ -0,0 +1,180 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { SCAFFOLD_PANEL_CAPACITY } from "./constants"
6
+ import { getPanelCapacity } from "./utils"
7
+
8
+ /**
9
+ * Internal bookkeeping for responsive panel hiding.
10
+ *
11
+ * - `visibleOrder`: visible panel ids, least-recently-activated first — the
12
+ * front is what capacity pressure evicts next.
13
+ * - `autoHidden`: panels the scaffold hid because the canvas shrank, most
14
+ * recent last. They return on their own when room comes back.
15
+ * - `userHidden`: panels hidden by a tab press. They stay hidden until the
16
+ * tab is pressed again, however wide the canvas grows.
17
+ */
18
+ interface PanelVisibilityState {
19
+ visibleOrder: string[]
20
+ autoHidden: string[]
21
+ userHidden: string[]
22
+ }
23
+
24
+ const EMPTY_STATE: PanelVisibilityState = {
25
+ visibleOrder: [],
26
+ autoHidden: [],
27
+ userHidden: [],
28
+ }
29
+
30
+ /** Fold the registered ids and current capacity into a settled state:
31
+ * prune departed panels, admit new ones, evict the least-recently-activated
32
+ * visible panels past capacity, and restore auto-hidden ones when room
33
+ * returns. Returns `prev` untouched when nothing changes. */
34
+ function reconcile(
35
+ prev: PanelVisibilityState,
36
+ ids: readonly string[],
37
+ capacity: number
38
+ ): PanelVisibilityState {
39
+ const known = new Set(ids)
40
+ const visibleOrder = prev.visibleOrder.filter((id) => known.has(id))
41
+ const autoHidden = prev.autoHidden.filter((id) => known.has(id))
42
+ const userHidden = prev.userHidden.filter((id) => known.has(id))
43
+
44
+ const tracked = new Set([...visibleOrder, ...autoHidden, ...userHidden])
45
+ for (const id of ids) if (!tracked.has(id)) visibleOrder.push(id)
46
+
47
+ while (visibleOrder.length > capacity && visibleOrder.length > 1) {
48
+ autoHidden.push(visibleOrder.shift() as string)
49
+ }
50
+ while (visibleOrder.length < capacity && autoHidden.length > 0) {
51
+ visibleOrder.push(autoHidden.pop() as string)
52
+ }
53
+
54
+ const unchanged =
55
+ visibleOrder.join(" ") === prev.visibleOrder.join(" ") &&
56
+ autoHidden.join(" ") === prev.autoHidden.join(" ") &&
57
+ userHidden.join(" ") === prev.userHidden.join(" ")
58
+ return unchanged ? prev : { visibleOrder, autoHidden, userHidden }
59
+ }
60
+
61
+ export interface PanelVisibility {
62
+ /** How many panels currently fit side by side (1..capacity cap). */
63
+ panelCapacity: number
64
+ /** Ids of panels currently hidden (auto + user), for tabs to reflect. */
65
+ hiddenPanelIds: readonly string[]
66
+ isPanelHidden: (id: string) => boolean
67
+ togglePanelVisibility: (id: string) => void
68
+ /** Id of the panel whose tab is hovered — the canvas spotlights it. */
69
+ hoveredPanelId: string | null
70
+ /** @internal Tabs report pointer enter/leave on their label. */
71
+ setPanelHovered: (id: string, hovered: boolean) => void
72
+ /** @internal Canvas reports its ordered panel ids each render. */
73
+ registerPanelIds: (ids: readonly string[]) => void
74
+ /** @internal Canvas reports its measured width. */
75
+ setCanvasWidth: (width: number) => void
76
+ }
77
+
78
+ /** Owns which panels are hidden as the canvas gains and loses room.
79
+ * Lives in `Scaffold.Root`; Canvas and Tab reach it through context. */
80
+ export function usePanelVisibility(): PanelVisibility {
81
+ const [panelIds, setPanelIds] = React.useState<readonly string[]>([])
82
+ const [measuredCapacity, setMeasuredCapacity] = React.useState<number | null>(
83
+ null
84
+ )
85
+ const [state, setState] = React.useState<PanelVisibilityState>(EMPTY_STATE)
86
+ const [hoveredPanelId, setHoveredPanelId] = React.useState<string | null>(
87
+ null
88
+ )
89
+
90
+ const panelCapacity = measuredCapacity ?? SCAFFOLD_PANEL_CAPACITY
91
+
92
+ const registerPanelIds = React.useCallback((ids: readonly string[]) => {
93
+ setPanelIds((prev) =>
94
+ prev.length === ids.length && prev.every((id, i) => id === ids[i])
95
+ ? prev
96
+ : [...ids]
97
+ )
98
+ }, [])
99
+
100
+ // Store the derived capacity, not the raw width — resize storms only
101
+ // re-render the tree when a panel actually gains or loses room.
102
+ const setCanvasWidth = React.useCallback((width: number) => {
103
+ setMeasuredCapacity(getPanelCapacity(width))
104
+ }, [])
105
+
106
+ // Settle during render (the derive-state-while-rendering pattern): ids
107
+ // and capacity arrive as state, so an effect here would only add a
108
+ // cascading render after each change.
109
+ const settled = reconcile(state, panelIds, panelCapacity)
110
+ if (settled !== state) setState(settled)
111
+
112
+ const togglePanelVisibility = React.useCallback(
113
+ (id: string) => {
114
+ setState((prev) => {
115
+ if (!panelIds.includes(id)) return prev
116
+ const hidden =
117
+ prev.autoHidden.includes(id) || prev.userHidden.includes(id)
118
+ if (!hidden && prev.visibleOrder.length <= 1) return prev
119
+
120
+ const next: PanelVisibilityState = hidden
121
+ ? {
122
+ // Showing: newest activation — reconcile evicts the oldest
123
+ // visible panel if the canvas can't fit one more.
124
+ visibleOrder: [...prev.visibleOrder, id],
125
+ autoHidden: prev.autoHidden.filter((other) => other !== id),
126
+ userHidden: prev.userHidden.filter((other) => other !== id),
127
+ }
128
+ : {
129
+ visibleOrder: prev.visibleOrder.filter((other) => other !== id),
130
+ autoHidden: prev.autoHidden,
131
+ userHidden: [...prev.userHidden, id],
132
+ }
133
+ return reconcile(next, panelIds, panelCapacity)
134
+ })
135
+ },
136
+ [panelIds, panelCapacity]
137
+ )
138
+
139
+ // Leaving clears only its own id — a tab that unmounts mid-hover must
140
+ // not wipe out a hover the pointer has already moved on to.
141
+ const setPanelHovered = React.useCallback((id: string, hovered: boolean) => {
142
+ setHoveredPanelId((prev) => {
143
+ if (hovered) return id
144
+ return prev === id ? null : prev
145
+ })
146
+ }, [])
147
+
148
+ const hiddenPanelIds = React.useMemo(
149
+ () => [...settled.autoHidden, ...settled.userHidden],
150
+ [settled]
151
+ )
152
+
153
+ const isPanelHidden = React.useCallback(
154
+ (id: string) => hiddenPanelIds.includes(id),
155
+ [hiddenPanelIds]
156
+ )
157
+
158
+ return React.useMemo(
159
+ () => ({
160
+ panelCapacity,
161
+ hiddenPanelIds,
162
+ isPanelHidden,
163
+ togglePanelVisibility,
164
+ hoveredPanelId,
165
+ setPanelHovered,
166
+ registerPanelIds,
167
+ setCanvasWidth,
168
+ }),
169
+ [
170
+ panelCapacity,
171
+ hiddenPanelIds,
172
+ isPanelHidden,
173
+ togglePanelVisibility,
174
+ hoveredPanelId,
175
+ setPanelHovered,
176
+ registerPanelIds,
177
+ setCanvasWidth,
178
+ ]
179
+ )
180
+ }
@@ -2,6 +2,25 @@ import type { Variants } from "motion/react"
2
2
  import type { ClassNameValue } from "tailwind-merge"
3
3
  import type { TSubPanelVariant } from "@/components/blocks/scaffold/type.ts"
4
4
  import { EASE_OUT } from "@/lib/ease.ts"
5
+ import {
6
+ SCAFFOLD_CANVAS_GAP,
7
+ SCAFFOLD_PANEL_CAPACITY,
8
+ SCAFFOLD_PANEL_MIN_WIDTH,
9
+ } from "./constants"
10
+
11
+ /**
12
+ * How many panels fit on a canvas of `width` px, granting each
13
+ * `SCAFFOLD_PANEL_MIN_WIDTH` plus the gaps between them. Clamped to
14
+ * [1, SCAFFOLD_PANEL_CAPACITY]; an unmeasured canvas (`null`) fits the cap.
15
+ */
16
+ export function getPanelCapacity(width: number | null): number {
17
+ if (width === null) return SCAFFOLD_PANEL_CAPACITY
18
+ const fits = Math.floor(
19
+ (width + SCAFFOLD_CANVAS_GAP) /
20
+ (SCAFFOLD_PANEL_MIN_WIDTH + SCAFFOLD_CANVAS_GAP)
21
+ )
22
+ return Math.max(1, Math.min(SCAFFOLD_PANEL_CAPACITY, fits))
23
+ }
5
24
 
6
25
  /** Desktop backdrop the whole scaffold sits on — a soft warm-gray wash. */
7
26
  export const scaffoldBackgroundClass: ClassNameValue =
@@ -0,0 +1,400 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { 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 { Tree } from "../index"
7
+ import type { TreeNode } from "../type"
8
+ import { moveNode } from "../utils"
9
+
10
+ /** 3-level data — `navigation` promotes the top level to section names. */
11
+ const SECTIONED: TreeNode[] = [
12
+ {
13
+ id: "research",
14
+ label: "Research",
15
+ children: [
16
+ { id: "search", label: "Search", icon: <SearchIcon />, href: "/search" },
17
+ {
18
+ id: "library",
19
+ label: "Library",
20
+ children: [
21
+ { id: "manuscripts", label: "Manuscripts", href: "/m" },
22
+ { id: "codices", label: "Codices", href: "/c" },
23
+ ],
24
+ },
25
+ ],
26
+ },
27
+ {
28
+ id: "workspace",
29
+ label: "Workspace",
30
+ children: [{ id: "notes", label: "Notes", href: "/notes" }],
31
+ },
32
+ ]
33
+
34
+ /** 2-level data — the top level stays plain link rows. */
35
+ const FLAT_NAV: TreeNode[] = [
36
+ { id: "search", label: "Search", href: "/search" },
37
+ {
38
+ id: "library",
39
+ label: "Library",
40
+ children: [{ id: "manuscripts", label: "Manuscripts", href: "/m" }],
41
+ },
42
+ ]
43
+
44
+ const TOC: TreeNode[] = [
45
+ {
46
+ id: "guide",
47
+ label: "Guide",
48
+ href: "/guide",
49
+ children: [
50
+ {
51
+ id: "setup",
52
+ label: "Setup",
53
+ children: [{ id: "install", label: "Install" }],
54
+ },
55
+ ],
56
+ },
57
+ { id: "api", label: "API", href: "/api" },
58
+ ]
59
+
60
+ const RAIL: TreeNode[] = [
61
+ { id: "search", label: "Search", icon: <SearchIcon />, href: "/search" },
62
+ { id: "notes", label: "Notes", icon: <FileTextIcon />, href: "/notes" },
63
+ ]
64
+
65
+ const FILES: TreeNode[] = [
66
+ {
67
+ id: "src",
68
+ label: "src",
69
+ icon: <FolderIcon />,
70
+ defaultOpen: true,
71
+ children: [
72
+ { id: "index-ts", label: "index.ts" },
73
+ { id: "utils-ts", label: "utils.ts" },
74
+ ],
75
+ },
76
+ { id: "readme", label: "README.md" },
77
+ ]
78
+
79
+ /** Branch exits run through AnimatePresence — let them settle on real
80
+ * timers, then query once (happy-dom serves stale waitFor queries while
81
+ * an exit is in flight). */
82
+ const settleExit = () => new Promise((resolve) => setTimeout(resolve, 400))
83
+
84
+ describe("Tree · navigation", () => {
85
+ test("3-level data renders top level as collapsible sections, not links", async () => {
86
+ const user = userEvent.setup()
87
+ render(<Tree items={SECTIONED} variant="navigation" />)
88
+
89
+ const section = screen.getByRole("button", { name: "Research" })
90
+ expect(section.getAttribute("aria-expanded")).toBe("true")
91
+ // Sections are buttons — no anchor wraps them.
92
+ expect(section.closest("a")).toBeNull()
93
+ expect(screen.getByRole("link", { name: "Search" })).toBeDefined()
94
+
95
+ // Collapsing the section folds its items away.
96
+ await user.click(section)
97
+ await settleExit()
98
+ expect(screen.queryByRole("link", { name: "Search" })).toBeNull()
99
+ })
100
+
101
+ test("2-level data keeps top level as links and parents as toggles", async () => {
102
+ const user = userEvent.setup()
103
+ render(<Tree items={FLAT_NAV} variant="navigation" />)
104
+
105
+ expect(screen.getByRole("link", { name: "Search" })).toBeDefined()
106
+ const parent = screen.getByRole("button", { name: "Library" })
107
+ expect(parent.getAttribute("aria-expanded")).toBe("false")
108
+
109
+ await user.click(parent)
110
+ expect(await screen.findByRole("link", { name: "Manuscripts" })).toBeDefined()
111
+ })
112
+
113
+ test("read-only variants render no drag or rename affordances", () => {
114
+ render(<Tree items={SECTIONED} variant="navigation" />)
115
+ const rows = document.querySelectorAll('[data-slot="tree-row"]')
116
+ expect(rows.length).toBeGreaterThan(0)
117
+ for (const row of rows) expect(row.getAttribute("draggable")).toBeNull()
118
+ })
119
+
120
+ test("activeId marks the row current and expands collapsed ancestors", async () => {
121
+ const { rerender } = render(
122
+ <Tree items={SECTIONED} variant="navigation" />
123
+ )
124
+ // Library starts collapsed — its children are absent.
125
+ expect(screen.queryByRole("link", { name: "Codices" })).toBeNull()
126
+
127
+ rerender(<Tree activeId="codices" items={SECTIONED} variant="navigation" />)
128
+ const active = await screen.findByRole("link", { name: "Codices" })
129
+ expect(active.getAttribute("aria-current")).toBe("page")
130
+ })
131
+
132
+ test("onNavigate fires for link rows after the node's own onSelect", async () => {
133
+ const user = userEvent.setup()
134
+ const order: string[] = []
135
+ const items: TreeNode[] = [
136
+ {
137
+ id: "a",
138
+ label: "Alpha",
139
+ href: "/a",
140
+ onSelect: () => order.push("onSelect"),
141
+ },
142
+ ]
143
+ render(
144
+ <Tree
145
+ items={items}
146
+ onNavigate={() => order.push("onNavigate")}
147
+ variant="navigation"
148
+ />
149
+ )
150
+ await user.click(screen.getByRole("link", { name: "Alpha" }))
151
+ expect(order).toEqual(["onSelect", "onNavigate"])
152
+ })
153
+ })
154
+
155
+ describe("Tree · toc", () => {
156
+ test("leaves anchor to #{id}; explicit hrefs win; parents keep their route", async () => {
157
+ const user = userEvent.setup()
158
+ render(<Tree items={TOC} variant="toc" />)
159
+
160
+ const guide = screen.getByRole("link", { name: "Guide" })
161
+ expect(guide.getAttribute("href")).toBe("/guide")
162
+
163
+ // Guide navigates on the row; its expansion is the overlay toggle.
164
+ await user.click(screen.getByRole("button", { name: "Expand Guide" }))
165
+ // Below the route level every node is an in-page anchor, parents
166
+ // included — only depth 0 carries routes.
167
+ const setup = await screen.findByRole("link", { name: "Setup" })
168
+ expect(setup.getAttribute("href")).toBe("#setup")
169
+
170
+ await user.click(screen.getByRole("button", { name: "Expand Setup" }))
171
+ const install = await screen.findByRole("link", { name: "Install" })
172
+ expect(install.getAttribute("href")).toBe("#install")
173
+ })
174
+ })
175
+
176
+ describe("Tree · sidebar", () => {
177
+ test("the rail's width class tracks collapsed in both directions", async () => {
178
+ // Locks the resting widths to the class, so the rail is right even when
179
+ // the animation never applies. NB this does NOT reproduce the bug it was
180
+ // written alongside — that was an inline width pinned at 44px by a
181
+ // px-against-"100%" motion target, and motion sets no inline styles
182
+ // under happy-dom. Only a real browser catches that one.
183
+ const { rerender } = render(<Tree items={RAIL} variant="sidebar" />)
184
+ const rail = () => document.querySelector('[data-slot="tree"]')
185
+
186
+ expect(rail()?.className).toContain("w-full")
187
+ expect(rail()?.getAttribute("data-collapsed")).toBeNull()
188
+
189
+ rerender(<Tree collapsed items={RAIL} variant="sidebar" />)
190
+ await settleExit()
191
+ expect(rail()?.className).toContain("w-11")
192
+ expect(rail()?.getAttribute("data-collapsed")).toBe("")
193
+
194
+ // Back open — the direction that was broken.
195
+ rerender(<Tree items={RAIL} variant="sidebar" />)
196
+ await settleExit()
197
+ expect(rail()?.className).toContain("w-full")
198
+ expect(rail()?.className).not.toContain("w-11")
199
+ expect(rail()?.getAttribute("data-collapsed")).toBeNull()
200
+ })
201
+
202
+ test("collapsed shows icon-only rows that keep an accessible name", async () => {
203
+ const { rerender } = render(<Tree items={RAIL} variant="sidebar" />)
204
+ expect(screen.getByRole("link", { name: "Search" }).textContent).toContain(
205
+ "Search"
206
+ )
207
+
208
+ rerender(<Tree collapsed items={RAIL} variant="sidebar" />)
209
+ await settleExit()
210
+ const row = screen.getByRole("link", { name: "Search" })
211
+ // The label folds to nothing rather than unmounting — keeping it in the
212
+ // DOM is what lets the fold animate — so assert the fold, not absence.
213
+ const label = row.querySelector('[data-slot="tree-row-label"]')
214
+ expect(label?.textContent).toBe("Search")
215
+ expect(label?.getAttribute("style")).toContain("display: none")
216
+ // With the label hidden, aria-label is what names the row.
217
+ expect(row.getAttribute("aria-label")).toBe("Search")
218
+ })
219
+
220
+ test("rail rows are tooltip triggers and keep identity across collapse", async () => {
221
+ // happy-dom can't drive Base UI's hover/focus-visible open logic, so
222
+ // assert the wiring: Base UI stamps its trigger attribute on the row.
223
+ const { rerender } = render(<Tree collapsed items={RAIL} variant="sidebar" />)
224
+ const collapsedRow = screen.getByRole("link", { name: "Search" })
225
+ expect(collapsedRow.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true)
226
+
227
+ // The tooltip wrapper stays mounted when the rail expands (it is only
228
+ // disabled) — a remounted row would cut the label fold animation short.
229
+ // Let the label unfold before querying: mid-animation it is still
230
+ // display:none, and the row has no aria-label once expanded, so it has
231
+ // no accessible name to match on until the fold-out finishes.
232
+ // NB this asserts identity, not the fold-out itself — motion settles
233
+ // instantly under happy-dom, while in a real browser the rail currently
234
+ // does not reanimate back open. That defect is tracked separately; do
235
+ // not read this test as covering it.
236
+ rerender(<Tree items={RAIL} variant="sidebar" />)
237
+ await settleExit()
238
+ expect(screen.getByRole("link", { name: "Search" })).toBe(collapsedRow)
239
+ })
240
+
241
+ test("nested children are ignored — the rail is single-level", () => {
242
+ const items: TreeNode[] = [
243
+ {
244
+ id: "top",
245
+ label: "Top",
246
+ icon: <SearchIcon />,
247
+ children: [{ id: "nested", label: "Nested" }],
248
+ },
249
+ ]
250
+ render(<Tree items={items} variant="sidebar" />)
251
+ expect(screen.queryByText("Nested")).toBeNull()
252
+ // No chevron either — the row is a plain button.
253
+ expect(
254
+ screen.getByRole("button", { name: "Top" }).getAttribute("aria-expanded")
255
+ ).toBeNull()
256
+ })
257
+ })
258
+
259
+ describe("Tree · files", () => {
260
+ test("folders toggle on row press; only leaves navigate", async () => {
261
+ const user = userEvent.setup()
262
+ const onNavigate = mock(() => {})
263
+ render(<Tree items={FILES} onNavigate={onNavigate} variant="files" />)
264
+
265
+ await user.click(screen.getByText("index.ts"))
266
+ expect(onNavigate).toHaveBeenCalledTimes(1)
267
+
268
+ const folder = screen.getByRole("button", { name: /^src/ })
269
+ await user.click(folder)
270
+ expect(onNavigate).toHaveBeenCalledTimes(1)
271
+ await settleExit()
272
+ expect(screen.queryByText("index.ts")).toBeNull()
273
+ })
274
+
275
+ test("trailing actions sit beside the row, not inside it", async () => {
276
+ const user = userEvent.setup()
277
+ const onDelete = mock(() => {})
278
+ render(
279
+ <Tree
280
+ items={FILES}
281
+ renderTrailing={(node) => (
282
+ <button onClick={onDelete} type="button">
283
+ {`Delete ${node.label}`}
284
+ </button>
285
+ )}
286
+ variant="files"
287
+ />
288
+ )
289
+
290
+ // A folder row is a <button> and a leaf row an <a> — either one nesting
291
+ // the trailing button would be invalid markup React warns about.
292
+ for (const row of document.querySelectorAll('[data-slot="tree-row"]')) {
293
+ expect(row.querySelector("button, a")).toBeNull()
294
+ }
295
+
296
+ // Still wired: pressing it does not also toggle or navigate the row.
297
+ const folder = screen.getByRole("button", { name: /^src/ })
298
+ await user.click(screen.getByRole("button", { name: "Delete src" }))
299
+ expect(onDelete).toHaveBeenCalledTimes(1)
300
+ expect(folder.getAttribute("aria-expanded")).toBe("true")
301
+ })
302
+
303
+ test("double-click renames inline; Enter commits, Escape cancels", async () => {
304
+ const user = userEvent.setup()
305
+ const onRename = mock(() => {})
306
+ render(<Tree items={FILES} onRename={onRename} variant="files" />)
307
+
308
+ await user.dblClick(screen.getByText("README.md"))
309
+ const input = document.querySelector<HTMLInputElement>(
310
+ '[data-slot="tree-rename-input"]'
311
+ )
312
+ expect(input).not.toBeNull()
313
+ await user.clear(input as HTMLInputElement)
314
+ await user.type(input as HTMLInputElement, "NOTES.md{Enter}")
315
+ expect(onRename).toHaveBeenCalledWith("readme", "NOTES.md")
316
+
317
+ // Escape leaves the label untouched.
318
+ await user.dblClick(screen.getByText("README.md"))
319
+ await user.keyboard("{Escape}")
320
+ expect(onRename).toHaveBeenCalledTimes(1)
321
+ })
322
+
323
+ test("drag reorders around a row and drops into a folder", () => {
324
+ const onMove = mock(() => {})
325
+ render(<Tree items={FILES} onMove={onMove} variant="files" />)
326
+ const rows = Array.from(
327
+ document.querySelectorAll<HTMLElement>('[data-slot="tree-row"]')
328
+ )
329
+ const src = rows.find((row) => row.dataset.id === "src") as HTMLElement
330
+ const readme = rows.find(
331
+ (row) => row.dataset.id === "readme"
332
+ ) as HTMLElement
333
+ expect(src.getAttribute("draggable")).toBe("true")
334
+
335
+ // happy-dom rects are zero-height, so the hook falls back to the
336
+ // 0.5 midpoint → "inside" for folders, "after" for leaves.
337
+ fireEvent.dragStart(readme)
338
+ fireEvent.dragOver(src)
339
+ fireEvent.drop(src)
340
+ expect(onMove).toHaveBeenCalledWith("readme", "src", 2)
341
+
342
+ // A folder never drops into its own subtree.
343
+ onMove.mockClear()
344
+ fireEvent.dragStart(src)
345
+ const indexRow = rows.find(
346
+ (row) => row.dataset.id === "index-ts"
347
+ ) as HTMLElement
348
+ fireEvent.dragOver(indexRow)
349
+ fireEvent.drop(indexRow)
350
+ expect(onMove).not.toHaveBeenCalled()
351
+ })
352
+
353
+ test("moveNode reducer lifts and reinserts immutably", () => {
354
+ const next = moveNode(FILES, "readme", "src", 2)
355
+ const src = next.find((node) => node.id === "src")
356
+ expect(src?.children?.map((child) => child.id)).toEqual([
357
+ "index-ts",
358
+ "utils-ts",
359
+ "readme",
360
+ ])
361
+ // Refuses a drop into the moved node's own subtree.
362
+ expect(moveNode(FILES, "src", "index-ts", 0)).toEqual(FILES)
363
+ // The input is untouched.
364
+ expect(FILES.find((node) => node.id === "src")?.children?.length).toBe(2)
365
+ })
366
+ })
367
+
368
+ describe("Tree · keyboard", () => {
369
+ test("arrows walk visible rows; right expands, left collapses", async () => {
370
+ const user = userEvent.setup()
371
+ render(<Tree items={FLAT_NAV} variant="navigation" />)
372
+
373
+ const search = screen.getByRole("link", { name: "Search" })
374
+ search.focus()
375
+ await user.keyboard("{ArrowDown}")
376
+ const library = screen.getByRole("button", { name: "Library" })
377
+ expect(document.activeElement).toBe(library)
378
+
379
+ await user.keyboard("{ArrowRight}")
380
+ expect(await screen.findByRole("link", { name: "Manuscripts" })).toBeDefined()
381
+ await waitFor(() =>
382
+ expect(library.getAttribute("aria-expanded")).toBe("true")
383
+ )
384
+
385
+ await user.keyboard("{ArrowLeft}")
386
+ await settleExit()
387
+ expect(screen.queryByRole("link", { name: "Manuscripts" })).toBeNull()
388
+ expect(document.activeElement).toBe(library)
389
+ })
390
+
391
+ test("F2 starts a rename on the focused files row", async () => {
392
+ const user = userEvent.setup()
393
+ render(<Tree items={FILES} onRename={() => {}} variant="files" />)
394
+ screen.getByText("README.md").closest("button")?.focus()
395
+ await user.keyboard("{F2}")
396
+ expect(
397
+ document.querySelector('[data-slot="tree-rename-input"]')
398
+ ).not.toBeNull()
399
+ })
400
+ })