@exegia/corpora-ui 0.18.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 (78) 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 +8 -0
  7. package/dist-lib/components/blocks/scaffold/index.d.ts +3 -1
  8. package/dist-lib/components/blocks/scaffold/panel-menu-button.d.ts +4 -3
  9. package/dist-lib/components/blocks/scaffold/scaffold-actions.d.ts +5 -4
  10. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  11. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  12. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +14 -0
  13. package/dist-lib/components/blocks/scaffold/type.d.ts +58 -28
  14. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  15. package/dist-lib/components/blocks/scaffold/utils.d.ts +12 -0
  16. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  17. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  18. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  19. package/dist-lib/components/composed/tree/index.d.ts +4 -0
  20. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  21. package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
  22. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  23. package/dist-lib/components/composed/tree/type.d.ts +192 -0
  24. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
  25. package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
  26. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  27. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  28. package/dist-lib/components/user-avatar/component.d.ts +10 -0
  29. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  30. package/dist-lib/components/user-avatar/index.d.ts +13 -0
  31. package/dist-lib/components/user-avatar/type.d.ts +26 -0
  32. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  33. package/dist-lib/index.d.ts +1 -0
  34. package/dist-lib/index.js +2309 -1285
  35. package/dist-lib/index.js.map +1 -1
  36. package/package.json +2 -1
  37. package/src/components/beste/piece/browser-frame.tsx +9 -6
  38. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  39. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
  40. package/src/components/blocks/nav/sidebar/type.ts +105 -1
  41. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
  42. package/src/components/blocks/nav/sidebar/utils.ts +28 -0
  43. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +268 -26
  44. package/src/components/blocks/scaffold/constants.ts +11 -0
  45. package/src/components/blocks/scaffold/index.ts +4 -1
  46. package/src/components/blocks/scaffold/panel-menu-button.tsx +96 -115
  47. package/src/components/blocks/scaffold/scaffold-actions.tsx +41 -50
  48. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  49. package/src/components/blocks/scaffold/scaffold-inspector.tsx +27 -3
  50. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  51. package/src/components/blocks/scaffold/scaffold-panel.tsx +56 -30
  52. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  53. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  54. package/src/components/blocks/scaffold/scaffold-tab.tsx +104 -0
  55. package/src/components/blocks/scaffold/type.ts +62 -33
  56. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  57. package/src/components/blocks/scaffold/utils.ts +35 -0
  58. package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
  59. package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
  60. package/src/components/composed/tree/constants.ts +60 -0
  61. package/src/components/composed/tree/index.ts +13 -0
  62. package/src/components/composed/tree/tree-context.ts +13 -0
  63. package/src/components/composed/tree/tree-node.tsx +451 -0
  64. package/src/components/composed/tree/tree.tsx +278 -0
  65. package/src/components/composed/tree/type.ts +212 -0
  66. package/src/components/composed/tree/use-tree-dnd.ts +148 -0
  67. package/src/components/composed/tree/use-tree.ts +306 -0
  68. package/src/components/composed/tree/utils.ts +172 -0
  69. package/src/components/composed/user-avatar.tsx +4 -1
  70. package/src/components/docs/component-preview.tsx +11 -11
  71. package/src/components/user-avatar/audio-wave.tsx +22 -0
  72. package/src/components/user-avatar/component.tsx +53 -0
  73. package/src/components/user-avatar/fallback.tsx +26 -0
  74. package/src/components/user-avatar/index.ts +15 -0
  75. package/src/components/user-avatar/type.ts +35 -0
  76. package/src/components/user-avatar/utils.ts +8 -0
  77. package/src/index.css +211 -153
  78. package/src/index.ts +1 -0
@@ -1,5 +1,26 @@
1
+ import type { Variants } from "motion/react"
1
2
  import type { ClassNameValue } from "tailwind-merge"
2
3
  import type { TSubPanelVariant } from "@/components/blocks/scaffold/type.ts"
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
+ }
3
24
 
4
25
  /** Desktop backdrop the whole scaffold sits on — a soft warm-gray wash. */
5
26
  export const scaffoldBackgroundClass: ClassNameValue =
@@ -22,6 +43,20 @@ export const revealOnPanelHoverClass: ClassNameValue =
22
43
  export const actionSegmentClass: ClassNameValue =
23
44
  "flex h-[30px] items-center rounded-full text-neutral-700 transition-[background-color,color,scale,box-shadow] duration-150 ease-smooth-out hover:bg-white hover:text-neutral-900 hover:shadow-xs active:scale-97 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:hover:text-white motion-reduce:transition-none motion-reduce:active:scale-100"
24
45
 
46
+ /** Pill segments (seam-menu actions, tabs, Add) blur/fade in place while
47
+ * the track morphs around them. The exit is a fast tween, not the layout
48
+ * spring — the track stays overflow-visible, so popped-out segments must be
49
+ * gone before the shrinking pill leaves them behind as a ghost. */
50
+ export const segmentVariants: Variants = {
51
+ hidden: { opacity: 0, filter: "blur(4px)" },
52
+ visible: { opacity: 1, filter: "blur(0px)" },
53
+ exit: {
54
+ opacity: 0,
55
+ filter: "blur(4px)",
56
+ transition: { duration: 0.18, ease: EASE_OUT },
57
+ },
58
+ }
59
+
25
60
  /** Map of sub-panel variants to their corresponding side. */
26
61
  export const subPanelVariant: Record<TSubPanelVariant, ClassNameValue> = {
27
62
  card: "bg-neutral-50 dark:bg-neutral-900 rounded-md border-t border-b border-t-white dark:border-t-neutral-700/50 border-b-neutral-900/10 dark:border-b-black/70 shadow-sm shadow-neutral-900/10 dark:shadow-black/50 inset-shadow-sm inset-shadow-white dark:inset-shadow-neutral-800",
@@ -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
+ })