@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.
Files changed (73) 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 +5 -0
  7. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  8. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  9. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  10. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  11. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  12. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  13. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  14. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  15. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  16. package/dist-lib/components/composed/tree/index.d.ts +4 -0
  17. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  18. package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
  19. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  20. package/dist-lib/components/composed/tree/type.d.ts +192 -0
  21. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
  22. package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
  23. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  24. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  25. package/dist-lib/components/user-avatar/component.d.ts +10 -0
  26. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  27. package/dist-lib/components/user-avatar/index.d.ts +13 -0
  28. package/dist-lib/components/user-avatar/type.d.ts +26 -0
  29. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  30. package/dist-lib/index.d.ts +1 -0
  31. package/dist-lib/index.js +2141 -1139
  32. package/dist-lib/index.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/components/beste/piece/browser-frame.tsx +9 -6
  35. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  36. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
  37. package/src/components/blocks/nav/sidebar/type.ts +105 -1
  38. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
  39. package/src/components/blocks/nav/sidebar/utils.ts +28 -0
  40. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  41. package/src/components/blocks/scaffold/constants.ts +7 -0
  42. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  43. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  44. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  45. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  46. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  47. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  48. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  49. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  50. package/src/components/blocks/scaffold/type.ts +28 -0
  51. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  52. package/src/components/blocks/scaffold/utils.ts +19 -0
  53. package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
  54. package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
  55. package/src/components/composed/tree/constants.ts +60 -0
  56. package/src/components/composed/tree/index.ts +13 -0
  57. package/src/components/composed/tree/tree-context.ts +13 -0
  58. package/src/components/composed/tree/tree-node.tsx +451 -0
  59. package/src/components/composed/tree/tree.tsx +278 -0
  60. package/src/components/composed/tree/type.ts +212 -0
  61. package/src/components/composed/tree/use-tree-dnd.ts +148 -0
  62. package/src/components/composed/tree/use-tree.ts +306 -0
  63. package/src/components/composed/tree/utils.ts +172 -0
  64. package/src/components/composed/user-avatar.tsx +4 -1
  65. package/src/components/docs/component-preview.tsx +11 -11
  66. package/src/components/user-avatar/audio-wave.tsx +22 -0
  67. package/src/components/user-avatar/component.tsx +53 -0
  68. package/src/components/user-avatar/fallback.tsx +26 -0
  69. package/src/components/user-avatar/index.ts +15 -0
  70. package/src/components/user-avatar/type.ts +35 -0
  71. package/src/components/user-avatar/utils.ts +8 -0
  72. package/src/index.css +211 -153
  73. package/src/index.ts +1 -0
@@ -11,51 +11,63 @@ import {
11
11
  useState,
12
12
  } from "react";
13
13
  import type {
14
- AISidebarProps,
14
+ AISidebarController,
15
15
  DropTarget,
16
16
  FlatResource,
17
17
  SidebarResource,
18
18
  SidebarResourceMove,
19
+ UseAISidebarOptions,
19
20
  } from "./type.ts"
20
21
  import {
22
+ ancestorIdsOf,
21
23
  canContain,
22
24
  containsResource,
25
+ expandableIdsOf,
23
26
  findResource,
24
27
  flattenResources,
25
28
  moveResource,
26
29
  renameResource,
27
30
  } from "./utils.ts"
28
31
 
29
- export type UseAISidebarOptions = Pick<
30
- AISidebarProps,
31
- | "items"
32
- | "defaultItems"
33
- | "onItemsChange"
34
- | "onMove"
35
- | "onMoveError"
36
- | "onRename"
37
- | "activeId"
38
- | "defaultActiveId"
39
- | "onActiveChange"
40
- | "defaultExpandedIds"
41
- >
42
-
43
- export function useAISidebar({
44
- items,
45
- defaultItems = [],
46
- onItemsChange,
47
- onMove,
48
- onMoveError,
49
- onRename,
50
- activeId,
51
- defaultActiveId = null,
52
- onActiveChange,
53
- defaultExpandedIds = [],
54
- }: UseAISidebarOptions) {
32
+ export type { UseAISidebarOptions }
33
+
34
+ /**
35
+ * The AI sidebar's headless controller — every behaviour the block
36
+ * performs, callable from outside it: select, expand/collapse (one row,
37
+ * every row, or just the ancestors of one), move focus, rename, open a
38
+ * row's action menu, reorder.
39
+ *
40
+ * `items`, `activeId` and `expandedIds` are each controlled when passed
41
+ * and hook-owned via their `default*` twin otherwise. Reorder and rename
42
+ * are applied to the data optimistically either way and rolled back if
43
+ * `onMove`/`onRename` rejects, so a consumer never reduces the tree
44
+ * itself — `moveResource` and `renameResource` are exported for the ones
45
+ * who keep their data elsewhere.
46
+ *
47
+ * ```tsx
48
+ * const sidebar = useAISidebar({ defaultItems: resources })
49
+ * <AISidebar controller={sidebar} />
50
+ * <Button onClick={sidebar.collapseAll}>Collapse all</Button>
51
+ * ```
52
+ */
53
+ export function useAISidebar(options: UseAISidebarOptions): AISidebarController {
54
+ const {
55
+ items,
56
+ defaultItems = [],
57
+ onItemsChange,
58
+ onMove,
59
+ onMoveError,
60
+ onRename,
61
+ activeId,
62
+ defaultActiveId = null,
63
+ onActiveChange,
64
+ onExpandedChange,
65
+ } = options
66
+
55
67
  const [internalItems, setInternalItems] = useState(items ?? defaultItems);
56
68
  const [internalActiveId, setInternalActiveId] = useState(defaultActiveId);
57
- const [expandedIds, setExpandedIds] = useState(
58
- () => new Set(defaultExpandedIds),
69
+ const [ownExpandedIds, setOwnExpandedIds] = useState(
70
+ () => new Set(options.defaultExpandedIds ?? []),
59
71
  );
60
72
  const [focusedId, setFocusedId] = useState<string | null>(
61
73
  activeId ?? defaultActiveId,
@@ -67,11 +79,20 @@ export function useAISidebar({
67
79
  const [hoveredId, setHoveredId] = useState<string | null>(null);
68
80
  const hoverLayoutId = useId();
69
81
  const [announcement, setAnnouncement] = useState("");
82
+ // `movePending` is mirrored in a ref because the guard has to read it
83
+ // synchronously, before React has committed the state change.
84
+ const [movePending, setMovePending] = useState(false);
70
85
  const rowRefs = useRef(new Map<string, HTMLDivElement>());
71
86
  const movePendingRef = useRef(false);
72
87
  const renderedItems = internalItems;
73
88
  const selectedId = activeId ?? internalActiveId;
74
89
 
90
+ const controlledExpandedIds = options.expandedIds
91
+ const expandedIds = useMemo(
92
+ () => (controlledExpandedIds ? new Set(controlledExpandedIds) : ownExpandedIds),
93
+ [controlledExpandedIds, ownExpandedIds],
94
+ )
95
+
75
96
  const [prevItems, setPrevItems] = useState(items);
76
97
  if (items !== prevItems) {
77
98
  setPrevItems(items);
@@ -112,7 +133,70 @@ export function useAISidebar({
112
133
  [onItemsChange],
113
134
  );
114
135
 
115
- const performMove = useCallback(
136
+ const commitExpanded = useCallback(
137
+ (next: Set<string>) => {
138
+ if (controlledExpandedIds === undefined) setOwnExpandedIds(next);
139
+ onExpandedChange?.([...next]);
140
+ },
141
+ [controlledExpandedIds, onExpandedChange],
142
+ );
143
+
144
+ const getItem = useCallback(
145
+ (id: string) => findResource(renderedItems, id),
146
+ [renderedItems],
147
+ );
148
+
149
+ const isExpanded = useCallback(
150
+ (id: string) => expandedIds.has(id),
151
+ [expandedIds],
152
+ );
153
+
154
+ const expand = useCallback(
155
+ (id: string) => {
156
+ if (expandedIds.has(id)) return;
157
+ commitExpanded(new Set(expandedIds).add(id));
158
+ },
159
+ [commitExpanded, expandedIds],
160
+ );
161
+
162
+ const collapse = useCallback(
163
+ (id: string) => {
164
+ if (!expandedIds.has(id)) return;
165
+ const next = new Set(expandedIds);
166
+ next.delete(id);
167
+ commitExpanded(next);
168
+ },
169
+ [commitExpanded, expandedIds],
170
+ );
171
+
172
+ const toggleExpanded = useCallback(
173
+ (id: string) => {
174
+ const next = new Set(expandedIds);
175
+ if (next.has(id)) next.delete(id);
176
+ else next.add(id);
177
+ commitExpanded(next);
178
+ },
179
+ [commitExpanded, expandedIds],
180
+ );
181
+
182
+ const expandAll = useCallback(() => {
183
+ commitExpanded(new Set(expandableIdsOf(renderedItems)));
184
+ }, [commitExpanded, renderedItems]);
185
+
186
+ const collapseAll = useCallback(() => {
187
+ commitExpanded(new Set());
188
+ }, [commitExpanded]);
189
+
190
+ const reveal = useCallback(
191
+ (id: string) => {
192
+ const ancestors = ancestorIdsOf(renderedItems, id);
193
+ if (!ancestors.some((ancestor) => !expandedIds.has(ancestor))) return;
194
+ commitExpanded(new Set([...expandedIds, ...ancestors]));
195
+ },
196
+ [commitExpanded, expandedIds, renderedItems],
197
+ );
198
+
199
+ const move = useCallback(
116
200
  async (move: SidebarResourceMove) => {
117
201
  if (movePendingRef.current) {
118
202
  setAnnouncement("Wait for the current move to finish.");
@@ -123,6 +207,7 @@ export function useAISidebar({
123
207
  if (!next || next === before) return;
124
208
 
125
209
  movePendingRef.current = true;
210
+ setMovePending(true);
126
211
  updateItems(next);
127
212
  setDropTarget(null);
128
213
  setDraggingId(null);
@@ -142,12 +227,13 @@ export function useAISidebar({
142
227
  onMoveError?.(error, move);
143
228
  } finally {
144
229
  movePendingRef.current = false;
230
+ setMovePending(false);
145
231
  }
146
232
  },
147
233
  [onMove, onMoveError, renderedItems, updateItems],
148
234
  );
149
235
 
150
- const focusRow = useCallback((id: string) => {
236
+ const focus = useCallback((id: string) => {
151
237
  setFocusedId(id);
152
238
  requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
153
239
  }, []);
@@ -160,16 +246,52 @@ export function useAISidebar({
160
246
  [activeId, onActiveChange],
161
247
  );
162
248
 
163
- const toggle = useCallback((id: string) => {
164
- setExpandedIds((current) => {
165
- const next = new Set(current);
166
- if (next.has(id)) next.delete(id);
167
- else next.add(id);
168
- return next;
249
+ const startRename = useCallback(
250
+ (id: string) => {
251
+ // An id from elsewhere would arm rename mode against a row that
252
+ // never renders.
253
+ if (!findResource(renderedItems, id)) return;
254
+ setRenamingId(id);
255
+ },
256
+ [renderedItems],
257
+ );
258
+
259
+ const cancelRename = useCallback(() => setRenamingId(null), []);
260
+
261
+ const rename = useCallback(
262
+ (id: string, label: string) => {
263
+ const trimmed = label.trim();
264
+ setRenamingId(null);
265
+ const item = findResource(renderedItems, id);
266
+ if (!item || !trimmed || trimmed === item.label) return;
267
+ const before = renderedItems;
268
+ updateItems(renameResource(before, id, trimmed));
269
+ void Promise.resolve(onRename?.(item, trimmed)).catch(() => {
270
+ updateItems(before);
271
+ setAnnouncement(`Rename failed. ${item.label} was restored.`);
272
+ });
273
+ },
274
+ [onRename, renderedItems, updateItems],
275
+ );
276
+
277
+ const openMenu = useCallback(
278
+ (id: string) => {
279
+ if (!findResource(renderedItems, id)) return;
280
+ setMenuOpenId(id);
281
+ },
282
+ [renderedItems],
283
+ );
284
+
285
+ // Closing returns focus to the row that owned the menu — otherwise focus
286
+ // falls back to the body and the keyboard user loses their place.
287
+ const closeMenu = useCallback(() => {
288
+ setMenuOpenId((current) => {
289
+ if (current) focus(current);
290
+ return null;
169
291
  });
170
- }, []);
292
+ }, [focus]);
171
293
 
172
- const handleKeyDown = useCallback(
294
+ const onRowKeyDown = useCallback(
173
295
  (event: KeyboardEvent<HTMLDivElement>, row: FlatResource) => {
174
296
  const index = flat.findIndex(({ item }) => item.id === row.item.id);
175
297
  const previous = flat[index - 1];
@@ -178,29 +300,29 @@ export function useAISidebar({
178
300
 
179
301
  if (event.key === "ArrowDown" && !moveModifier && next) {
180
302
  event.preventDefault();
181
- focusRow(next.item.id);
303
+ focus(next.item.id);
182
304
  return;
183
305
  }
184
306
  if (event.key === "ArrowUp" && !moveModifier && previous) {
185
307
  event.preventDefault();
186
- focusRow(previous.item.id);
308
+ focus(previous.item.id);
187
309
  return;
188
310
  }
189
311
  if (event.key === "Home" && flat[0]) {
190
312
  event.preventDefault();
191
- focusRow(flat[0].item.id);
313
+ focus(flat[0].item.id);
192
314
  return;
193
315
  }
194
316
  if (event.key === "End" && flat.at(-1)) {
195
317
  event.preventDefault();
196
- focusRow(flat.at(-1)?.item.id ?? row.item.id);
318
+ focus(flat.at(-1)?.item.id ?? row.item.id);
197
319
  return;
198
320
  }
199
321
 
200
322
  if (row.item.disabled) {
201
323
  if (event.key === "ArrowLeft" && row.parentId) {
202
324
  event.preventDefault();
203
- focusRow(row.parentId);
325
+ focus(row.parentId);
204
326
  } else if (
205
327
  moveModifier ||
206
328
  ["ArrowRight", "Enter", " ", "F2", "ContextMenu"].includes(
@@ -215,50 +337,61 @@ export function useAISidebar({
215
337
 
216
338
  if (moveModifier && event.key === "ArrowUp" && previous) {
217
339
  event.preventDefault();
218
- void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "before" });
340
+ void move({ itemId: row.item.id, targetId: previous.item.id, position: "before" });
219
341
  return;
220
342
  }
221
343
  if (moveModifier && event.key === "ArrowDown" && next) {
222
344
  event.preventDefault();
223
- void performMove({ itemId: row.item.id, targetId: next.item.id, position: "after" });
345
+ void move({ itemId: row.item.id, targetId: next.item.id, position: "after" });
224
346
  return;
225
347
  }
226
348
  if (moveModifier && event.key === "ArrowRight" && previous && canContain(previous.item)) {
227
349
  event.preventDefault();
228
- setExpandedIds((current) => new Set(current).add(previous.item.id));
229
- void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "inside" });
350
+ expand(previous.item.id);
351
+ void move({ itemId: row.item.id, targetId: previous.item.id, position: "inside" });
230
352
  return;
231
353
  }
232
354
  if (moveModifier && event.key === "ArrowLeft" && row.parentId) {
233
355
  event.preventDefault();
234
- void performMove({ itemId: row.item.id, targetId: row.parentId, position: "after" });
356
+ void move({ itemId: row.item.id, targetId: row.parentId, position: "after" });
235
357
  return;
236
358
  }
237
359
 
238
360
  if (event.key === "ArrowRight" && canContain(row.item)) {
239
361
  event.preventDefault();
240
- if (!expandedIds.has(row.item.id)) toggle(row.item.id);
241
- else if (next?.parentId === row.item.id) focusRow(next.item.id);
362
+ if (!expandedIds.has(row.item.id)) expand(row.item.id);
363
+ else if (next?.parentId === row.item.id) focus(next.item.id);
242
364
  } else if (event.key === "ArrowLeft") {
243
365
  event.preventDefault();
244
- if (expandedIds.has(row.item.id)) toggle(row.item.id);
245
- else if (row.parentId) focusRow(row.parentId);
366
+ if (expandedIds.has(row.item.id)) collapse(row.item.id);
367
+ else if (row.parentId) focus(row.parentId);
246
368
  } else if (event.key === "Enter" || event.key === " ") {
247
369
  event.preventDefault();
248
- if (canContain(row.item)) toggle(row.item.id);
370
+ if (canContain(row.item)) toggleExpanded(row.item.id);
249
371
  else select(row.item.id);
250
372
  } else if (event.key === "F2") {
251
373
  event.preventDefault();
252
- setRenamingId(row.item.id);
374
+ startRename(row.item.id);
253
375
  } else if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
254
376
  event.preventDefault();
255
- setMenuOpenId(row.item.id);
377
+ openMenu(row.item.id);
256
378
  }
257
379
  },
258
- [expandedIds, flat, focusRow, performMove, select, toggle],
380
+ [
381
+ collapse,
382
+ expand,
383
+ expandedIds,
384
+ flat,
385
+ focus,
386
+ move,
387
+ openMenu,
388
+ select,
389
+ startRename,
390
+ toggleExpanded,
391
+ ],
259
392
  );
260
393
 
261
- const handleRootDragOver = useCallback(
394
+ const onRootDragOver = useCallback(
262
395
  (event: DragEvent<HTMLDivElement>) => {
263
396
  if (!draggingId || event.target !== event.currentTarget) return;
264
397
  event.preventDefault();
@@ -267,22 +400,22 @@ export function useAISidebar({
267
400
  [draggingId],
268
401
  );
269
402
 
270
- const handleDrop = useCallback(
403
+ const onDrop = useCallback(
271
404
  (event: DragEvent<HTMLDivElement>) => {
272
405
  event.preventDefault();
273
406
  event.stopPropagation();
274
407
  if (draggingId && dropTarget) {
275
- void performMove({
408
+ void move({
276
409
  itemId: draggingId,
277
410
  targetId: dropTarget.id,
278
411
  position: dropTarget.position,
279
412
  });
280
413
  }
281
414
  },
282
- [draggingId, dropTarget, performMove],
415
+ [draggingId, dropTarget, move],
283
416
  );
284
417
 
285
- const handleRowDragStart = useCallback(
418
+ const onRowDragStart = useCallback(
286
419
  (event: DragEvent<HTMLDivElement>, id: string) => {
287
420
  setDraggingId(id);
288
421
  event.dataTransfer.effectAllowed = "move";
@@ -291,12 +424,12 @@ export function useAISidebar({
291
424
  [],
292
425
  );
293
426
 
294
- const handleRowDragEnd = useCallback(() => {
427
+ const onRowDragEnd = useCallback(() => {
295
428
  setDraggingId(null);
296
429
  setDropTarget(null);
297
430
  }, []);
298
431
 
299
- const handleRowDragOver = useCallback(
432
+ const onRowDragOver = useCallback(
300
433
  (event: DragEvent<HTMLDivElement>, targetRow: FlatResource) => {
301
434
  if (!draggingId || draggingId === targetRow.item.id) return;
302
435
  const source = findResource(renderedItems, draggingId);
@@ -319,30 +452,7 @@ export function useAISidebar({
319
452
  [draggingId, renderedItems],
320
453
  );
321
454
 
322
- const commitRename = useCallback(
323
- (row: FlatResource, label: string) => {
324
- const trimmed = label.trim();
325
- setRenamingId(null);
326
- if (!trimmed || trimmed === row.item.label) return;
327
- const before = renderedItems;
328
- updateItems(renameResource(before, row.item.id, trimmed));
329
- void Promise.resolve(onRename?.(row.item, trimmed)).catch(() => {
330
- updateItems(before);
331
- setAnnouncement(`Rename failed. ${row.item.label} was restored.`);
332
- });
333
- },
334
- [onRename, renderedItems, updateItems],
335
- );
336
-
337
- const handleMenuOpenChange = useCallback(
338
- (row: FlatResource, open: boolean) => {
339
- setMenuOpenId(open ? row.item.id : null);
340
- if (!open) focusRow(row.item.id);
341
- },
342
- [focusRow],
343
- );
344
-
345
- const handleRowHover = useCallback((id: string, hovered: boolean) => {
455
+ const onRowHover = useCallback((id: string, hovered: boolean) => {
346
456
  setHoveredId((current) =>
347
457
  hovered ? id : current === id ? null : current,
348
458
  );
@@ -355,32 +465,99 @@ export function useAISidebar({
355
465
  else rowRefs.current.delete(id);
356
466
  }, []);
357
467
 
358
- return {
359
- flat,
360
- selectedId,
361
- expandedIds,
362
- focusedId,
363
- setFocusedId,
364
- draggingId,
365
- dropTarget,
366
- hoveredId,
367
- hoverLayoutId,
368
- handleRowHover,
369
- clearHover,
370
- menuOpenId,
371
- renamingId,
372
- setRenamingId,
373
- announcement,
374
- select,
375
- toggle,
376
- handleKeyDown,
377
- handleRootDragOver,
378
- handleDrop,
379
- handleRowDragStart,
380
- handleRowDragEnd,
381
- handleRowDragOver,
382
- commitRename,
383
- handleMenuOpenChange,
384
- setRowRef,
385
- };
468
+ const dnd = useMemo(
469
+ () => ({
470
+ draggingId,
471
+ dropTarget,
472
+ onRootDragOver,
473
+ onDrop,
474
+ onRowDragStart,
475
+ onRowDragEnd,
476
+ onRowDragOver,
477
+ }),
478
+ [
479
+ draggingId,
480
+ dropTarget,
481
+ onRootDragOver,
482
+ onDrop,
483
+ onRowDragStart,
484
+ onRowDragEnd,
485
+ onRowDragOver,
486
+ ],
487
+ );
488
+
489
+ const hover = useMemo(
490
+ () => ({
491
+ hoveredId,
492
+ layoutId: hoverLayoutId,
493
+ onRowHover,
494
+ clear: clearHover,
495
+ }),
496
+ [clearHover, hoverLayoutId, hoveredId, onRowHover],
497
+ );
498
+
499
+ return useMemo<AISidebarController>(
500
+ () => ({
501
+ items: renderedItems,
502
+ flat,
503
+ getItem,
504
+ selectedId,
505
+ select,
506
+ expandedIds,
507
+ isExpanded,
508
+ expand,
509
+ collapse,
510
+ toggleExpanded,
511
+ expandAll,
512
+ collapseAll,
513
+ reveal,
514
+ focusedId,
515
+ focus,
516
+ renamingId,
517
+ startRename,
518
+ cancelRename,
519
+ rename,
520
+ menuOpenId,
521
+ openMenu,
522
+ closeMenu,
523
+ move,
524
+ movePending,
525
+ announcement,
526
+ dnd,
527
+ hover,
528
+ onRowKeyDown,
529
+ setRowRef,
530
+ }),
531
+ [
532
+ renderedItems,
533
+ flat,
534
+ getItem,
535
+ selectedId,
536
+ select,
537
+ expandedIds,
538
+ isExpanded,
539
+ expand,
540
+ collapse,
541
+ toggleExpanded,
542
+ expandAll,
543
+ collapseAll,
544
+ reveal,
545
+ focusedId,
546
+ focus,
547
+ renamingId,
548
+ startRename,
549
+ cancelRename,
550
+ rename,
551
+ menuOpenId,
552
+ openMenu,
553
+ closeMenu,
554
+ move,
555
+ movePending,
556
+ announcement,
557
+ dnd,
558
+ hover,
559
+ onRowKeyDown,
560
+ setRowRef,
561
+ ],
562
+ );
386
563
  }
@@ -38,6 +38,34 @@ export function findResource(
38
38
  }
39
39
 
40
40
 
41
+ /** Ids of every ancestor of `id` (nearest last); empty when absent. */
42
+ export function ancestorIdsOf(
43
+ items: SidebarResource[],
44
+ id: string
45
+ ): string[] {
46
+ const walk = (nodes: SidebarResource[], trail: string[]): string[] | null => {
47
+ for (const node of nodes) {
48
+ if (node.id === id) return trail
49
+ if (node.children) {
50
+ const found = walk(node.children, [...trail, node.id])
51
+ if (found) return found
52
+ }
53
+ }
54
+ return null
55
+ }
56
+ return walk(items, []) ?? []
57
+ }
58
+
59
+ /** Ids of every row that can hold children and has some — the set
60
+ * `expandAll` opens. */
61
+ export function expandableIdsOf(items: SidebarResource[]): string[] {
62
+ return items.flatMap((item) =>
63
+ item.children?.length && canContain(item)
64
+ ? [item.id, ...expandableIdsOf(item.children)]
65
+ : []
66
+ )
67
+ }
68
+
41
69
  export function containsResource(item: SidebarResource, id: string): boolean {
42
70
  return (
43
71
  item.id === id ||