@exegia/corpora-ui 0.11.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 +1 -1
- package/dist-lib/components/agents/sidebar-row.d.ts +1 -1
- package/dist-lib/components/agents/type.d.ts +7 -1
- package/package.json +1 -1
- package/src/components/agents/__tests__/ai-sidebar.test.tsx +93 -0
- package/src/components/agents/ai-sidebar.tsx +3 -1
- package/src/components/agents/sidebar-row.tsx +52 -14
- package/src/components/agents/type.ts +7 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { AISidebarProps } from './type.ts';
|
|
2
|
-
export declare function AISidebar({ renderIcon, renderMenu, ariaLabel, className, ...options }: AISidebarProps): import("react").JSX.Element;
|
|
2
|
+
export declare function AISidebar({ renderIcon, renderMenu, renderActionsTrigger, ariaLabel, className, ...options }: AISidebarProps): import("react").JSX.Element;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { ResourceRowProps } from './type.ts';
|
|
2
|
-
export declare function ResourceRow({ row, active, expanded, focused, draggingId, dropTarget, menuOpen, renaming, onDragEnd, onDragOver, onDragStart, onDrop, onFocus, onKeyDown, onMenuOpenChange, onRenameCancel, onRenameCommit, onRenameStart, onSelect, onToggle, hoverActive, hoverPill, hoverLayoutId, onHoverChange, renderIcon, renderMenu, setRef, }: ResourceRowProps): import("react").JSX.Element;
|
|
2
|
+
export declare function ResourceRow({ row, active, expanded, focused, draggingId, dropTarget, menuOpen, renaming, onDragEnd, onDragOver, onDragStart, onDrop, onFocus, onKeyDown, onMenuOpenChange, onRenameCancel, onRenameCommit, onRenameStart, onSelect, onToggle, hoverActive, hoverPill, hoverLayoutId, onHoverChange, renderIcon, renderMenu, renderActionsTrigger, setRef, }: ResourceRowProps): import("react").JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ReactNode, DragEvent, KeyboardEvent } from 'react';
|
|
1
|
+
import { ReactElement, ReactNode, DragEvent, KeyboardEvent } from 'react';
|
|
2
2
|
export type SidebarResourceKind = "folder" | "project" | "file" | "bookmark";
|
|
3
3
|
export interface SidebarResource {
|
|
4
4
|
id: string;
|
|
@@ -31,6 +31,11 @@ export interface AISidebarProps {
|
|
|
31
31
|
defaultExpandedIds?: string[];
|
|
32
32
|
renderIcon?: (item: SidebarResource) => ReactNode;
|
|
33
33
|
renderMenu?: (item: SidebarResource, controls: SidebarResourceMenuControls) => ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* Replaces the default "…" actions button. Must return a single element —
|
|
36
|
+
* the popover clones it to attach its trigger ref and click handler.
|
|
37
|
+
*/
|
|
38
|
+
renderActionsTrigger?: (item: SidebarResource) => ReactElement;
|
|
34
39
|
ariaLabel?: string;
|
|
35
40
|
className?: string;
|
|
36
41
|
}
|
|
@@ -73,5 +78,6 @@ export interface ResourceRowProps {
|
|
|
73
78
|
onHoverChange?: (hovered: boolean) => void;
|
|
74
79
|
renderIcon?: (item: SidebarResource) => ReactNode;
|
|
75
80
|
renderMenu?: AISidebarProps["renderMenu"];
|
|
81
|
+
renderActionsTrigger?: AISidebarProps["renderActionsTrigger"];
|
|
76
82
|
setRef: (node: HTMLDivElement | null) => void;
|
|
77
83
|
}
|
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
|
+
})
|
|
@@ -9,6 +9,7 @@ import { ResourceRow } from "@/components/agents/sidebar-row.tsx"
|
|
|
9
9
|
export function AISidebar({
|
|
10
10
|
renderIcon,
|
|
11
11
|
renderMenu,
|
|
12
|
+
renderActionsTrigger,
|
|
12
13
|
ariaLabel = "Resources",
|
|
13
14
|
className,
|
|
14
15
|
...options
|
|
@@ -93,6 +94,7 @@ export function AISidebar({
|
|
|
93
94
|
onDrop={handleDrop}
|
|
94
95
|
renderIcon={renderIcon}
|
|
95
96
|
renderMenu={renderMenu}
|
|
97
|
+
renderActionsTrigger={renderActionsTrigger}
|
|
96
98
|
setRef={(node) => setRowRef(row.item.id, node)}
|
|
97
99
|
/>
|
|
98
100
|
))}
|
|
@@ -102,7 +104,7 @@ export function AISidebar({
|
|
|
102
104
|
<div
|
|
103
105
|
aria-hidden="true"
|
|
104
106
|
data-active={dropTarget?.id === null || undefined}
|
|
105
|
-
className="absolute inset-x-1 bottom-0 flex h-8 items-center justify-center rounded-
|
|
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"
|
|
106
108
|
>
|
|
107
109
|
Move to top level
|
|
108
110
|
</div>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
cloneElement,
|
|
2
3
|
useEffect,
|
|
3
4
|
useRef,
|
|
4
5
|
useState,
|
|
6
|
+
type MouseEvent,
|
|
5
7
|
} from "react"
|
|
6
8
|
import type { ResourceRowProps } from "@/components/agents/type.ts"
|
|
7
9
|
import {
|
|
@@ -10,7 +12,7 @@ import {
|
|
|
10
12
|
useReducedMotion,
|
|
11
13
|
type Variants,
|
|
12
14
|
} from "motion/react"
|
|
13
|
-
import { MoreHorizontal, Pencil } from "lucide-react"
|
|
15
|
+
import { ChevronRight, MoreHorizontal, Pencil } from "lucide-react"
|
|
14
16
|
import { SPRING_LAYOUT } from "@/lib/ease.ts"
|
|
15
17
|
import { cn } from "@/lib/utils.ts"
|
|
16
18
|
import { canContain } from "@/components/agents/utils.ts"
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
MorphPopoverContent,
|
|
22
24
|
MorphPopoverTrigger,
|
|
23
25
|
} from "@/components/motion/popover-morph.tsx"
|
|
26
|
+
import { Button } from "@/components/ui/button";
|
|
24
27
|
|
|
25
28
|
// Same choreography as SharedLayoutBg: the wrapper only fades/blurs out when
|
|
26
29
|
// the pointer leaves the whole tree; a row-to-row move exits instantly so the
|
|
@@ -65,6 +68,7 @@ export function ResourceRow({
|
|
|
65
68
|
onHoverChange,
|
|
66
69
|
renderIcon,
|
|
67
70
|
renderMenu,
|
|
71
|
+
renderActionsTrigger,
|
|
68
72
|
setRef,
|
|
69
73
|
}: ResourceRowProps) {
|
|
70
74
|
const reduce = useReducedMotion() ?? false
|
|
@@ -175,7 +179,9 @@ export function ResourceRow({
|
|
|
175
179
|
"data-[drop=inside]:bg-primary/10 data-[drop=inside]:ring-1 data-[drop=inside]:ring-primary/45",
|
|
176
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",
|
|
177
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",
|
|
178
|
-
!acceptsChildren &&
|
|
182
|
+
!acceptsChildren &&
|
|
183
|
+
active &&
|
|
184
|
+
"bg-primary/10 text-primary dark:bg-primary/25 dark:text-primary-foreground",
|
|
179
185
|
row.item.disabled && "cursor-not-allowed opacity-45"
|
|
180
186
|
)}
|
|
181
187
|
style={{ paddingLeft: `${12 + row.depth * 16}px` }}
|
|
@@ -196,7 +202,7 @@ export function ResourceRow({
|
|
|
196
202
|
<motion.span
|
|
197
203
|
layoutId={hoverLayoutId}
|
|
198
204
|
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
|
|
199
|
-
className="block h-full w-full rounded-md bg-muted/50"
|
|
205
|
+
className="block h-full w-full rounded-md bg-foreground/8 dark:bg-muted/50"
|
|
200
206
|
/>
|
|
201
207
|
) : null}
|
|
202
208
|
</motion.span>
|
|
@@ -233,7 +239,7 @@ export function ResourceRow({
|
|
|
233
239
|
onRenameCancel()
|
|
234
240
|
}
|
|
235
241
|
}}
|
|
236
|
-
className="relative
|
|
242
|
+
className="relative h-7 min-w-0 flex-1 text-sm text-foreground outline-none focus-visible:ring-0 focus-visible:ring-ring"
|
|
237
243
|
/>
|
|
238
244
|
) : (
|
|
239
245
|
<MarqueeLabel active={hovered || menuOpen}>
|
|
@@ -244,16 +250,36 @@ export function ResourceRow({
|
|
|
244
250
|
{!renaming && !row.item.disabled ? (
|
|
245
251
|
<MorphPopover open={menuOpen} onOpenChange={onMenuOpenChange}>
|
|
246
252
|
<MorphPopoverTrigger>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
})()}
|
|
257
283
|
</MorphPopoverTrigger>
|
|
258
284
|
<MorphPopoverContent
|
|
259
285
|
side="bottom"
|
|
@@ -266,6 +292,18 @@ export function ResourceRow({
|
|
|
266
292
|
</MorphPopoverContent>
|
|
267
293
|
</MorphPopover>
|
|
268
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}
|
|
269
307
|
</motion.div>
|
|
270
308
|
)
|
|
271
309
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode, DragEvent, KeyboardEvent } from "react"
|
|
1
|
+
import type { ReactElement, ReactNode, DragEvent, KeyboardEvent } from "react"
|
|
2
2
|
|
|
3
3
|
export type SidebarResourceKind = "folder" | "project" | "file" | "bookmark"
|
|
4
4
|
|
|
@@ -40,6 +40,11 @@ export interface AISidebarProps {
|
|
|
40
40
|
item: SidebarResource,
|
|
41
41
|
controls: SidebarResourceMenuControls
|
|
42
42
|
) => ReactNode
|
|
43
|
+
/**
|
|
44
|
+
* Replaces the default "…" actions button. Must return a single element —
|
|
45
|
+
* the popover clones it to attach its trigger ref and click handler.
|
|
46
|
+
*/
|
|
47
|
+
renderActionsTrigger?: (item: SidebarResource) => ReactElement
|
|
43
48
|
ariaLabel?: string
|
|
44
49
|
className?: string
|
|
45
50
|
}
|
|
@@ -85,5 +90,6 @@ export interface ResourceRowProps {
|
|
|
85
90
|
onHoverChange?: (hovered: boolean) => void
|
|
86
91
|
renderIcon?: (item: SidebarResource) => ReactNode
|
|
87
92
|
renderMenu?: AISidebarProps["renderMenu"]
|
|
93
|
+
renderActionsTrigger?: AISidebarProps["renderActionsTrigger"]
|
|
88
94
|
setRef: (node: HTMLDivElement | null) => void
|
|
89
95
|
}
|