@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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-lg 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"
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 && active && "bg-accent text-foreground",
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 mx-1 h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
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
- <button
248
- type="button"
249
- draggable={false}
250
- tabIndex={-1}
251
- aria-label={`Actions for ${row.item.label}`}
252
- onClick={(event) => event.stopPropagation()}
253
- 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"
254
- >
255
- <MoreHorizontal aria-hidden="true" className="size-4" />
256
- </button>
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
  }