@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.
- package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +83 -1
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +22 -31
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +5 -0
- package/dist-lib/components/blocks/scaffold/constants.d.ts +8 -0
- package/dist-lib/components/blocks/scaffold/index.d.ts +3 -1
- package/dist-lib/components/blocks/scaffold/panel-menu-button.d.ts +4 -3
- package/dist-lib/components/blocks/scaffold/scaffold-actions.d.ts +5 -4
- package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
- package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
- package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +14 -0
- package/dist-lib/components/blocks/scaffold/type.d.ts +58 -28
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
- package/dist-lib/components/blocks/scaffold/utils.d.ts +12 -0
- package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +35 -0
- package/dist-lib/components/composed/tree/index.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
- package/dist-lib/components/composed/tree/tree.d.ts +19 -0
- package/dist-lib/components/composed/tree/type.d.ts +192 -0
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
- package/dist-lib/components/composed/tree/utils.d.ts +28 -0
- package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
- package/dist-lib/components/user-avatar/component.d.ts +10 -0
- package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
- package/dist-lib/components/user-avatar/index.d.ts +13 -0
- package/dist-lib/components/user-avatar/type.d.ts +26 -0
- package/dist-lib/components/user-avatar/utils.d.ts +2 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.js +2309 -1285
- package/dist-lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/beste/piece/browser-frame.tsx +9 -6
- package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
- package/src/components/blocks/nav/sidebar/type.ts +105 -1
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
- package/src/components/blocks/nav/sidebar/utils.ts +28 -0
- package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +268 -26
- package/src/components/blocks/scaffold/constants.ts +11 -0
- package/src/components/blocks/scaffold/index.ts +4 -1
- package/src/components/blocks/scaffold/panel-menu-button.tsx +96 -115
- package/src/components/blocks/scaffold/scaffold-actions.tsx +41 -50
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +27 -3
- package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-panel.tsx +56 -30
- package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
- package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-tab.tsx +104 -0
- package/src/components/blocks/scaffold/type.ts +62 -33
- package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
- package/src/components/blocks/scaffold/utils.ts +35 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
- package/src/components/composed/tree/constants.ts +60 -0
- package/src/components/composed/tree/index.ts +13 -0
- package/src/components/composed/tree/tree-context.ts +13 -0
- package/src/components/composed/tree/tree-node.tsx +451 -0
- package/src/components/composed/tree/tree.tsx +278 -0
- package/src/components/composed/tree/type.ts +212 -0
- package/src/components/composed/tree/use-tree-dnd.ts +148 -0
- package/src/components/composed/tree/use-tree.ts +306 -0
- package/src/components/composed/tree/utils.ts +172 -0
- package/src/components/composed/user-avatar.tsx +4 -1
- package/src/components/docs/component-preview.tsx +11 -11
- package/src/components/user-avatar/audio-wave.tsx +22 -0
- package/src/components/user-avatar/component.tsx +53 -0
- package/src/components/user-avatar/fallback.tsx +26 -0
- package/src/components/user-avatar/index.ts +15 -0
- package/src/components/user-avatar/type.ts +35 -0
- package/src/components/user-avatar/utils.ts +8 -0
- package/src/index.css +211 -153
- 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
|
+
})
|