@exegia/corpora-ui 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +83 -1
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +22 -31
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +5 -0
- package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
- package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
- package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
- package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
- package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
- package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
- package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +35 -0
- package/dist-lib/components/composed/tree/index.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
- package/dist-lib/components/composed/tree/tree.d.ts +19 -0
- package/dist-lib/components/composed/tree/type.d.ts +192 -0
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
- package/dist-lib/components/composed/tree/utils.d.ts +28 -0
- package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
- package/dist-lib/components/user-avatar/component.d.ts +10 -0
- package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
- package/dist-lib/components/user-avatar/index.d.ts +13 -0
- package/dist-lib/components/user-avatar/type.d.ts +26 -0
- package/dist-lib/components/user-avatar/utils.d.ts +2 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.js +2141 -1139
- package/dist-lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/beste/piece/browser-frame.tsx +9 -6
- package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
- package/src/components/blocks/nav/sidebar/type.ts +105 -1
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
- package/src/components/blocks/nav/sidebar/utils.ts +28 -0
- package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
- package/src/components/blocks/scaffold/constants.ts +7 -0
- package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
- package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
- package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
- package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
- package/src/components/blocks/scaffold/type.ts +28 -0
- package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
- package/src/components/blocks/scaffold/utils.ts +19 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
- package/src/components/composed/tree/constants.ts +60 -0
- package/src/components/composed/tree/index.ts +13 -0
- package/src/components/composed/tree/tree-context.ts +13 -0
- package/src/components/composed/tree/tree-node.tsx +451 -0
- package/src/components/composed/tree/tree.tsx +278 -0
- package/src/components/composed/tree/type.ts +212 -0
- package/src/components/composed/tree/use-tree-dnd.ts +148 -0
- package/src/components/composed/tree/use-tree.ts +306 -0
- package/src/components/composed/tree/utils.ts +172 -0
- package/src/components/composed/user-avatar.tsx +4 -1
- package/src/components/docs/component-preview.tsx +11 -11
- package/src/components/user-avatar/audio-wave.tsx +22 -0
- package/src/components/user-avatar/component.tsx +53 -0
- package/src/components/user-avatar/fallback.tsx +26 -0
- package/src/components/user-avatar/index.ts +15 -0
- package/src/components/user-avatar/type.ts +35 -0
- package/src/components/user-avatar/utils.ts +8 -0
- package/src/index.css +211 -153
- package/src/index.ts +1 -0
|
@@ -0,0 +1,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
|
+
}
|