@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.
- 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 +5 -0
- 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 +6 -1
- package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
- package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -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 +2141 -1139
- 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 +160 -1
- package/src/components/blocks/scaffold/constants.ts +7 -0
- package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
- package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
- 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 +45 -7
- package/src/components/blocks/scaffold/type.ts +28 -0
- package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
- package/src/components/blocks/scaffold/utils.ts +19 -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
|
@@ -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
|
+
})
|