@exegia/corpora-ui 0.10.0 → 0.12.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/agents/__tests__/ai-sidebar.test.d.ts +1 -0
- package/dist-lib/components/agents/ai-sidebar.d.ts +2 -0
- package/dist-lib/components/agents/index.d.ts +12 -0
- package/dist-lib/components/agents/marquee-label.d.ts +4 -0
- package/dist-lib/components/agents/sidebar-icon.d.ts +2 -0
- package/dist-lib/components/agents/sidebar-row.d.ts +2 -0
- package/dist-lib/components/agents/type.d.ts +83 -0
- package/dist-lib/components/agents/use-ai-sidebar.d.ts +31 -0
- package/dist-lib/components/agents/utils.d.ts +16 -0
- package/dist-lib/components/beste/piece/browser-frame.d.ts +2 -0
- package/dist-lib/components/beste/piece/type.d.ts +9 -0
- package/dist-lib/components/beste/piece/utils.d.ts +3 -0
- package/dist-lib/components/blocks/auth/signup-block.d.ts +5 -5
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +3 -56
- package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +3 -0
- package/dist-lib/components/blocks/nav/types.d.ts +60 -0
- package/dist-lib/components/blocks/nav/utils.d.ts +1 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +6 -1
- package/dist-lib/components/blocks/shell/index.d.ts +1 -0
- package/dist-lib/components/blocks/shell/shell-layout.d.ts +3 -0
- package/dist-lib/components/blocks/shell/type.d.ts +26 -0
- package/dist-lib/components/blocks/shell/utils.d.ts +1 -0
- package/dist-lib/components/composed/social-providers.d.ts +9 -1
- package/dist-lib/components/docs/demo-controls.d.ts +4 -4
- package/dist-lib/components/motion/animated-sidebar.d.ts +12 -1
- package/dist-lib/components/motion/popover-morph.d.ts +35 -0
- package/dist-lib/components/motion/popover-position.d.ts +15 -0
- package/dist-lib/components/ui/badge.d.ts +7 -0
- package/dist-lib/components/ui/select.d.ts +30 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.js +644 -524
- package/dist-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/agents/__tests__/ai-sidebar.test.tsx +93 -0
- package/src/components/agents/ai-sidebar.tsx +118 -0
- package/src/components/agents/index.ts +14 -0
- package/src/components/agents/marquee-label.tsx +59 -0
- package/src/components/agents/sidebar-icon.tsx +14 -0
- package/src/components/agents/sidebar-row.tsx +309 -0
- package/src/components/agents/type.ts +95 -0
- package/src/components/agents/use-ai-sidebar.ts +386 -0
- package/src/components/agents/utils.ts +142 -0
- package/src/components/beste/piece/browser-frame.tsx +55 -0
- package/src/components/beste/piece/type.ts +11 -0
- package/src/components/beste/piece/utils.ts +21 -0
- package/src/components/blocks/auth/linked-accounts-block.tsx +55 -57
- package/src/components/blocks/auth/passkey-manager-block.tsx +88 -81
- package/src/components/blocks/auth/signup-block.tsx +6 -6
- package/src/components/blocks/nav/sidebar-block.tsx +25 -155
- package/src/components/blocks/nav/sidebar-nav-row.tsx +79 -0
- package/src/components/blocks/nav/types.ts +65 -0
- package/src/components/blocks/nav/utils.ts +4 -0
- package/src/components/blocks/profile/profile-card-block.tsx +17 -6
- package/src/components/blocks/shell/index.ts +1 -0
- package/src/components/blocks/shell/shell-layout.tsx +115 -0
- package/src/components/blocks/shell/type.ts +50 -0
- package/src/components/blocks/shell/utils.ts +2 -0
- package/src/components/composed/social-providers.tsx +83 -27
- package/src/components/docs/component-preview.tsx +13 -12
- package/src/components/docs/demo-controls.tsx +37 -21
- package/src/components/motion/animated-sidebar.tsx +392 -364
- package/src/components/motion/popover-morph.tsx +292 -0
- package/src/components/motion/popover-position.ts +86 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/select.tsx +257 -0
- package/src/index.css +18 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import userEvent from "@testing-library/user-event"
|
|
4
|
+
|
|
5
|
+
import { AISidebar } from "../ai-sidebar"
|
|
6
|
+
import type { SidebarResource } from "../type.ts"
|
|
7
|
+
|
|
8
|
+
const RESOURCES: SidebarResource[] = [
|
|
9
|
+
{
|
|
10
|
+
id: "corpora",
|
|
11
|
+
label: "Corpora",
|
|
12
|
+
kind: "project",
|
|
13
|
+
children: [
|
|
14
|
+
{ id: "manuscripts", label: "Manuscripts", kind: "folder" },
|
|
15
|
+
{ id: "readme", label: "Field notes", kind: "file" },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{ id: "reading-list", label: "Reading list", kind: "bookmark" },
|
|
19
|
+
{ id: "empty-folder", label: "Empty folder", kind: "folder" },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const chevronOf = (row: HTMLElement) =>
|
|
23
|
+
row.querySelector("svg.lucide-chevron-right")
|
|
24
|
+
|
|
25
|
+
describe("AISidebar", () => {
|
|
26
|
+
test("renders the tree and marks the active row", () => {
|
|
27
|
+
render(
|
|
28
|
+
<AISidebar defaultItems={RESOURCES} defaultActiveId="reading-list" />
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
expect(screen.getByRole("tree", { name: "Resources" })).toBeDefined()
|
|
32
|
+
const active = screen.getByRole("treeitem", { name: /Reading list/ })
|
|
33
|
+
expect(active.getAttribute("aria-selected")).toBe("true")
|
|
34
|
+
expect(active.className).toContain("bg-primary/10")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("shows a trailing chevron only on root rows with children", () => {
|
|
38
|
+
render(
|
|
39
|
+
<AISidebar defaultItems={RESOURCES} defaultExpandedIds={["corpora"]} />
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const root = screen.getByRole("treeitem", { name: /Corpora/ })
|
|
43
|
+
const chevron = chevronOf(root)
|
|
44
|
+
expect(chevron).not.toBeNull()
|
|
45
|
+
// Expanded root rows rotate the chevron to point down.
|
|
46
|
+
expect(chevron?.getAttribute("class")).toContain("rotate-90")
|
|
47
|
+
|
|
48
|
+
// Childless root folder and nested folder both render without one.
|
|
49
|
+
expect(
|
|
50
|
+
chevronOf(screen.getByRole("treeitem", { name: /Empty folder/ }))
|
|
51
|
+
).toBeNull()
|
|
52
|
+
expect(
|
|
53
|
+
chevronOf(screen.getByRole("treeitem", { name: /Manuscripts/ }))
|
|
54
|
+
).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("chevron sits flat while the root row is collapsed", () => {
|
|
58
|
+
render(<AISidebar defaultItems={RESOURCES} />)
|
|
59
|
+
|
|
60
|
+
const chevron = chevronOf(
|
|
61
|
+
screen.getByRole("treeitem", { name: /Corpora/ })
|
|
62
|
+
)
|
|
63
|
+
expect(chevron).not.toBeNull()
|
|
64
|
+
expect(chevron?.getAttribute("class")).not.toContain("rotate-90")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("renderActionsTrigger replaces the default actions button", async () => {
|
|
68
|
+
const user = userEvent.setup()
|
|
69
|
+
const selected: string[] = []
|
|
70
|
+
render(
|
|
71
|
+
<AISidebar
|
|
72
|
+
defaultItems={RESOURCES}
|
|
73
|
+
onActiveChange={(id) => selected.push(id)}
|
|
74
|
+
renderActionsTrigger={(item) => (
|
|
75
|
+
<button type="button" aria-label={`Custom actions ${item.label}`}>
|
|
76
|
+
*
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expect(
|
|
83
|
+
screen.queryByRole("button", { name: "Actions for Reading list" })
|
|
84
|
+
).toBeNull()
|
|
85
|
+
|
|
86
|
+
// Opening the menu from the custom trigger must not select the row.
|
|
87
|
+
await user.click(
|
|
88
|
+
screen.getByRole("button", { name: "Custom actions Reading list" })
|
|
89
|
+
)
|
|
90
|
+
expect(await screen.findByText("Rename")).toBeDefined()
|
|
91
|
+
expect(selected).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence } from "motion/react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import type { AISidebarProps } from "@/components/agents/type.ts"
|
|
6
|
+
import { useAISidebar } from "@/components/agents/use-ai-sidebar.ts"
|
|
7
|
+
import { ResourceRow } from "@/components/agents/sidebar-row.tsx"
|
|
8
|
+
|
|
9
|
+
export function AISidebar({
|
|
10
|
+
renderIcon,
|
|
11
|
+
renderMenu,
|
|
12
|
+
renderActionsTrigger,
|
|
13
|
+
ariaLabel = "Resources",
|
|
14
|
+
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);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<div
|
|
49
|
+
role="tree"
|
|
50
|
+
aria-label={ariaLabel}
|
|
51
|
+
aria-multiselectable="false"
|
|
52
|
+
onDragOver={handleRootDragOver}
|
|
53
|
+
onDrop={handleDrop}
|
|
54
|
+
onMouseLeave={clearHover}
|
|
55
|
+
className={cn(
|
|
56
|
+
"relative flex min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden",
|
|
57
|
+
draggingId && "select-none pb-9",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<AnimatePresence initial={false}>
|
|
62
|
+
{flat.map((row) => (
|
|
63
|
+
<ResourceRow
|
|
64
|
+
key={row.item.id}
|
|
65
|
+
row={row}
|
|
66
|
+
active={selectedId === row.item.id}
|
|
67
|
+
expanded={expandedIds.has(row.item.id)}
|
|
68
|
+
focused={focusedId === row.item.id}
|
|
69
|
+
draggingId={draggingId}
|
|
70
|
+
dropTarget={dropTarget}
|
|
71
|
+
menuOpen={menuOpenId === row.item.id}
|
|
72
|
+
renaming={renamingId === row.item.id}
|
|
73
|
+
hoverActive={hoveredId !== null && !draggingId}
|
|
74
|
+
hoverPill={
|
|
75
|
+
hoveredId === row.item.id &&
|
|
76
|
+
!draggingId &&
|
|
77
|
+
!row.item.disabled
|
|
78
|
+
}
|
|
79
|
+
hoverLayoutId={hoverLayoutId}
|
|
80
|
+
onHoverChange={(hovered) =>
|
|
81
|
+
handleRowHover(row.item.id, hovered)
|
|
82
|
+
}
|
|
83
|
+
onFocus={() => setFocusedId(row.item.id)}
|
|
84
|
+
onSelect={() => select(row.item.id)}
|
|
85
|
+
onToggle={() => toggle(row.item.id)}
|
|
86
|
+
onKeyDown={(event) => handleKeyDown(event, row)}
|
|
87
|
+
onRenameStart={() => setRenamingId(row.item.id)}
|
|
88
|
+
onRenameCancel={() => setRenamingId(null)}
|
|
89
|
+
onRenameCommit={(label) => commitRename(row, label)}
|
|
90
|
+
onMenuOpenChange={(open) => handleMenuOpenChange(row, open)}
|
|
91
|
+
onDragStart={handleRowDragStart}
|
|
92
|
+
onDragEnd={handleRowDragEnd}
|
|
93
|
+
onDragOver={handleRowDragOver}
|
|
94
|
+
onDrop={handleDrop}
|
|
95
|
+
renderIcon={renderIcon}
|
|
96
|
+
renderMenu={renderMenu}
|
|
97
|
+
renderActionsTrigger={renderActionsTrigger}
|
|
98
|
+
setRef={(node) => setRowRef(row.item.id, node)}
|
|
99
|
+
/>
|
|
100
|
+
))}
|
|
101
|
+
</AnimatePresence>
|
|
102
|
+
|
|
103
|
+
{draggingId ? (
|
|
104
|
+
<div
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
data-active={dropTarget?.id === null || undefined}
|
|
107
|
+
className="absolute inset-x-1 bottom-0 flex h-8 items-center justify-center rounded-md border border-dashed border-border text-[10px] text-muted-foreground data-[active=true]:border-primary/50 data-[active=true]:bg-primary/10 data-[active=true]:text-foreground"
|
|
108
|
+
>
|
|
109
|
+
Move to top level
|
|
110
|
+
</div>
|
|
111
|
+
) : null}
|
|
112
|
+
</div>
|
|
113
|
+
<span className="sr-only" aria-live="polite">
|
|
114
|
+
{announcement}
|
|
115
|
+
</span>
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
import { AISidebar } from "./ai-sidebar"
|
|
3
|
+
import { MarqueeLabel } from "./marquee-label.tsx"
|
|
4
|
+
import { ResourceRow } from "./sidebar-row.tsx"
|
|
5
|
+
import { SidebarIcon } from "./sidebar-icon.tsx"
|
|
6
|
+
export { useAISidebar, type UseAISidebarOptions } from "./use-ai-sidebar.ts"
|
|
7
|
+
export type * from "./type.ts"
|
|
8
|
+
|
|
9
|
+
export const Sidebar = {
|
|
10
|
+
Wrapper: AISidebar,
|
|
11
|
+
MarqueeLabel,
|
|
12
|
+
Row: ResourceRow,
|
|
13
|
+
Icon: SidebarIcon
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { motion, useReducedMotion } from "motion/react"
|
|
2
|
+
import { useEffect, useRef, useState } from "react"
|
|
3
|
+
import { ROW_REVEAL } from "@/components/agents/utils.ts"
|
|
4
|
+
|
|
5
|
+
export function MarqueeLabel({
|
|
6
|
+
active,
|
|
7
|
+
children,
|
|
8
|
+
}: {
|
|
9
|
+
active: boolean
|
|
10
|
+
children: string
|
|
11
|
+
}) {
|
|
12
|
+
const reduce = useReducedMotion() ?? false
|
|
13
|
+
const viewportRef = useRef<HTMLSpanElement>(null)
|
|
14
|
+
const labelRef = useRef<HTMLSpanElement>(null)
|
|
15
|
+
const [distance, setDistance] = useState(0)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const measure = () => {
|
|
19
|
+
const viewport = viewportRef.current
|
|
20
|
+
const label = labelRef.current
|
|
21
|
+
if (!viewport || !label) return
|
|
22
|
+
setDistance(
|
|
23
|
+
label.scrollWidth > viewport.clientWidth ? label.scrollWidth + 24 : 0
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
measure()
|
|
27
|
+
const observer = new ResizeObserver(measure)
|
|
28
|
+
if (viewportRef.current) observer.observe(viewportRef.current)
|
|
29
|
+
if (labelRef.current) observer.observe(labelRef.current)
|
|
30
|
+
return () => observer.disconnect()
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
const running = active && distance > 0 && !reduce
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
ref={viewportRef}
|
|
38
|
+
className="relative block min-w-0 flex-1 overflow-hidden"
|
|
39
|
+
>
|
|
40
|
+
<motion.span
|
|
41
|
+
className="flex w-max items-center gap-6 whitespace-nowrap"
|
|
42
|
+
animate={{ x: running ? [0, -distance] : 0 }}
|
|
43
|
+
transition={
|
|
44
|
+
running
|
|
45
|
+
? {
|
|
46
|
+
duration: Math.max(2.4, distance / 34),
|
|
47
|
+
ease: "linear",
|
|
48
|
+
repeat: Number.POSITIVE_INFINITY,
|
|
49
|
+
repeatDelay: 2,
|
|
50
|
+
}
|
|
51
|
+
: ROW_REVEAL
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
<span ref={labelRef}>{children}</span>
|
|
55
|
+
{running ? <span aria-hidden="true">{children}</span> : null}
|
|
56
|
+
</motion.span>
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SidebarResource } from "@/components/agents/type.ts"
|
|
2
|
+
import { Bookmark, FileText, Folder, FolderOpen } from "lucide-react"
|
|
3
|
+
|
|
4
|
+
export function SidebarIcon(item: SidebarResource, expanded: boolean) {
|
|
5
|
+
const Icon =
|
|
6
|
+
item.kind === "folder" || item.kind === "project"
|
|
7
|
+
? expanded
|
|
8
|
+
? FolderOpen
|
|
9
|
+
: Folder
|
|
10
|
+
: item.kind === "bookmark"
|
|
11
|
+
? Bookmark
|
|
12
|
+
: FileText
|
|
13
|
+
return <Icon className="size-4" />
|
|
14
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
} from "react"
|
|
8
|
+
import type { ResourceRowProps } from "@/components/agents/type.ts"
|
|
9
|
+
import {
|
|
10
|
+
AnimatePresence,
|
|
11
|
+
motion,
|
|
12
|
+
useReducedMotion,
|
|
13
|
+
type Variants,
|
|
14
|
+
} from "motion/react"
|
|
15
|
+
import { ChevronRight, MoreHorizontal, Pencil } from "lucide-react"
|
|
16
|
+
import { SPRING_LAYOUT } from "@/lib/ease.ts"
|
|
17
|
+
import { cn } from "@/lib/utils.ts"
|
|
18
|
+
import { canContain } from "@/components/agents/utils.ts"
|
|
19
|
+
import { SidebarIcon } from "@/components/agents/sidebar-icon.tsx"
|
|
20
|
+
import { MarqueeLabel } from "@/components/agents/marquee-label.tsx"
|
|
21
|
+
import {
|
|
22
|
+
MorphPopover,
|
|
23
|
+
MorphPopoverContent,
|
|
24
|
+
MorphPopoverTrigger,
|
|
25
|
+
} from "@/components/motion/popover-morph.tsx"
|
|
26
|
+
import { Button } from "@/components/ui/button";
|
|
27
|
+
|
|
28
|
+
// Same choreography as SharedLayoutBg: the wrapper only fades/blurs out when
|
|
29
|
+
// the pointer leaves the whole tree; a row-to-row move exits instantly so the
|
|
30
|
+
// layoutId pill slides instead of crossfading.
|
|
31
|
+
const pillVariants: Variants = {
|
|
32
|
+
initial: { opacity: 0, filter: "blur(6px)" },
|
|
33
|
+
animate: { opacity: 1, filter: "blur(0px)" },
|
|
34
|
+
exit: (hoverActive: boolean) =>
|
|
35
|
+
!hoverActive ? { opacity: 0, filter: "blur(6px)" } : {},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const reducedPillVariants: Variants = {
|
|
39
|
+
initial: { opacity: 0 },
|
|
40
|
+
animate: { opacity: 1 },
|
|
41
|
+
exit: (hoverActive: boolean) => (!hoverActive ? { opacity: 0 } : {}),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ResourceRow({
|
|
45
|
+
row,
|
|
46
|
+
active,
|
|
47
|
+
expanded,
|
|
48
|
+
focused,
|
|
49
|
+
draggingId,
|
|
50
|
+
dropTarget,
|
|
51
|
+
menuOpen,
|
|
52
|
+
renaming,
|
|
53
|
+
onDragEnd,
|
|
54
|
+
onDragOver,
|
|
55
|
+
onDragStart,
|
|
56
|
+
onDrop,
|
|
57
|
+
onFocus,
|
|
58
|
+
onKeyDown,
|
|
59
|
+
onMenuOpenChange,
|
|
60
|
+
onRenameCancel,
|
|
61
|
+
onRenameCommit,
|
|
62
|
+
onRenameStart,
|
|
63
|
+
onSelect,
|
|
64
|
+
onToggle,
|
|
65
|
+
hoverActive = false,
|
|
66
|
+
hoverPill = false,
|
|
67
|
+
hoverLayoutId,
|
|
68
|
+
onHoverChange,
|
|
69
|
+
renderIcon,
|
|
70
|
+
renderMenu,
|
|
71
|
+
renderActionsTrigger,
|
|
72
|
+
setRef,
|
|
73
|
+
}: ResourceRowProps) {
|
|
74
|
+
const reduce = useReducedMotion() ?? false
|
|
75
|
+
const [hovered, setHovered] = useState(false)
|
|
76
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
77
|
+
const skipRenameBlurRef = useRef(false)
|
|
78
|
+
const draggedRef = useRef(false)
|
|
79
|
+
const [draft, setDraft] = useState(row.item.label)
|
|
80
|
+
const acceptsChildren = canContain(row.item)
|
|
81
|
+
const isDragging = draggingId === row.item.id
|
|
82
|
+
const dropPosition =
|
|
83
|
+
dropTarget?.id === row.item.id ? dropTarget.position : null
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!renaming) return
|
|
87
|
+
skipRenameBlurRef.current = false
|
|
88
|
+
// setDraft(row.item.label);
|
|
89
|
+
requestAnimationFrame(() => {
|
|
90
|
+
inputRef.current?.focus()
|
|
91
|
+
inputRef.current?.select()
|
|
92
|
+
})
|
|
93
|
+
}, [renaming, row.item.label])
|
|
94
|
+
|
|
95
|
+
const menu = renderMenu?.(row.item, {
|
|
96
|
+
close: () => onMenuOpenChange(false),
|
|
97
|
+
rename: () => {
|
|
98
|
+
onMenuOpenChange(false)
|
|
99
|
+
onRenameStart()
|
|
100
|
+
},
|
|
101
|
+
}) ?? (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => {
|
|
105
|
+
onMenuOpenChange(false)
|
|
106
|
+
onRenameStart()
|
|
107
|
+
}}
|
|
108
|
+
className="flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs text-foreground transition-colors outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
109
|
+
>
|
|
110
|
+
<Pencil aria-hidden="true" className="size-3.5" />
|
|
111
|
+
Rename
|
|
112
|
+
</button>
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<motion.div
|
|
117
|
+
ref={setRef}
|
|
118
|
+
layout="position"
|
|
119
|
+
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
|
|
120
|
+
role="treeitem"
|
|
121
|
+
aria-level={row.depth + 1}
|
|
122
|
+
aria-selected={acceptsChildren ? undefined : active}
|
|
123
|
+
aria-expanded={acceptsChildren ? expanded : undefined}
|
|
124
|
+
aria-disabled={row.item.disabled || undefined}
|
|
125
|
+
tabIndex={focused ? 0 : -1}
|
|
126
|
+
draggable={!row.item.disabled && !renaming}
|
|
127
|
+
data-menu-open={menuOpen || undefined}
|
|
128
|
+
data-drop={dropPosition ?? undefined}
|
|
129
|
+
data-dragging={isDragging || undefined}
|
|
130
|
+
onFocus={onFocus}
|
|
131
|
+
onKeyDown={onKeyDown}
|
|
132
|
+
onClick={(event) => {
|
|
133
|
+
if (
|
|
134
|
+
event.defaultPrevented ||
|
|
135
|
+
draggedRef.current ||
|
|
136
|
+
renaming ||
|
|
137
|
+
row.item.disabled
|
|
138
|
+
)
|
|
139
|
+
return
|
|
140
|
+
if (acceptsChildren) onToggle()
|
|
141
|
+
else onSelect()
|
|
142
|
+
}}
|
|
143
|
+
onDoubleClick={(event) => {
|
|
144
|
+
if (acceptsChildren || row.item.disabled) return
|
|
145
|
+
event.preventDefault()
|
|
146
|
+
onRenameStart()
|
|
147
|
+
}}
|
|
148
|
+
onMouseEnter={() => {
|
|
149
|
+
setHovered(true)
|
|
150
|
+
onHoverChange?.(true)
|
|
151
|
+
}}
|
|
152
|
+
onMouseLeave={() => {
|
|
153
|
+
setHovered(false)
|
|
154
|
+
onHoverChange?.(false)
|
|
155
|
+
}}
|
|
156
|
+
onDragStartCapture={(event) => {
|
|
157
|
+
draggedRef.current = true
|
|
158
|
+
onDragStart(event, row.item.id)
|
|
159
|
+
}}
|
|
160
|
+
onDragEndCapture={() => {
|
|
161
|
+
onDragEnd()
|
|
162
|
+
requestAnimationFrame(() => {
|
|
163
|
+
draggedRef.current = false
|
|
164
|
+
})
|
|
165
|
+
}}
|
|
166
|
+
onDragOver={(event) => onDragOver(event, row)}
|
|
167
|
+
onDrop={onDrop}
|
|
168
|
+
className={cn(
|
|
169
|
+
"group/resource relative flex min-h-9 min-w-0 cursor-pointer items-center gap-2.5 rounded-md pr-3 text-sm outline-none",
|
|
170
|
+
"text-muted-foreground transition-[color,background-color,scale] duration-150 ease-smooth-out hover:text-foreground motion-reduce:transition-none",
|
|
171
|
+
!row.item.disabled &&
|
|
172
|
+
!renaming &&
|
|
173
|
+
"active:scale-97 motion-reduce:active:scale-100",
|
|
174
|
+
// Fallback for standalone rows that render without the shared pill.
|
|
175
|
+
hoverLayoutId === undefined && "hover:bg-muted",
|
|
176
|
+
"focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
177
|
+
"data-[menu-open=true]:bg-muted data-[menu-open=true]:text-foreground",
|
|
178
|
+
"data-[dragging=true]:opacity-40",
|
|
179
|
+
"data-[drop=inside]:bg-primary/10 data-[drop=inside]:ring-1 data-[drop=inside]:ring-primary/45",
|
|
180
|
+
"data-[drop=before]:before:absolute data-[drop=before]:before:-top-0.5 data-[drop=before]:before:right-2 data-[drop=before]:before:left-2 data-[drop=before]:before:h-0.5 data-[drop=before]:before:rounded-full data-[drop=before]:before:bg-primary",
|
|
181
|
+
"data-[drop=after]:after:absolute data-[drop=after]:after:right-2 data-[drop=after]:after:-bottom-0.5 data-[drop=after]:after:left-2 data-[drop=after]:after:h-0.5 data-[drop=after]:after:rounded-full data-[drop=after]:after:bg-primary",
|
|
182
|
+
!acceptsChildren &&
|
|
183
|
+
active &&
|
|
184
|
+
"bg-primary/10 text-primary dark:bg-primary/25 dark:text-primary-foreground",
|
|
185
|
+
row.item.disabled && "cursor-not-allowed opacity-45"
|
|
186
|
+
)}
|
|
187
|
+
style={{ paddingLeft: `${12 + row.depth * 16}px` }}
|
|
188
|
+
>
|
|
189
|
+
{hoverLayoutId ? (
|
|
190
|
+
<AnimatePresence custom={hoverActive}>
|
|
191
|
+
{hoverActive ? (
|
|
192
|
+
<motion.span
|
|
193
|
+
aria-hidden="true"
|
|
194
|
+
variants={reduce ? reducedPillVariants : pillVariants}
|
|
195
|
+
initial="initial"
|
|
196
|
+
animate="animate"
|
|
197
|
+
exit="exit"
|
|
198
|
+
custom={hoverActive}
|
|
199
|
+
className="pointer-events-none absolute inset-0"
|
|
200
|
+
>
|
|
201
|
+
{hoverPill ? (
|
|
202
|
+
<motion.span
|
|
203
|
+
layoutId={hoverLayoutId}
|
|
204
|
+
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
|
|
205
|
+
className="block h-full w-full rounded-md bg-foreground/8 dark:bg-muted/50"
|
|
206
|
+
/>
|
|
207
|
+
) : null}
|
|
208
|
+
</motion.span>
|
|
209
|
+
) : null}
|
|
210
|
+
</AnimatePresence>
|
|
211
|
+
) : null}
|
|
212
|
+
<span
|
|
213
|
+
aria-hidden="true"
|
|
214
|
+
className="relative grid size-5 shrink-0 place-items-center"
|
|
215
|
+
>
|
|
216
|
+
{renderIcon?.(row.item) ?? SidebarIcon(row.item, expanded)}
|
|
217
|
+
</span>
|
|
218
|
+
|
|
219
|
+
{renaming ? (
|
|
220
|
+
<input
|
|
221
|
+
ref={inputRef}
|
|
222
|
+
value={draft}
|
|
223
|
+
aria-label={`Rename ${row.item.label}`}
|
|
224
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
225
|
+
draggable={false}
|
|
226
|
+
onClick={(event) => event.stopPropagation()}
|
|
227
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
228
|
+
onBlur={() => {
|
|
229
|
+
if (!skipRenameBlurRef.current) onRenameCommit(draft)
|
|
230
|
+
}}
|
|
231
|
+
onKeyDown={(event) => {
|
|
232
|
+
event.stopPropagation()
|
|
233
|
+
if (event.key === "Enter") {
|
|
234
|
+
skipRenameBlurRef.current = true
|
|
235
|
+
onRenameCommit(draft)
|
|
236
|
+
}
|
|
237
|
+
if (event.key === "Escape") {
|
|
238
|
+
skipRenameBlurRef.current = true
|
|
239
|
+
onRenameCancel()
|
|
240
|
+
}
|
|
241
|
+
}}
|
|
242
|
+
className="relative h-7 min-w-0 flex-1 text-sm text-foreground outline-none focus-visible:ring-0 focus-visible:ring-ring"
|
|
243
|
+
/>
|
|
244
|
+
) : (
|
|
245
|
+
<MarqueeLabel active={hovered || menuOpen}>
|
|
246
|
+
{row.item.label}
|
|
247
|
+
</MarqueeLabel>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{!renaming && !row.item.disabled ? (
|
|
251
|
+
<MorphPopover open={menuOpen} onOpenChange={onMenuOpenChange}>
|
|
252
|
+
<MorphPopoverTrigger>
|
|
253
|
+
{(() => {
|
|
254
|
+
const custom = renderActionsTrigger?.(row.item)
|
|
255
|
+
if (custom) {
|
|
256
|
+
// A row click selects/toggles — the trigger must not bubble.
|
|
257
|
+
const customOnClick = (
|
|
258
|
+
custom.props as {
|
|
259
|
+
onClick?: (event: MouseEvent<HTMLElement>) => void
|
|
260
|
+
}
|
|
261
|
+
).onClick
|
|
262
|
+
return cloneElement(custom, {
|
|
263
|
+
draggable: false,
|
|
264
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
265
|
+
event.stopPropagation()
|
|
266
|
+
customOnClick?.(event)
|
|
267
|
+
},
|
|
268
|
+
} as Partial<unknown>)
|
|
269
|
+
}
|
|
270
|
+
return (
|
|
271
|
+
<Button
|
|
272
|
+
draggable={false}
|
|
273
|
+
tabIndex={-1}
|
|
274
|
+
variant="link"
|
|
275
|
+
aria-label={`Actions for ${row.item.label}`}
|
|
276
|
+
onClick={(event) => event.stopPropagation()}
|
|
277
|
+
className="relative grid size-7 shrink-0 place-items-center rounded-lg opacity-0 transition-opacity duration-150 ease-smooth-out outline-none group-hover/resource:opacity-80 group-hover/resource:delay-150 group-data-[menu-open=true]/resource:opacity-100 hover:bg-foreground/5 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
|
|
278
|
+
>
|
|
279
|
+
<MoreHorizontal aria-hidden="true" className="size-4" />
|
|
280
|
+
</Button>
|
|
281
|
+
)
|
|
282
|
+
})()}
|
|
283
|
+
</MorphPopoverTrigger>
|
|
284
|
+
<MorphPopoverContent
|
|
285
|
+
side="bottom"
|
|
286
|
+
align="end"
|
|
287
|
+
sideOffset={8}
|
|
288
|
+
radius={12}
|
|
289
|
+
className="w-40 p-1.5"
|
|
290
|
+
>
|
|
291
|
+
<div data-sidebar-resource-menu={row.item.id}>{menu}</div>
|
|
292
|
+
</MorphPopoverContent>
|
|
293
|
+
</MorphPopover>
|
|
294
|
+
) : null}
|
|
295
|
+
|
|
296
|
+
{row.depth === 0 &&
|
|
297
|
+
acceptsChildren &&
|
|
298
|
+
(row.item.children?.length ?? 0) > 0 ? (
|
|
299
|
+
<ChevronRight
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
className={cn(
|
|
302
|
+
"relative size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-smooth-out motion-reduce:transition-none",
|
|
303
|
+
expanded && "rotate-90"
|
|
304
|
+
)}
|
|
305
|
+
/>
|
|
306
|
+
) : null}
|
|
307
|
+
</motion.div>
|
|
308
|
+
)
|
|
309
|
+
}
|