@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.18.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 max-h-[500px] flex-col overflow-hidden rounded-lg border border-border bg-neutral-50 shadow-lg dark:bg-neutral-900 relative">
26
- <div id="title-bar" className={cn("flex flex-1 w-full items-center gap-2 px-4 py-3 z-20", titleBarStyle(titleStyle, 'title'))}>
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 ml-2">{title}</span>
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 { AISidebarProps, FlatResource } from "./type"
6
- import { useAISidebar, type UseAISidebarOptions } from "./use-ai-sidebar"
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
- export function AISidebar({
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
- ...options
16
- }: AISidebarProps) {
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={handleRootDragOver}
53
- onDrop={handleDrop}
54
- onMouseLeave={clearHover}
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={expandedIds.has(row.item.id)}
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 && !draggingId && !row.item.disabled
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
- hoverLayoutId={hoverLayoutId}
78
- onHoverChange={(hovered) => handleRowHover(row.item.id, hovered)}
79
- onFocus={() => setFocusedId(row.item.id)}
80
- onSelect={() => select(row.item.id)}
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
- export interface AISidebarProps {
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