@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
@@ -0,0 +1,278 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+ import { TooltipProvider } from "@/components/ui/tooltip"
7
+ import { TreeContext } from "./tree-context"
8
+ import { TreeRow } from "./tree-node"
9
+ import type {
10
+ TreeContextValue,
11
+ TreeController,
12
+ TreeDataProps,
13
+ TreeNode,
14
+ TreeProps,
15
+ } from "./type"
16
+ import {
17
+ RAIL_COLLAPSED_WIDTH,
18
+ TREE_COLLAPSE_DURATION,
19
+ TREE_EASE,
20
+ } from "./constants"
21
+ import { useTree } from "./use-tree"
22
+ import { motion, useReducedMotion } from "motion/react"
23
+
24
+ const DEFAULT_LABELS: Record<TreeController["variant"], string> = {
25
+ navigation: "Main",
26
+ toc: "On this page",
27
+ sidebar: "Main",
28
+ files: "Files",
29
+ }
30
+
31
+ /**
32
+ * A nested item tree with four shapes: `navigation` (app nav — 3-level
33
+ * data promotes the top level to collapsible section names), `toc`
34
+ * (routes on top, in-page `#` anchors at the leaves), `sidebar` (a
35
+ * single-level rail that collapses to icons) and `files` (a compact file
36
+ * explorer with rename, drag-and-drop and trailing row actions).
37
+ *
38
+ * Two ways to drive it. Data-driven like the sidebar block: pass `items`,
39
+ * wire `onNavigate` into your router. Or hold a `useTree` controller and
40
+ * pass it as `tree` — same rendering, but expand, collapse, select, rename
41
+ * and reorder are then callable from anywhere in your app.
42
+ *
43
+ * Branches expand with a soft height morph and row stagger; the rail's
44
+ * labels fold away when `collapsed` flips. Arrow keys walk visible rows,
45
+ * expand and collapse; F2 renames in `files`.
46
+ */
47
+ export function Tree(props: TreeProps): React.ReactElement {
48
+ // Two components rather than one: hooks may not be called conditionally,
49
+ // and the controller form has no props to build a fallback controller
50
+ // from. Nobody switches a tree between the two forms at runtime.
51
+ return props.tree
52
+ ? <TreeView
53
+ ariaLabel={props.ariaLabel}
54
+ className={props.className}
55
+ renderTrailing={props.renderTrailing}
56
+ tree={props.tree}
57
+ />
58
+ : <UncontrolledTree {...props} />
59
+ }
60
+
61
+ /** The props form: builds its own controller and renders through it. */
62
+ function UncontrolledTree(props: TreeDataProps): React.ReactElement {
63
+ const {
64
+ variant,
65
+ items,
66
+ activeId,
67
+ onNavigate,
68
+ sound = true,
69
+ ariaLabel,
70
+ className,
71
+ } = props
72
+ const tree = useTree({
73
+ variant,
74
+ items,
75
+ activeId,
76
+ onNavigate,
77
+ sound,
78
+ collapsed: variant === "sidebar" ? (props.collapsed ?? false) : undefined,
79
+ onMove: variant === "files" ? props.onMove : undefined,
80
+ onRename: variant === "files" ? props.onRename : undefined,
81
+ })
82
+ return (
83
+ <TreeView
84
+ ariaLabel={ariaLabel}
85
+ className={className}
86
+ renderTrailing={variant === "files" ? props.renderTrailing : undefined}
87
+ tree={tree}
88
+ />
89
+ )
90
+ }
91
+
92
+ interface TreeViewProps {
93
+ tree: TreeController
94
+ ariaLabel?: string
95
+ className?: string
96
+ renderTrailing?: (node: TreeNode) => React.ReactNode
97
+ }
98
+
99
+ /** Rendering only — every piece of state and behaviour lives on `tree`. */
100
+ function TreeView({
101
+ tree,
102
+ ariaLabel,
103
+ className,
104
+ renderTrailing,
105
+ }: TreeViewProps): React.ReactElement {
106
+ const { variant, items, sectioned, collapsed } = tree
107
+ const rootRef = React.useRef<HTMLUListElement>(null)
108
+ const reduce = useReducedMotion()
109
+
110
+ // The rail animates between two px widths, so the expanded one has to be
111
+ // measured off the container. Watched rather than read once: the rail is
112
+ // as wide as whatever holds it, which can change on resize.
113
+ //
114
+ // A container with no width of its own sizes to the list — so it shrinks
115
+ // around the 44px rail once collapsed and follows the list mid-tween. Both
116
+ // would feed the list's own width back in as its target and pin it. So:
117
+ // no observing while collapsed (the last expanded width stands), and each
118
+ // sample is taken with the list's inline width cleared, so the classes'
119
+ // resting width — not the tween — is what the container is holding.
120
+ const railRef = React.useRef<HTMLElement>(null)
121
+ const [railWidth, setRailWidth] = React.useState<number | null>(null)
122
+ React.useLayoutEffect(() => {
123
+ const container = railRef.current
124
+ const list = rootRef.current
125
+ if (variant !== "sidebar" || collapsed || !container || !list) return
126
+ const measure = () => {
127
+ const inline = list.style.width
128
+ list.style.width = ""
129
+ const width = container.clientWidth
130
+ list.style.width = inline
131
+ setRailWidth(width)
132
+ }
133
+ measure()
134
+ const observer = new ResizeObserver(measure)
135
+ observer.observe(container)
136
+ return () => observer.disconnect()
137
+ }, [variant, collapsed])
138
+
139
+ // Until the measurement lands the className carries the width, so the
140
+ // rail is never wrong — just not animated on its very first frame.
141
+ const railTarget =
142
+ variant === "sidebar" && railWidth !== null
143
+ ? { width: collapsed ? RAIL_COLLAPSED_WIDTH : railWidth }
144
+ : undefined
145
+
146
+ // Roving over the DOM rather than a parallel model: collapsed branches
147
+ // unmount, so a row query is exactly the visible set in order.
148
+ function handleKeyDown(event: React.KeyboardEvent) {
149
+ const row = (event.target as HTMLElement).closest<HTMLElement>(
150
+ '[data-slot="tree-row"]'
151
+ )
152
+ const root = rootRef.current
153
+ if (!row || !root) return
154
+ const rows = Array.from(
155
+ root.querySelectorAll<HTMLElement>('[data-slot="tree-row"]')
156
+ )
157
+ const index = rows.indexOf(row)
158
+ if (index < 0) return
159
+ const id = row.getAttribute("data-id")
160
+ const branch = row.hasAttribute("data-branch")
161
+ const open = row.hasAttribute("data-expanded")
162
+
163
+ const focusRow = (target: HTMLElement | undefined) => {
164
+ if (!target) return
165
+ event.preventDefault()
166
+ target.focus()
167
+ }
168
+
169
+ switch (event.key) {
170
+ case "ArrowDown":
171
+ focusRow(rows[index + 1])
172
+ break
173
+ case "ArrowUp":
174
+ focusRow(rows[index - 1])
175
+ break
176
+ case "Home":
177
+ focusRow(rows[0])
178
+ break
179
+ case "End":
180
+ focusRow(rows[rows.length - 1])
181
+ break
182
+ case "ArrowRight":
183
+ if (branch && !open && id) {
184
+ event.preventDefault()
185
+ tree.expand(id)
186
+ } else if (open) {
187
+ focusRow(rows[index + 1])
188
+ }
189
+ break
190
+ case "ArrowLeft":
191
+ if (open && id) {
192
+ event.preventDefault()
193
+ tree.collapse(id)
194
+ } else {
195
+ focusRow(
196
+ row
197
+ .closest("li")
198
+ ?.parentElement?.closest("li")
199
+ ?.querySelector<HTMLElement>('[data-slot="tree-row"]') ??
200
+ undefined
201
+ )
202
+ }
203
+ break
204
+ case "F2":
205
+ if (id && tree.canRename) {
206
+ event.preventDefault()
207
+ tree.startRename(id)
208
+ }
209
+ break
210
+ }
211
+ }
212
+
213
+ const context = React.useMemo<TreeContextValue>(
214
+ () => ({ tree, renderTrailing }),
215
+ [tree, renderTrailing]
216
+ )
217
+
218
+ const label = ariaLabel ?? DEFAULT_LABELS[variant]
219
+ let rendered = (
220
+ <motion.ul
221
+ aria-label={label}
222
+ className={cn(
223
+ "flex min-w-0 flex-col h-full",
224
+ // Resting widths, and the fallback until the rail is measured. The
225
+ // motion target below overrides these with an inline width.
226
+ collapsed ? "w-11 gap-y-2" : "w-full",
227
+ variant === "files" ? "gap-px" : sectioned ? "gap-3" : "gap-0.5"
228
+ )}
229
+ // Both endpoints must be px: motion cannot interpolate a number
230
+ // against "100%", and animating 44 -> "100%" pinned the inline width
231
+ // at 44px, so the rail collapsed once and never reopened. The
232
+ // expanded target is the rail's own measured width.
233
+ initial={false}
234
+ animate={railTarget}
235
+ transition={
236
+ reduce
237
+ ? { duration: 0 }
238
+ : { duration: TREE_COLLAPSE_DURATION, ease: TREE_EASE }
239
+ }
240
+ data-collapsed={collapsed ? "" : undefined}
241
+ data-slot="tree"
242
+ data-variant={variant}
243
+ onKeyDown={handleKeyDown}
244
+ ref={rootRef}
245
+ role="tree"
246
+ >
247
+ {items.map((node) => (
248
+ <TreeRow depth={0} key={node.id} node={node} />
249
+ ))}
250
+ </motion.ul>
251
+ )
252
+
253
+ // The collapsed rail's rows carry tooltips — one provider groups their
254
+ // open state and drops the hover delay across the rail. Mounted for the
255
+ // expanded rail too: toggling the wrapper with `collapsed` would remount
256
+ // every row and skip the label fold animation.
257
+ if (variant === "sidebar")
258
+ rendered = <TooltipProvider>{rendered}</TooltipProvider>
259
+
260
+ return (
261
+ <TreeContext.Provider value={context}>
262
+ {variant === "files" ? (
263
+ <div className={cn("min-w-0", className)} data-slot="tree-root">
264
+ {rendered}
265
+ </div>
266
+ ) : (
267
+ <nav
268
+ aria-label={label}
269
+ className={cn("min-w-0 flex h-full justify-center", className)}
270
+ data-slot="tree-root"
271
+ ref={railRef}
272
+ >
273
+ {rendered}
274
+ </nav>
275
+ )}
276
+ </TreeContext.Provider>
277
+ )
278
+ }
@@ -0,0 +1,212 @@
1
+ import type * as React from "react"
2
+
3
+ /** One node in the tree. Nesting depth carries meaning per variant — see
4
+ * `TreeProps["variant"]`. */
5
+ export interface TreeNode {
6
+ id: string
7
+ label: string
8
+ /** Leading icon. Required in practice for `sidebar` — it is all that
9
+ * remains of a row while the rail is collapsed. */
10
+ icon?: React.ReactNode
11
+ /** Renders an anchor. Without one the row is a button; `toc` leaves
12
+ * default to `#{id}` so they scroll to the matching heading. */
13
+ href?: string
14
+ target?: "_blank" | "_self" | "_parent" | "_top"
15
+ disabled?: boolean
16
+ /** Start expanded. Ancestors of `activeId` expand regardless. */
17
+ defaultOpen?: boolean
18
+ /** Trailing hint — a count, a "New" pill. */
19
+ badge?: React.ReactNode
20
+ /** Fires on selection, before the tree-level `onNavigate`. */
21
+ onSelect?: () => void
22
+ children?: TreeNode[]
23
+ }
24
+
25
+ export type TreeVariant = "navigation" | "toc" | "sidebar" | "files"
26
+
27
+ /** Where a dragged row lands relative to its drop target. */
28
+ export type TreeDropPosition = "before" | "after" | "inside"
29
+
30
+ interface TreeBaseProps {
31
+ items: TreeNode[]
32
+ /** Never set on the data form — `tree` selects the controller form. */
33
+ tree?: never
34
+ /** `id` of the current entry — matches any depth. Its ancestors expand. */
35
+ activeId?: string
36
+ /** Fires for every selection (rows and leaves alike), after the row's
37
+ * anchor default — wire your router's navigate here. */
38
+ onNavigate?: (node: TreeNode) => void
39
+ /** Expand/collapse cues. Silent until `bindSounds()`. */
40
+ sound?: boolean
41
+ ariaLabel?: string
42
+ className?: string
43
+ }
44
+
45
+ interface TreeReadonlyProps {
46
+ collapsed?: never
47
+ onMove?: never
48
+ onRename?: never
49
+ renderTrailing?: never
50
+ }
51
+
52
+ /** Drive the tree from a `useTree` controller instead of raw props. The
53
+ * controller carries the variant, the data and every handler; only the
54
+ * presentational props stay here. */
55
+ export interface TreeControllerProps {
56
+ tree: TreeController
57
+ items?: never
58
+ ariaLabel?: string
59
+ className?: string
60
+ /** Row actions revealed on hover/focus (`files` only — ignored by the
61
+ * other variants, which the controller form cannot type-gate). */
62
+ renderTrailing?: (node: TreeNode) => React.ReactNode
63
+ }
64
+
65
+ /** Either form: raw props, or a `useTree` controller via `tree`. */
66
+ export type TreeProps = TreeDataProps | TreeControllerProps
67
+
68
+ /** The four shapes a Tree takes. Editing props only exist on `files`;
69
+ * `collapsed` only on `sidebar` — the union is the contract. */
70
+ export type TreeDataProps = TreeBaseProps &
71
+ (
72
+ | ({
73
+ /** Nested app navigation. With 3 levels of nodes the top level
74
+ * becomes collapsible section names (styled as headings, not
75
+ * links); with 2 it renders plain link rows. */
76
+ variant: "navigation"
77
+ } & TreeReadonlyProps)
78
+ | ({
79
+ /** Table of contents: top-level nodes are routes, leaf nodes are
80
+ * in-page anchors (`#{id}` unless the node carries an `href`). */
81
+ variant: "toc"
82
+ } & TreeReadonlyProps)
83
+ | ({
84
+ /** Single-level icon rail. `collapsed` shrinks rows to their
85
+ * leading icon; nested children are ignored. */
86
+ variant: "sidebar"
87
+ collapsed?: boolean
88
+ } & Omit<TreeReadonlyProps, "collapsed">)
89
+ | {
90
+ /** File explorer: only leaves navigate, folders toggle. Compact
91
+ * spacing; rename, drag-and-drop and trailing actions enabled. */
92
+ variant: "files"
93
+ /** Enables drag-and-drop. Reorder `items` yourself: `parentId`
94
+ * `null` means the root list, `index` is the slot among the new
95
+ * siblings (computed on the list without the dragged node). */
96
+ onMove?: (id: string, parentId: string | null, index: number) => void
97
+ /** Enables inline rename (double-click or F2). */
98
+ onRename?: (id: string, label: string) => void
99
+ /** Row actions revealed on hover/focus — a menu, a delete icon. */
100
+ renderTrailing?: (node: TreeNode) => React.ReactNode
101
+ collapsed?: never
102
+ }
103
+ )
104
+
105
+ /** Options for `useTree`. Each of `items`, `activeId` and `collapsed` is
106
+ * controlled when passed and hook-owned when its `default*` twin is used
107
+ * instead. */
108
+ export interface UseTreeOptions {
109
+ /** Which shape the tree takes — gates rename/reorder (`files`) and the
110
+ * collapsible rail (`sidebar`). */
111
+ variant: TreeVariant
112
+ /** Controlled data. With it, `rename`/`move` only report the edit unless
113
+ * you also pass `onItemsChange`. */
114
+ items?: TreeNode[]
115
+ /** Hook-owned data — `rename` and `move` apply the edit themselves. */
116
+ defaultItems?: TreeNode[]
117
+ /** Next tree after a `rename`/`move`. Also enables self-applying edits
118
+ * on top of controlled `items`. */
119
+ onItemsChange?: (items: TreeNode[]) => void
120
+ /** Controlled selection. Ancestors of the active node expand. */
121
+ activeId?: string
122
+ defaultActiveId?: string
123
+ /** Fires for every selection (rows and leaves alike), after the node's
124
+ * own `onSelect` — wire your router's navigate here. */
125
+ onNavigate?: (node: TreeNode) => void
126
+ /** Start with these ids expanded instead of the `defaultOpen` set. */
127
+ defaultExpandedIds?: Iterable<string>
128
+ onExpandedChange?: (ids: string[]) => void
129
+ /** Controlled rail fold (`sidebar` only). */
130
+ collapsed?: boolean
131
+ defaultCollapsed?: boolean
132
+ onCollapsedChange?: (collapsed: boolean) => void
133
+ /** Enables rename (`files`). Not needed when the hook owns the data. */
134
+ onRename?: (id: string, label: string) => void
135
+ /** Enables drag-and-drop (`files`). `parentId` `null` means the root
136
+ * list, `index` is the slot among the new siblings (computed on the list
137
+ * without the moved node). Not needed when the hook owns the data. */
138
+ onMove?: (id: string, parentId: string | null, index: number) => void
139
+ /** Expand/collapse cues. Silent until `bindSounds()`. */
140
+ sound?: boolean
141
+ }
142
+
143
+ /** Everything the tree can do, callable from outside the component.
144
+ * Returned by `useTree` and accepted by `<Tree tree={…} />`. */
145
+ export interface TreeController {
146
+ variant: TreeVariant
147
+ items: TreeNode[]
148
+ /** `navigation` with 3 levels of nodes — depth 0 renders as sections. */
149
+ sectioned: boolean
150
+ /** Ids of the section headings, empty unless `sectioned`. Pass one to
151
+ * `expand`/`collapse`/`toggleExpanded` to work a whole section. */
152
+ sectionIds: string[]
153
+ sound: boolean
154
+ getNode: (id: string) => TreeNode | null
155
+
156
+ expandedIds: ReadonlySet<string>
157
+ isExpanded: (id: string) => boolean
158
+ expand: (id: string) => void
159
+ collapse: (id: string) => void
160
+ toggleExpanded: (id: string) => void
161
+ expandAll: () => void
162
+ collapseAll: () => void
163
+ /** Open every ancestor of `id`, leaving the rest of the tree alone. */
164
+ reveal: (id: string) => void
165
+
166
+ /** Rail folded to icons — always `false` outside `sidebar`. */
167
+ collapsed: boolean
168
+ setCollapsed: (collapsed: boolean) => void
169
+ toggleCollapsed: () => void
170
+
171
+ activeId?: string
172
+ /** Select a node: runs its `onSelect`, then `onNavigate`. Inert on
173
+ * disabled and unknown nodes. */
174
+ select: (id: string) => void
175
+
176
+ /** Whether rename is wired up at all (`files` + a handler or owned data). */
177
+ canRename: boolean
178
+ /** Rename target id, `null` while idle. */
179
+ renamingId: string | null
180
+ startRename: (id: string) => void
181
+ cancelRename: () => void
182
+ /** Commit a rename and leave rename mode. Blank or unchanged labels are
183
+ * dropped. */
184
+ rename: (id: string, label: string) => void
185
+
186
+ /** Whether reorder is wired up at all. */
187
+ canMove: boolean
188
+ move: (id: string, parentId: string | null, index: number) => void
189
+
190
+ dnd: TreeDndContextValue
191
+ }
192
+
193
+ /** @internal What a row needs from the root, threaded through context so
194
+ * the recursive rows stay prop-light. */
195
+ export interface TreeContextValue {
196
+ tree: TreeController
197
+ renderTrailing?: (node: TreeNode) => React.ReactNode
198
+ }
199
+
200
+ /** @internal Drag state shared by every row (all `null`/no-op outside
201
+ * `files`). */
202
+ export interface TreeDndContextValue {
203
+ enabled: boolean
204
+ draggedId: string | null
205
+ /** Row the pointer is over and where the drop would land. */
206
+ dropTarget: { id: string; position: TreeDropPosition } | null
207
+ onRowDragStart: (event: React.DragEvent, id: string) => void
208
+ onRowDragOver: (event: React.DragEvent, node: TreeNode) => void
209
+ onRowDragLeave: (event: React.DragEvent) => void
210
+ onRowDrop: (event: React.DragEvent, node: TreeNode) => void
211
+ onRowDragEnd: () => void
212
+ }
@@ -0,0 +1,148 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import type {
6
+ TreeDndContextValue,
7
+ TreeDropPosition,
8
+ TreeNode,
9
+ UseTreeOptions,
10
+ } from "./type"
11
+ import { containsNode, findNode, locateNode } from "./utils"
12
+
13
+ /** How far into a folder row the pointer must sit (from either edge) to
14
+ * read as "inside" rather than "before"/"after". */
15
+ const EDGE_RATIO = 0.25
16
+
17
+ /** Owns drag state for the `files` variant: which row is in flight, which
18
+ * row the pointer rests on and where the drop would land. Everything is
19
+ * component state — `dataTransfer` is set for interop but never read, so
20
+ * the flow also works where the store is unavailable (tests, some
21
+ * webviews). Without `onMove` every handler is inert. */
22
+ export function useTreeDnd(
23
+ items: readonly TreeNode[],
24
+ onMove: UseTreeOptions["onMove"]
25
+ ): TreeDndContextValue {
26
+ const enabled = onMove !== undefined
27
+ const [draggedId, setDraggedId] = React.useState<string | null>(null)
28
+ const [dropTarget, setDropTarget] = React.useState<{
29
+ id: string
30
+ position: TreeDropPosition
31
+ } | null>(null)
32
+
33
+ const reset = React.useCallback(() => {
34
+ setDraggedId(null)
35
+ setDropTarget(null)
36
+ }, [])
37
+
38
+ const onRowDragStart = React.useCallback(
39
+ (event: React.DragEvent, id: string) => {
40
+ if (!enabled) return
41
+ event.stopPropagation()
42
+ setDraggedId(id)
43
+ if (event.dataTransfer) {
44
+ event.dataTransfer.effectAllowed = "move"
45
+ event.dataTransfer.setData("text/plain", id)
46
+ }
47
+ },
48
+ [enabled]
49
+ )
50
+
51
+ const onRowDragOver = React.useCallback(
52
+ (event: React.DragEvent, node: TreeNode) => {
53
+ if (!enabled || draggedId === null) return
54
+ // A node never drops onto itself or into its own subtree.
55
+ if (containsNode(items, draggedId, node.id)) return
56
+ event.preventDefault()
57
+ event.stopPropagation()
58
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move"
59
+
60
+ const rect = event.currentTarget.getBoundingClientRect()
61
+ const ratio =
62
+ rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5
63
+ // Any node carrying a children array is a folder — an empty one
64
+ // still accepts drops "inside".
65
+ const folder = node.children !== undefined
66
+ const position: TreeDropPosition = folder
67
+ ? ratio < EDGE_RATIO
68
+ ? "before"
69
+ : ratio > 1 - EDGE_RATIO
70
+ ? "after"
71
+ : "inside"
72
+ : ratio < 0.5
73
+ ? "before"
74
+ : "after"
75
+ setDropTarget((prev) =>
76
+ prev?.id === node.id && prev.position === position
77
+ ? prev
78
+ : { id: node.id, position }
79
+ )
80
+ },
81
+ [enabled, draggedId, items]
82
+ )
83
+
84
+ const onRowDragLeave = React.useCallback((event: React.DragEvent) => {
85
+ // Leaving for a child of the same row still counts as hovering it.
86
+ const next = event.relatedTarget as Node | null
87
+ if (next && event.currentTarget.contains(next)) return
88
+ setDropTarget(null)
89
+ }, [])
90
+
91
+ const onRowDrop = React.useCallback(
92
+ (event: React.DragEvent, node: TreeNode) => {
93
+ if (!enabled) return
94
+ event.preventDefault()
95
+ event.stopPropagation()
96
+ const target = dropTarget
97
+ if (draggedId === null || target === null || target.id !== node.id) {
98
+ reset()
99
+ return
100
+ }
101
+
102
+ if (target.position === "inside") {
103
+ // Append at the end of the folder, sized without the dragged node
104
+ // (it may already live there).
105
+ const children = node.children ?? []
106
+ const index = children.filter((child) => child.id !== draggedId).length
107
+ onMove?.(draggedId, node.id, index)
108
+ } else {
109
+ const location = locateNode(items, node.id)
110
+ if (location) {
111
+ const siblings = (
112
+ location.parentId === null
113
+ ? items
114
+ : (findNode(items, location.parentId)?.children ?? [])
115
+ ).filter((sibling) => sibling.id !== draggedId)
116
+ const at = siblings.findIndex((sibling) => sibling.id === node.id)
117
+ const index = target.position === "after" ? at + 1 : at
118
+ onMove?.(draggedId, location.parentId, index)
119
+ }
120
+ }
121
+ reset()
122
+ },
123
+ [enabled, draggedId, dropTarget, items, onMove, reset]
124
+ )
125
+
126
+ return React.useMemo(
127
+ () => ({
128
+ enabled,
129
+ draggedId,
130
+ dropTarget,
131
+ onRowDragStart,
132
+ onRowDragOver,
133
+ onRowDragLeave,
134
+ onRowDrop,
135
+ onRowDragEnd: reset,
136
+ }),
137
+ [
138
+ enabled,
139
+ draggedId,
140
+ dropTarget,
141
+ onRowDragStart,
142
+ onRowDragOver,
143
+ onRowDragLeave,
144
+ onRowDrop,
145
+ reset,
146
+ ]
147
+ )
148
+ }