@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exegia/corpora-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"lucide-react": "^1.31.0",
|
|
51
51
|
"motion": "^12.43.0",
|
|
52
52
|
"motion-icons-react": "^1.1.0",
|
|
53
|
+
"tailwind-animations": "^1.0.2",
|
|
53
54
|
"tailwind-merge": "^3.6.0"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
@@ -12,9 +12,6 @@ export function BrowserFrame({
|
|
|
12
12
|
titleStyle,
|
|
13
13
|
children,
|
|
14
14
|
}: IBrowserProps) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
15
|
return (
|
|
19
16
|
<div
|
|
20
17
|
className={cn(
|
|
@@ -22,8 +19,14 @@ export function BrowserFrame({
|
|
|
22
19
|
className
|
|
23
20
|
)}
|
|
24
21
|
>
|
|
25
|
-
<div className="flex min-h-32 w-full
|
|
26
|
-
<div
|
|
22
|
+
<div className="relative flex max-h-[500px] min-h-32 w-full flex-col overflow-hidden rounded-lg border border-border bg-neutral-50 shadow-lg dark:bg-neutral-900">
|
|
23
|
+
<div
|
|
24
|
+
id="title-bar"
|
|
25
|
+
className={cn(
|
|
26
|
+
"z-20 flex flex-1 items-center gap-2 px-4 py-3",
|
|
27
|
+
titleBarStyle(titleStyle, "title")
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
27
30
|
<div className="flex items-center gap-2" aria-hidden="true">
|
|
28
31
|
<span className="size-3 cursor-pointer rounded-full bg-rose-500 shadow-md outline outline-rose-800" />
|
|
29
32
|
<span className="size-3 cursor-pointer rounded-full bg-amber-500 shadow-md outline outline-amber-800" />
|
|
@@ -42,7 +45,7 @@ export function BrowserFrame({
|
|
|
42
45
|
)}
|
|
43
46
|
{title && !url && (
|
|
44
47
|
<div className="flex w-full flex-1 justify-center select-none">
|
|
45
|
-
<span className="text-sm text-foreground
|
|
48
|
+
<span className="ml-2 text-sm text-foreground">{title}</span>
|
|
46
49
|
</div>
|
|
47
50
|
)}
|
|
48
51
|
</div>
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test"
|
|
2
|
+
import { act, render, renderHook, screen } from "@testing-library/react"
|
|
3
|
+
|
|
4
|
+
import { AISidebar } from "../ai-sidebar"
|
|
5
|
+
import type { AISidebarController, SidebarResource } from "../type.ts"
|
|
6
|
+
import { useAISidebar } from "../use-ai-sidebar"
|
|
7
|
+
|
|
8
|
+
const RESOURCES: SidebarResource[] = [
|
|
9
|
+
{
|
|
10
|
+
id: "corpora",
|
|
11
|
+
label: "Corpora",
|
|
12
|
+
kind: "project",
|
|
13
|
+
children: [
|
|
14
|
+
{
|
|
15
|
+
id: "manuscripts",
|
|
16
|
+
label: "Manuscripts",
|
|
17
|
+
kind: "folder",
|
|
18
|
+
children: [{ id: "codex-a", label: "Codex Askewianus", kind: "file" }],
|
|
19
|
+
},
|
|
20
|
+
{ id: "readme", label: "Field notes", kind: "file" },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{ id: "reading-list", label: "Reading list", kind: "bookmark" },
|
|
24
|
+
{ id: "archive", label: "Archive", kind: "folder", disabled: true },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const labels = (controller: AISidebarController) =>
|
|
28
|
+
controller.flat.map((row) => row.item.id)
|
|
29
|
+
|
|
30
|
+
describe("useAISidebar · expansion", () => {
|
|
31
|
+
test("expand, collapse and toggle change what flattens", () => {
|
|
32
|
+
const { result } = renderHook(() =>
|
|
33
|
+
useAISidebar({ defaultItems: RESOURCES })
|
|
34
|
+
)
|
|
35
|
+
expect(labels(result.current)).toEqual(["corpora", "reading-list", "archive"])
|
|
36
|
+
|
|
37
|
+
act(() => result.current.expand("corpora"))
|
|
38
|
+
expect(result.current.isExpanded("corpora")).toBe(true)
|
|
39
|
+
expect(labels(result.current)).toContain("manuscripts")
|
|
40
|
+
|
|
41
|
+
// Expanding twice is a no-op rather than a toggle.
|
|
42
|
+
act(() => result.current.expand("corpora"))
|
|
43
|
+
expect(result.current.isExpanded("corpora")).toBe(true)
|
|
44
|
+
|
|
45
|
+
act(() => result.current.toggleExpanded("corpora"))
|
|
46
|
+
expect(result.current.isExpanded("corpora")).toBe(false)
|
|
47
|
+
act(() => result.current.collapse("corpora"))
|
|
48
|
+
expect(result.current.isExpanded("corpora")).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("expandAll opens every container that has children", () => {
|
|
52
|
+
const { result } = renderHook(() =>
|
|
53
|
+
useAISidebar({ defaultItems: RESOURCES })
|
|
54
|
+
)
|
|
55
|
+
act(() => result.current.expandAll())
|
|
56
|
+
expect(labels(result.current)).toEqual([
|
|
57
|
+
"corpora",
|
|
58
|
+
"manuscripts",
|
|
59
|
+
"codex-a",
|
|
60
|
+
"readme",
|
|
61
|
+
"reading-list",
|
|
62
|
+
"archive",
|
|
63
|
+
])
|
|
64
|
+
// A childless folder is not an expandable row.
|
|
65
|
+
expect(result.current.isExpanded("archive")).toBe(false)
|
|
66
|
+
|
|
67
|
+
act(() => result.current.collapseAll())
|
|
68
|
+
expect([...result.current.expandedIds]).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("reveal opens ancestors only", () => {
|
|
72
|
+
const { result } = renderHook(() =>
|
|
73
|
+
useAISidebar({ defaultItems: RESOURCES })
|
|
74
|
+
)
|
|
75
|
+
act(() => result.current.reveal("codex-a"))
|
|
76
|
+
expect(result.current.isExpanded("corpora")).toBe(true)
|
|
77
|
+
expect(result.current.isExpanded("manuscripts")).toBe(true)
|
|
78
|
+
expect(result.current.isExpanded("codex-a")).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("controlled expandedIds win and changes are only reported", () => {
|
|
82
|
+
const onExpandedChange = mock((ids: string[]) => void ids)
|
|
83
|
+
const { result } = renderHook(() =>
|
|
84
|
+
useAISidebar({
|
|
85
|
+
defaultItems: RESOURCES,
|
|
86
|
+
expandedIds: ["corpora"],
|
|
87
|
+
onExpandedChange,
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
act(() => result.current.collapse("corpora"))
|
|
91
|
+
expect(result.current.isExpanded("corpora")).toBe(true)
|
|
92
|
+
expect(onExpandedChange).toHaveBeenCalledWith([])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe("useAISidebar · selection and focus", () => {
|
|
97
|
+
test("select holds the id when uncontrolled and reports either way", () => {
|
|
98
|
+
const onActiveChange = mock((id: string) => void id)
|
|
99
|
+
const { result } = renderHook(() =>
|
|
100
|
+
useAISidebar({ defaultItems: RESOURCES, onActiveChange })
|
|
101
|
+
)
|
|
102
|
+
act(() => result.current.select("reading-list"))
|
|
103
|
+
expect(result.current.selectedId).toBe("reading-list")
|
|
104
|
+
expect(onActiveChange).toHaveBeenCalledWith("reading-list")
|
|
105
|
+
|
|
106
|
+
const controlled = renderHook(() =>
|
|
107
|
+
useAISidebar({
|
|
108
|
+
defaultItems: RESOURCES,
|
|
109
|
+
activeId: "corpora",
|
|
110
|
+
onActiveChange,
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
act(() => controlled.result.current.select("reading-list"))
|
|
114
|
+
expect(controlled.result.current.selectedId).toBe("corpora")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("focus moves the roving target, and it follows rows that fold away", () => {
|
|
118
|
+
const { result } = renderHook(() =>
|
|
119
|
+
useAISidebar({ defaultItems: RESOURCES, defaultExpandedIds: ["corpora"] })
|
|
120
|
+
)
|
|
121
|
+
act(() => result.current.focus("readme"))
|
|
122
|
+
expect(result.current.focusedId).toBe("readme")
|
|
123
|
+
|
|
124
|
+
// Collapsing the parent hides the focused row — focus falls back to the
|
|
125
|
+
// first visible row rather than pointing at nothing.
|
|
126
|
+
act(() => result.current.collapse("corpora"))
|
|
127
|
+
expect(result.current.focusedId).toBe("corpora")
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("useAISidebar · rename", () => {
|
|
132
|
+
test("renames hook-owned data and leaves rename mode", () => {
|
|
133
|
+
const { result } = renderHook(() =>
|
|
134
|
+
useAISidebar({ defaultItems: RESOURCES })
|
|
135
|
+
)
|
|
136
|
+
act(() => result.current.startRename("reading-list"))
|
|
137
|
+
expect(result.current.renamingId).toBe("reading-list")
|
|
138
|
+
|
|
139
|
+
act(() => result.current.rename("reading-list", " To read "))
|
|
140
|
+
expect(result.current.renamingId).toBeNull()
|
|
141
|
+
expect(result.current.getItem("reading-list")?.label).toBe("To read")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("blank, unchanged and unknown targets are dropped", () => {
|
|
145
|
+
const onRename = mock(() => {})
|
|
146
|
+
const { result } = renderHook(() =>
|
|
147
|
+
useAISidebar({ defaultItems: RESOURCES, onRename })
|
|
148
|
+
)
|
|
149
|
+
act(() => result.current.rename("reading-list", " "))
|
|
150
|
+
act(() => result.current.rename("reading-list", "Reading list"))
|
|
151
|
+
act(() => result.current.rename("nope", "Whatever"))
|
|
152
|
+
expect(onRename).not.toHaveBeenCalled()
|
|
153
|
+
|
|
154
|
+
// Arming rename against a row that never renders is inert.
|
|
155
|
+
act(() => result.current.startRename("nope"))
|
|
156
|
+
expect(result.current.renamingId).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("a rejected onRename rolls the label back and announces it", async () => {
|
|
160
|
+
const onRename = mock(() => Promise.reject(new Error("offline")))
|
|
161
|
+
const { result } = renderHook(() =>
|
|
162
|
+
useAISidebar({ defaultItems: RESOURCES, onRename })
|
|
163
|
+
)
|
|
164
|
+
await act(async () => {
|
|
165
|
+
result.current.rename("reading-list", "To read")
|
|
166
|
+
await Promise.resolve()
|
|
167
|
+
})
|
|
168
|
+
expect(result.current.getItem("reading-list")?.label).toBe("Reading list")
|
|
169
|
+
expect(result.current.announcement).toContain("Rename failed")
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe("useAISidebar · reorder", () => {
|
|
174
|
+
test("move reorders and announces", async () => {
|
|
175
|
+
const onMove = mock(() => {})
|
|
176
|
+
const { result } = renderHook(() =>
|
|
177
|
+
useAISidebar({ defaultItems: RESOURCES, onMove })
|
|
178
|
+
)
|
|
179
|
+
await act(async () =>
|
|
180
|
+
result.current.move({
|
|
181
|
+
itemId: "reading-list",
|
|
182
|
+
targetId: "corpora",
|
|
183
|
+
position: "before",
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
expect(result.current.items.map((item) => item.id)).toEqual([
|
|
187
|
+
"reading-list",
|
|
188
|
+
"corpora",
|
|
189
|
+
"archive",
|
|
190
|
+
])
|
|
191
|
+
expect(onMove).toHaveBeenCalled()
|
|
192
|
+
expect(result.current.announcement).toContain("Moved Reading list before")
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test("a rejected onMove restores the previous order", async () => {
|
|
196
|
+
const onMoveError = mock(() => {})
|
|
197
|
+
const { result } = renderHook(() =>
|
|
198
|
+
useAISidebar({
|
|
199
|
+
defaultItems: RESOURCES,
|
|
200
|
+
onMove: () => Promise.reject(new Error("409")),
|
|
201
|
+
onMoveError,
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
await act(async () =>
|
|
205
|
+
result.current.move({
|
|
206
|
+
itemId: "reading-list",
|
|
207
|
+
targetId: "corpora",
|
|
208
|
+
position: "before",
|
|
209
|
+
})
|
|
210
|
+
)
|
|
211
|
+
expect(result.current.items.map((item) => item.id)).toEqual([
|
|
212
|
+
"corpora",
|
|
213
|
+
"reading-list",
|
|
214
|
+
"archive",
|
|
215
|
+
])
|
|
216
|
+
expect(result.current.announcement).toContain("Move failed")
|
|
217
|
+
expect(onMoveError).toHaveBeenCalled()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test("moves the data forbids are refused", async () => {
|
|
221
|
+
const { result } = renderHook(() =>
|
|
222
|
+
useAISidebar({ defaultItems: RESOURCES })
|
|
223
|
+
)
|
|
224
|
+
// Into its own subtree.
|
|
225
|
+
await act(async () =>
|
|
226
|
+
result.current.move({
|
|
227
|
+
itemId: "corpora",
|
|
228
|
+
targetId: "manuscripts",
|
|
229
|
+
position: "inside",
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
// Inside a row that cannot contain children.
|
|
233
|
+
await act(async () =>
|
|
234
|
+
result.current.move({
|
|
235
|
+
itemId: "corpora",
|
|
236
|
+
targetId: "reading-list",
|
|
237
|
+
position: "inside",
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
expect(result.current.items.map((item) => item.id)).toEqual([
|
|
241
|
+
"corpora",
|
|
242
|
+
"reading-list",
|
|
243
|
+
"archive",
|
|
244
|
+
])
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe("useAISidebar · menu", () => {
|
|
249
|
+
test("openMenu and closeMenu track one row at a time", () => {
|
|
250
|
+
const { result } = renderHook(() =>
|
|
251
|
+
useAISidebar({ defaultItems: RESOURCES })
|
|
252
|
+
)
|
|
253
|
+
act(() => result.current.openMenu("reading-list"))
|
|
254
|
+
expect(result.current.menuOpenId).toBe("reading-list")
|
|
255
|
+
|
|
256
|
+
act(() => result.current.openMenu("nope"))
|
|
257
|
+
expect(result.current.menuOpenId).toBe("reading-list")
|
|
258
|
+
|
|
259
|
+
act(() => result.current.closeMenu())
|
|
260
|
+
expect(result.current.menuOpenId).toBeNull()
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
describe("AISidebar · controller form", () => {
|
|
265
|
+
function Harness({
|
|
266
|
+
onReady,
|
|
267
|
+
}: {
|
|
268
|
+
onReady: (controller: AISidebarController) => void
|
|
269
|
+
}) {
|
|
270
|
+
const controller = useAISidebar({ defaultItems: RESOURCES })
|
|
271
|
+
onReady(controller)
|
|
272
|
+
return <AISidebar controller={controller} />
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
test("renders from the controller and reacts to calls made outside it", async () => {
|
|
276
|
+
let sidebar!: AISidebarController
|
|
277
|
+
render(<Harness onReady={(next) => (sidebar = next)} />)
|
|
278
|
+
expect(screen.queryByRole("treeitem", { name: /Field notes/ })).toBeNull()
|
|
279
|
+
|
|
280
|
+
await act(async () => sidebar.expandAll())
|
|
281
|
+
expect(screen.getByRole("treeitem", { name: /Field notes/ })).toBeDefined()
|
|
282
|
+
|
|
283
|
+
await act(async () => sidebar.select("readme"))
|
|
284
|
+
expect(
|
|
285
|
+
screen.getByRole("treeitem", { name: /Field notes/ }).getAttribute("aria-selected")
|
|
286
|
+
).toBe("true")
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// Rename mode is not asserted through the rendered row here: mounting the
|
|
290
|
+
// input makes happy-dom fire focus/blur, and the row's blur handler
|
|
291
|
+
// commits and closes rename mode before an assertion can run. The hook
|
|
292
|
+
// tests above cover the state; the browser covers the rendering.
|
|
293
|
+
|
|
294
|
+
test("a move made from outside reorders the rendered rows", async () => {
|
|
295
|
+
let sidebar!: AISidebarController
|
|
296
|
+
render(<Harness onReady={(next) => (sidebar = next)} />)
|
|
297
|
+
|
|
298
|
+
await act(async () =>
|
|
299
|
+
sidebar.move({
|
|
300
|
+
itemId: "reading-list",
|
|
301
|
+
targetId: "corpora",
|
|
302
|
+
position: "before",
|
|
303
|
+
})
|
|
304
|
+
)
|
|
305
|
+
const rows = screen
|
|
306
|
+
.getAllByRole("treeitem")
|
|
307
|
+
.map((row) => row.getAttribute("aria-label") ?? row.textContent)
|
|
308
|
+
expect(rows[0]).toContain("Reading list")
|
|
309
|
+
})
|
|
310
|
+
})
|
|
@@ -2,46 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
import { AnimatePresence } from "motion/react";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
|
-
import type {
|
|
6
|
-
|
|
5
|
+
import type {
|
|
6
|
+
AISidebarComponentProps,
|
|
7
|
+
AISidebarController,
|
|
8
|
+
AISidebarProps,
|
|
9
|
+
AISidebarViewProps,
|
|
10
|
+
FlatResource,
|
|
11
|
+
} from "./type"
|
|
12
|
+
import { useAISidebar } from "./use-ai-sidebar"
|
|
7
13
|
import { ResourceRow } from "./sidebar-row"
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Resource tree for the app rail: nested rows with drag-and-drop reorder,
|
|
17
|
+
* inline rename, per-row action menus and full keyboard navigation.
|
|
18
|
+
*
|
|
19
|
+
* Two ways to drive it. Pass the data and handlers as props and the block
|
|
20
|
+
* owns its state, or hold a `useAISidebar` controller and pass it as
|
|
21
|
+
* `controller` — the same rendering, but select, expand, rename, reorder
|
|
22
|
+
* and menu state are then callable from anywhere in your app.
|
|
23
|
+
*/
|
|
24
|
+
export function AISidebar(props: AISidebarComponentProps) {
|
|
25
|
+
// Two components rather than one: hooks may not be called conditionally,
|
|
26
|
+
// and the controller form has no options to build a fallback controller
|
|
27
|
+
// from. Nobody switches a sidebar between the two forms at runtime.
|
|
28
|
+
return props.controller ? (
|
|
29
|
+
<AISidebarView {...viewProps(props)} controller={props.controller} />
|
|
30
|
+
) : (
|
|
31
|
+
<UncontrolledAISidebar {...props} />
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The props form: builds its own controller and renders through it. */
|
|
36
|
+
function UncontrolledAISidebar(props: AISidebarProps) {
|
|
37
|
+
const {
|
|
38
|
+
renderIcon: _icon,
|
|
39
|
+
renderMenu: _menu,
|
|
40
|
+
renderActionsTrigger: _trigger,
|
|
41
|
+
ariaLabel: _label,
|
|
42
|
+
className: _className,
|
|
43
|
+
controller: _controller,
|
|
44
|
+
...options
|
|
45
|
+
} = props
|
|
46
|
+
const controller = useAISidebar(options)
|
|
47
|
+
return <AISidebarView {...viewProps(props)} controller={controller} />
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function viewProps(props: AISidebarComponentProps): AISidebarViewProps {
|
|
51
|
+
return {
|
|
52
|
+
renderIcon: props.renderIcon,
|
|
53
|
+
renderMenu: props.renderMenu,
|
|
54
|
+
renderActionsTrigger: props.renderActionsTrigger,
|
|
55
|
+
ariaLabel: props.ariaLabel,
|
|
56
|
+
className: props.className,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Rendering only — every piece of state lives on the controller. */
|
|
61
|
+
function AISidebarView({
|
|
62
|
+
controller,
|
|
10
63
|
renderIcon,
|
|
11
64
|
renderMenu,
|
|
12
65
|
renderActionsTrigger,
|
|
13
66
|
ariaLabel = "Resources",
|
|
14
67
|
className,
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
const {
|
|
18
|
-
flat,
|
|
19
|
-
selectedId,
|
|
20
|
-
expandedIds,
|
|
21
|
-
focusedId,
|
|
22
|
-
setFocusedId,
|
|
23
|
-
draggingId,
|
|
24
|
-
dropTarget,
|
|
25
|
-
hoveredId,
|
|
26
|
-
hoverLayoutId,
|
|
27
|
-
handleRowHover,
|
|
28
|
-
clearHover,
|
|
29
|
-
menuOpenId,
|
|
30
|
-
renamingId,
|
|
31
|
-
setRenamingId,
|
|
32
|
-
announcement,
|
|
33
|
-
select,
|
|
34
|
-
toggle,
|
|
35
|
-
handleKeyDown,
|
|
36
|
-
handleRootDragOver,
|
|
37
|
-
handleDrop,
|
|
38
|
-
handleRowDragStart,
|
|
39
|
-
handleRowDragEnd,
|
|
40
|
-
handleRowDragOver,
|
|
41
|
-
commitRename,
|
|
42
|
-
handleMenuOpenChange,
|
|
43
|
-
setRowRef,
|
|
44
|
-
} = useAISidebar(options as UseAISidebarOptions);
|
|
68
|
+
}: AISidebarViewProps & { controller: AISidebarController }) {
|
|
69
|
+
const { dnd, hover } = controller
|
|
70
|
+
const { draggingId, dropTarget } = dnd
|
|
45
71
|
|
|
46
72
|
return (
|
|
47
73
|
<>
|
|
@@ -49,9 +75,9 @@ export function AISidebar({
|
|
|
49
75
|
role="tree"
|
|
50
76
|
aria-label={ariaLabel}
|
|
51
77
|
aria-multiselectable="false"
|
|
52
|
-
onDragOver={
|
|
53
|
-
onDrop={
|
|
54
|
-
onMouseLeave={
|
|
78
|
+
onDragOver={dnd.onRootDragOver}
|
|
79
|
+
onDrop={dnd.onDrop}
|
|
80
|
+
onMouseLeave={hover.clear}
|
|
55
81
|
className={cn(
|
|
56
82
|
"relative min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden",
|
|
57
83
|
draggingId && "pb-9 select-none",
|
|
@@ -59,39 +85,43 @@ export function AISidebar({
|
|
|
59
85
|
)}
|
|
60
86
|
>
|
|
61
87
|
<AnimatePresence initial={false}>
|
|
62
|
-
{flat.map((row: FlatResource) => (
|
|
88
|
+
{controller.flat.map((row: FlatResource) => (
|
|
63
89
|
<ResourceRow
|
|
64
90
|
key={row.item.id}
|
|
65
91
|
row={row}
|
|
66
|
-
active={selectedId === row.item.id}
|
|
67
|
-
expanded={
|
|
68
|
-
focused={focusedId === row.item.id}
|
|
92
|
+
active={controller.selectedId === row.item.id}
|
|
93
|
+
expanded={controller.isExpanded(row.item.id)}
|
|
94
|
+
focused={controller.focusedId === row.item.id}
|
|
69
95
|
draggingId={draggingId}
|
|
70
96
|
dropTarget={dropTarget}
|
|
71
|
-
menuOpen={menuOpenId === row.item.id}
|
|
72
|
-
renaming={renamingId === row.item.id}
|
|
73
|
-
hoverActive={hoveredId !== null && !draggingId}
|
|
97
|
+
menuOpen={controller.menuOpenId === row.item.id}
|
|
98
|
+
renaming={controller.renamingId === row.item.id}
|
|
99
|
+
hoverActive={hover.hoveredId !== null && !draggingId}
|
|
74
100
|
hoverPill={
|
|
75
|
-
hoveredId === row.item.id &&
|
|
101
|
+
hover.hoveredId === row.item.id &&
|
|
102
|
+
!draggingId &&
|
|
103
|
+
!row.item.disabled
|
|
104
|
+
}
|
|
105
|
+
hoverLayoutId={hover.layoutId}
|
|
106
|
+
onHoverChange={(hovered) => hover.onRowHover(row.item.id, hovered)}
|
|
107
|
+
onFocus={() => controller.focus(row.item.id)}
|
|
108
|
+
onSelect={() => controller.select(row.item.id)}
|
|
109
|
+
onToggle={() => controller.toggleExpanded(row.item.id)}
|
|
110
|
+
onKeyDown={(event) => controller.onRowKeyDown(event, row)}
|
|
111
|
+
onRenameStart={() => controller.startRename(row.item.id)}
|
|
112
|
+
onRenameCancel={controller.cancelRename}
|
|
113
|
+
onRenameCommit={(label) => controller.rename(row.item.id, label)}
|
|
114
|
+
onMenuOpenChange={(open) =>
|
|
115
|
+
open ? controller.openMenu(row.item.id) : controller.closeMenu()
|
|
76
116
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
onToggle={() => toggle(row.item.id)}
|
|
82
|
-
onKeyDown={(event) => handleKeyDown(event, row)}
|
|
83
|
-
onRenameStart={() => setRenamingId(row.item.id)}
|
|
84
|
-
onRenameCancel={() => setRenamingId(null)}
|
|
85
|
-
onRenameCommit={(label) => commitRename(row, label)}
|
|
86
|
-
onMenuOpenChange={(open) => handleMenuOpenChange(row, open)}
|
|
87
|
-
onDragStart={handleRowDragStart}
|
|
88
|
-
onDragEnd={handleRowDragEnd}
|
|
89
|
-
onDragOver={handleRowDragOver}
|
|
90
|
-
onDrop={handleDrop}
|
|
117
|
+
onDragStart={dnd.onRowDragStart}
|
|
118
|
+
onDragEnd={dnd.onRowDragEnd}
|
|
119
|
+
onDragOver={dnd.onRowDragOver}
|
|
120
|
+
onDrop={dnd.onDrop}
|
|
91
121
|
renderIcon={renderIcon}
|
|
92
122
|
renderMenu={renderMenu}
|
|
93
123
|
renderActionsTrigger={renderActionsTrigger}
|
|
94
|
-
setRef={(node) => setRowRef(row.item.id, node)}
|
|
124
|
+
setRef={(node) => controller.setRowRef(row.item.id, node)}
|
|
95
125
|
/>
|
|
96
126
|
))}
|
|
97
127
|
</AnimatePresence>
|
|
@@ -107,7 +137,7 @@ export function AISidebar({
|
|
|
107
137
|
)}
|
|
108
138
|
</div>
|
|
109
139
|
<span className="sr-only" aria-live="polite">
|
|
110
|
-
{announcement}
|
|
140
|
+
{controller.announcement}
|
|
111
141
|
</span>
|
|
112
142
|
</>
|
|
113
143
|
)
|
|
@@ -23,7 +23,10 @@ export interface SidebarResourceMenuControls {
|
|
|
23
23
|
rename: () => void
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/** Options for `useAISidebar`. `items`, `activeId` and `expandedIds` are
|
|
27
|
+
* each controlled when passed and hook-owned via their `default*` twin
|
|
28
|
+
* otherwise. */
|
|
29
|
+
export interface UseAISidebarOptions {
|
|
27
30
|
items?: SidebarResource[]
|
|
28
31
|
defaultItems?: SidebarResource[]
|
|
29
32
|
onItemsChange?: (items: SidebarResource[]) => void
|
|
@@ -34,7 +37,89 @@ export interface AISidebarProps {
|
|
|
34
37
|
activeId?: string | null
|
|
35
38
|
defaultActiveId?: string | null
|
|
36
39
|
onActiveChange?: (id: string) => void
|
|
40
|
+
expandedIds?: string[]
|
|
37
41
|
defaultExpandedIds?: string[]
|
|
42
|
+
onExpandedChange?: (ids: string[]) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Everything the sidebar can do, callable from outside the block — a
|
|
46
|
+
* toolbar, a command palette, a route change. Returned by `useAISidebar`
|
|
47
|
+
* and accepted by `<AISidebar controller={…} />`. */
|
|
48
|
+
export interface AISidebarController {
|
|
49
|
+
items: SidebarResource[]
|
|
50
|
+
/** Visible rows in order, each with its depth and parent id. */
|
|
51
|
+
flat: FlatResource[]
|
|
52
|
+
getItem: (id: string) => SidebarResource | undefined
|
|
53
|
+
|
|
54
|
+
selectedId: string | null
|
|
55
|
+
select: (id: string) => void
|
|
56
|
+
|
|
57
|
+
expandedIds: ReadonlySet<string>
|
|
58
|
+
isExpanded: (id: string) => boolean
|
|
59
|
+
expand: (id: string) => void
|
|
60
|
+
collapse: (id: string) => void
|
|
61
|
+
toggleExpanded: (id: string) => void
|
|
62
|
+
expandAll: () => void
|
|
63
|
+
collapseAll: () => void
|
|
64
|
+
/** Open every ancestor of `id`, leaving the rest of the tree alone. */
|
|
65
|
+
reveal: (id: string) => void
|
|
66
|
+
|
|
67
|
+
/** Roving-focus target. `focus` also moves DOM focus to the row. */
|
|
68
|
+
focusedId: string | null
|
|
69
|
+
focus: (id: string) => void
|
|
70
|
+
|
|
71
|
+
renamingId: string | null
|
|
72
|
+
startRename: (id: string) => void
|
|
73
|
+
cancelRename: () => void
|
|
74
|
+
/** Commit a rename and leave rename mode. Applied optimistically and
|
|
75
|
+
* rolled back if `onRename` rejects. Blank and unchanged labels are
|
|
76
|
+
* dropped. */
|
|
77
|
+
rename: (id: string, label: string) => void
|
|
78
|
+
|
|
79
|
+
menuOpenId: string | null
|
|
80
|
+
openMenu: (id: string) => void
|
|
81
|
+
closeMenu: () => void
|
|
82
|
+
|
|
83
|
+
/** Reorder, applied optimistically and rolled back if `onMove` rejects.
|
|
84
|
+
* Refuses moves the data forbids (into itself, into a non-container). */
|
|
85
|
+
move: (move: SidebarResourceMove) => Promise<void>
|
|
86
|
+
/** A move is in flight — further moves are refused until it settles. */
|
|
87
|
+
movePending: boolean
|
|
88
|
+
|
|
89
|
+
/** Live-region text for the last move or rename outcome. */
|
|
90
|
+
announcement: string
|
|
91
|
+
|
|
92
|
+
/** @internal Row wiring the view threads through — drag state, the
|
|
93
|
+
* hover pill and roving keyboard nav all belong to the rendering. */
|
|
94
|
+
dnd: AISidebarDndState
|
|
95
|
+
hover: AISidebarHoverState
|
|
96
|
+
onRowKeyDown: (event: KeyboardEvent<HTMLDivElement>, row: FlatResource) => void
|
|
97
|
+
setRowRef: (id: string, node: HTMLDivElement | null) => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @internal */
|
|
101
|
+
export interface AISidebarDndState {
|
|
102
|
+
draggingId: string | null
|
|
103
|
+
dropTarget: DropTarget | null
|
|
104
|
+
onRootDragOver: (event: DragEvent<HTMLDivElement>) => void
|
|
105
|
+
onDrop: (event: DragEvent<HTMLDivElement>) => void
|
|
106
|
+
onRowDragStart: (event: DragEvent<HTMLDivElement>, id: string) => void
|
|
107
|
+
onRowDragEnd: () => void
|
|
108
|
+
onRowDragOver: (event: DragEvent<HTMLDivElement>, row: FlatResource) => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @internal */
|
|
112
|
+
export interface AISidebarHoverState {
|
|
113
|
+
hoveredId: string | null
|
|
114
|
+
/** Shared motion layoutId that lets the pill travel between rows. */
|
|
115
|
+
layoutId: string
|
|
116
|
+
onRowHover: (id: string, hovered: boolean) => void
|
|
117
|
+
clear: () => void
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface AISidebarProps extends UseAISidebarOptions {
|
|
121
|
+
/** Never set on the data form — `controller` selects the other one. */
|
|
122
|
+
controller?: never
|
|
38
123
|
renderIcon?: (item: SidebarResource) => ReactNode
|
|
39
124
|
renderMenu?: (
|
|
40
125
|
item: SidebarResource,
|
|
@@ -49,6 +134,25 @@ export interface AISidebarProps {
|
|
|
49
134
|
className?: string
|
|
50
135
|
}
|
|
51
136
|
|
|
137
|
+
/** Presentational props, shared by both forms of `<AISidebar>`. */
|
|
138
|
+
export type AISidebarViewProps = Pick<
|
|
139
|
+
AISidebarProps,
|
|
140
|
+
| "renderIcon"
|
|
141
|
+
| "renderMenu"
|
|
142
|
+
| "renderActionsTrigger"
|
|
143
|
+
| "ariaLabel"
|
|
144
|
+
| "className"
|
|
145
|
+
>
|
|
146
|
+
|
|
147
|
+
/** Drive the block from a `useAISidebar` controller instead of raw props —
|
|
148
|
+
* the controller carries the data and every handler. */
|
|
149
|
+
export interface AISidebarControllerProps extends AISidebarViewProps {
|
|
150
|
+
controller: AISidebarController
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Either form: raw props, or a `useAISidebar` controller. */
|
|
154
|
+
export type AISidebarComponentProps = AISidebarProps | AISidebarControllerProps
|
|
155
|
+
|
|
52
156
|
export interface FlatResource {
|
|
53
157
|
item: SidebarResource
|
|
54
158
|
depth: number
|