@exegia/corpora-ui 0.14.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,8 +46,9 @@
46
46
  "class-variance-authority": "^0.7.1",
47
47
  "clsx": "^2.1.1",
48
48
  "cuelume": "^0.2.1",
49
- "lucide-react": "^1.27.0",
49
+ "lucide-react": "^1.31.0",
50
50
  "motion": "^12.43.0",
51
+ "motion-icons-react": "^1.1.0",
51
52
  "tailwind-merge": "^3.6.0"
52
53
  },
53
54
  "devDependencies": {
@@ -62,14 +62,22 @@ export function SidebarBlock({
62
62
  return (
63
63
  <AnimatedSidebarProvider
64
64
  // The provider ships a full-page flex shell; this block is just the
65
- // panel, so the shell is flattened back to the panel's own box.
65
+ // panel, so the shell is flattened back to the panel's own box. The
66
+ // block is a single panel, so its scalar open props map onto the
67
+ // provider's side-keyed records under the block's own `side`.
66
68
  className="h-full min-h-0 w-auto"
67
- defaultOpen={defaultOpen}
68
- defaultOpenMobile={defaultOpenMobile}
69
- onOpenChange={onOpenChange}
70
- onOpenMobileChange={onOpenMobileChange}
71
- open={open}
72
- openMobile={openMobile}
69
+ defaultOpen={{ [side]: defaultOpen ?? true }}
70
+ defaultOpenMobile={
71
+ defaultOpenMobile === undefined ? undefined : { [side]: defaultOpenMobile }
72
+ }
73
+ onOpenChange={(nextOpen, changedSide) => {
74
+ if (changedSide === side) onOpenChange?.(nextOpen)
75
+ }}
76
+ onOpenMobileChange={(nextOpen, changedSide) => {
77
+ if (changedSide === side) onOpenMobileChange?.(nextOpen)
78
+ }}
79
+ open={open === undefined ? undefined : { [side]: open }}
80
+ openMobile={openMobile === undefined ? undefined : { [side]: openMobile }}
73
81
  style={{
74
82
  ...(width ? { "--sidebar-width": width } : null),
75
83
  ...(iconWidth ? { "--sidebar-width-icon": iconWidth } : null),
@@ -0,0 +1,197 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { render, screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+
5
+ import { ShellLayout } from "../shell-layout"
6
+ import { useShellPanels } from "../use-shell-panels"
7
+ import type { TPanelMap, UseShellPanelsOptions } from "../type"
8
+
9
+ const PANELS: TPanelMap = {
10
+ left: {
11
+ id: "nav",
12
+ name: "Navigation",
13
+ component: <div>Nav body</div>,
14
+ open: true,
15
+ side: "left",
16
+ },
17
+ right: {
18
+ id: "inspector",
19
+ name: "Inspector",
20
+ component: <div>Inspector body</div>,
21
+ open: false,
22
+ side: "right",
23
+ },
24
+ }
25
+
26
+ /** The desktop rail / drawer elements, queried by their landmark roles. */
27
+ function leftRail() {
28
+ return screen.getByRole("navigation", { name: "Primary navigation" })
29
+ }
30
+
31
+ function rightDrawer(name = "Inspector") {
32
+ return screen.getByRole("complementary", { name })
33
+ }
34
+
35
+ describe("ShellLayout", () => {
36
+ test("renders panel content by side and children in the inset", () => {
37
+ render(
38
+ <ShellLayout panels={PANELS} variant="web">
39
+ <div>Main content</div>
40
+ </ShellLayout>
41
+ )
42
+
43
+ expect(leftRail().textContent).toContain("Nav body")
44
+ expect(rightDrawer().textContent).toContain("Inspector body")
45
+ expect(screen.getByRole("main").textContent).toContain("Main content")
46
+ })
47
+
48
+ test("names the right drawer after its panel", () => {
49
+ render(<ShellLayout panels={PANELS} variant="web" />)
50
+
51
+ expect(rightDrawer("Inspector")).toBeDefined()
52
+ })
53
+
54
+ test("the header triggers toggle their own side", async () => {
55
+ const user = userEvent.setup()
56
+ render(<ShellLayout panels={PANELS} variant="web" />)
57
+
58
+ const leftTrigger = screen.getByRole("button", { name: "Toggle sidebar" })
59
+ const rightTrigger = screen.getByRole("button", { name: "Toggle panel" })
60
+
61
+ expect(leftRail().getAttribute("data-state")).toBe("expanded")
62
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
63
+
64
+ await user.click(leftTrigger)
65
+ expect(leftRail().getAttribute("data-state")).toBe("collapsed")
66
+ expect(leftTrigger.getAttribute("aria-expanded")).toBe("false")
67
+ // The other side is untouched.
68
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
69
+
70
+ await user.click(rightTrigger)
71
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
72
+ expect(rightTrigger.getAttribute("aria-expanded")).toBe("true")
73
+ expect(leftRail().getAttribute("data-state")).toBe("collapsed")
74
+ })
75
+
76
+ test("a panel's trigger node replaces the default icon, keeping the toggle", async () => {
77
+ const user = userEvent.setup()
78
+ render(
79
+ <ShellLayout
80
+ panels={{
81
+ ...PANELS,
82
+ right: { ...PANELS.right!, trigger: <span>Custom trigger</span> },
83
+ }}
84
+ variant="web"
85
+ />
86
+ )
87
+
88
+ const rightTrigger = screen.getByRole("button", { name: "Toggle panel" })
89
+ expect(rightTrigger.textContent).toContain("Custom trigger")
90
+
91
+ await user.click(rightTrigger)
92
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
93
+ })
94
+
95
+ test("a panel's own open flag seeds its side's initial state", () => {
96
+ render(
97
+ <ShellLayout
98
+ panels={{
99
+ ...PANELS,
100
+ right: { ...PANELS.right!, open: true },
101
+ }}
102
+ variant="web"
103
+ />
104
+ )
105
+
106
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
107
+ })
108
+
109
+ test("an explicit defaultOpen record wins over the panel's flag", () => {
110
+ render(
111
+ <ShellLayout
112
+ defaultOpen={{ left: false, right: false }}
113
+ panels={{
114
+ ...PANELS,
115
+ right: { ...PANELS.right!, open: true },
116
+ }}
117
+ variant="web"
118
+ />
119
+ )
120
+
121
+ expect(leftRail().getAttribute("data-state")).toBe("collapsed")
122
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
123
+ })
124
+
125
+ test("⌘B toggles the left rail", async () => {
126
+ const user = userEvent.setup()
127
+ render(<ShellLayout panels={PANELS} variant="web" />)
128
+
129
+ expect(leftRail().getAttribute("data-state")).toBe("expanded")
130
+
131
+ await user.keyboard("{Meta>}b{/Meta}")
132
+ expect(leftRail().getAttribute("data-state")).toBe("collapsed")
133
+
134
+ await user.keyboard("{Meta>}b{/Meta}")
135
+ expect(leftRail().getAttribute("data-state")).toBe("expanded")
136
+ })
137
+ })
138
+
139
+ function HookedShell({
140
+ onPanelChange,
141
+ }: {
142
+ onPanelChange?: UseShellPanelsOptions["onPanelChange"]
143
+ }) {
144
+ const panels = useShellPanels({ onPanelChange })
145
+
146
+ return (
147
+ <ShellLayout {...panels.providerProps} panels={PANELS} variant="web">
148
+ <button onClick={() => panels.toggle("right")} type="button">
149
+ External toggle
150
+ </button>
151
+ </ShellLayout>
152
+ )
153
+ }
154
+
155
+ describe("useShellPanels", () => {
156
+ test("reports every change as (open, side)", async () => {
157
+ const user = userEvent.setup()
158
+ const onPanelChange = mock(() => {})
159
+ render(<HookedShell onPanelChange={onPanelChange} />)
160
+
161
+ await user.click(screen.getByRole("button", { name: "Toggle panel" }))
162
+ expect(onPanelChange).toHaveBeenLastCalledWith(true, "right")
163
+
164
+ await user.click(screen.getByRole("button", { name: "Toggle sidebar" }))
165
+ expect(onPanelChange).toHaveBeenLastCalledWith(false, "left")
166
+
167
+ expect(onPanelChange).toHaveBeenCalledTimes(2)
168
+ })
169
+
170
+ test("controls the shell from outside it", async () => {
171
+ const user = userEvent.setup()
172
+ render(<HookedShell />)
173
+
174
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
175
+
176
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
177
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
178
+
179
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
180
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
181
+ })
182
+
183
+ test("the shell's own triggers round-trip through the hook's state", async () => {
184
+ const user = userEvent.setup()
185
+ render(<HookedShell />)
186
+
187
+ // Trigger click flows through providerProps into hook state and back —
188
+ // the drawer only opens because the hook re-rendered the controlled
189
+ // provider.
190
+ await user.click(screen.getByRole("button", { name: "Toggle panel" }))
191
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
192
+
193
+ // The external toggle sees that state, so it closes rather than opens.
194
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
195
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
196
+ })
197
+ })
@@ -1,6 +1,7 @@
1
1
  import { ShellLayout } from "./shell-layout"
2
2
 
3
3
  export type * from "./type"
4
+ export * from "./use-shell-panels"
4
5
  export * from "./utils"
5
6
 
6
7
  export { ShellLayout as default }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { MenuIcon, PanelRightIcon } from "lucide-react"
3
+ import { MotionIcon } from "motion-icons-react"
4
4
  import * as React from "react"
5
5
 
6
6
  import {
@@ -10,44 +10,51 @@ import {
10
10
  AnimatedSidebarTrigger,
11
11
  } from "@/components/motion/animated-sidebar"
12
12
  import { cn } from "@/lib/utils"
13
- import type { ShellLayoutProps } from "./type"
13
+ import type { ShellLayoutProps, ShellPanelControlProps } from "./type"
14
14
  import { TITLE_BAR_HEIGHT } from "./utils"
15
15
  import type { ClassNameValue } from "tailwind-merge"
16
16
 
17
17
  export function ShellLayout({
18
18
  children,
19
- open,
20
- defaultOpen,
21
- onOpenChange,
22
- rightDrawer,
23
- rightOpen,
24
- defaultRightOpen,
25
- onRightOpenChange,
26
19
  variant = "desktop",
20
+ panels,
27
21
  className,
22
+ defaultOpen,
23
+ ...panelControlProps
28
24
  }: ShellLayoutProps): React.ReactElement {
29
25
 
30
26
  const background: ClassNameValue = `bg-linear-to-tr/increasing from-neutral-200 via-neutral-100 to-stone-200 dark:from-neutral-900 dark:via-neutral-950 dark:to-stone-950`
27
+
28
+ // Each panel seeds its own side's initial state (`defaultOpen ?? open`);
29
+ // an explicit `defaultOpen` record — usually from useShellPanels — wins
30
+ // per side.
31
+ const initialOpen: ShellPanelControlProps["defaultOpen"] = {
32
+ ...(panels?.left && {
33
+ left: panels.left.defaultOpen ?? panels.left.open,
34
+ }),
35
+ ...(panels?.right && {
36
+ right: panels.right.defaultOpen ?? panels.right.open,
37
+ }),
38
+ ...(defaultOpen)
39
+ }
40
+
31
41
  return (
32
42
  <AnimatedSidebarProvider
33
- className={cn("block-full", className, background, 'relative pr-2')}
34
- defaultOpen={defaultOpen}
35
- defaultOpenRight={defaultRightOpen}
36
- onOpenChange={onOpenChange}
37
- onOpenRightChange={onRightOpenChange}
38
- open={open}
39
- openRight={rightOpen}
43
+ {...panelControlProps}
44
+ defaultOpen={initialOpen}
45
+ className={cn("block-full", className, background, "relative pr-2")}
40
46
  style={{
41
47
  paddingTop: variant === "desktop" ? TITLE_BAR_HEIGHT : 0
42
48
  }}
43
49
  >
44
50
  <AnimatedSidebar
45
51
  ariaLabel="Primary navigation"
46
- className={cn("")}
47
52
  collapsible="icon"
48
53
  role="navigation"
49
54
  variant="inset"
50
- ></AnimatedSidebar>
55
+ >
56
+ {panels?.left?.component}
57
+ </AnimatedSidebar>
51
58
 
52
59
  <AnimatedSidebarInset
53
60
  className={cn(
@@ -58,12 +65,16 @@ export function ShellLayout({
58
65
  <header className="flex h-16 items-center justify-between gap-3 px-4 sm:px-6">
59
66
  <div className="flex min-w-0 items-center gap-3">
60
67
  <AnimatedSidebarTrigger>
61
- <MenuIcon className="size-5" />
68
+ {panels?.left?.trigger ?? (
69
+ <MotionIcon name="PanelLeft" size={24} animation="press" />
70
+ )}
62
71
  </AnimatedSidebarTrigger>
63
72
  </div>
64
73
  <div className="flex shrink-0 items-center gap-3">
65
74
  <AnimatedSidebarTrigger aria-label="Toggle panel" side="right">
66
- <PanelRightIcon className="size-5" />
75
+ {panels?.right?.trigger ?? (
76
+ <MotionIcon name="PanelRight" size={24} animation="press" />
77
+ )}
67
78
  </AnimatedSidebarTrigger>
68
79
  </div>
69
80
  </header>
@@ -72,10 +83,8 @@ export function ShellLayout({
72
83
  </div>
73
84
  </AnimatedSidebarInset>
74
85
 
75
-
76
-
77
86
  <AnimatedSidebar
78
- ariaLabel="Secondary panel"
87
+ ariaLabel={panels?.right?.name ?? "Secondary panel"}
79
88
  // Below md the panel is portal led over the page, so it carries the
80
89
  // surface itself; the desktop rail keeps it on the inner panel.
81
90
  className={cn("max-md:border-l max-md:border-neutral-200 max-md:bg-white dark:max-md:border-neutral-700 dark:max-md:bg-card")}
@@ -92,7 +101,7 @@ export function ShellLayout({
92
101
  side="right"
93
102
  variant="inset"
94
103
  >
95
- {rightDrawer}
104
+ {panels?.right?.component}
96
105
  </AnimatedSidebar>
97
106
  </AnimatedSidebarProvider>
98
107
  )
@@ -1,30 +1,96 @@
1
- import React from "react"
1
+ import type { ReactNode } from "react"
2
+
3
+ import type {
4
+ AnimatedSidebarProviderProps,
5
+ SidebarSide,
6
+ } from "@/components/motion/animated-sidebar"
2
7
 
3
8
  export interface ShellAction {
4
9
  id: string
5
10
  label: string
6
- icon: React.ReactNode
7
- badge?: React.ReactNode
11
+ icon: ReactNode
12
+ badge?: ReactNode
8
13
  onSelect?: () => void
9
14
  }
10
15
 
11
16
  export interface ShellWorkspace {
12
17
  name: string
13
- logo?: React.ReactNode
14
- meta?: React.ReactNode
18
+ logo?: ReactNode
19
+ meta?: ReactNode
15
20
  }
16
21
 
17
- export interface ShellLayoutProps {
18
- children?: React.ReactNode
19
- open?: boolean
22
+ export interface IPanel {
23
+ id: string
24
+ name: string
25
+ component: ReactNode
26
+ /** Replaces the default icon inside the panel's header trigger. The shell
27
+ * keeps the trigger button itself (toggle wiring, aria-label) — this only
28
+ * swaps what renders inside it. */
29
+ trigger?: ReactNode
30
+ open: boolean
20
31
  defaultOpen?: boolean
21
- onOpenChange?: (open: boolean) => void
22
- /** Content of the right drawer. It has no icon rail — it is either fully
23
- * shown or fully hidden. */
24
- rightDrawer?: React.ReactNode
25
- rightOpen?: boolean
26
- defaultRightOpen?: boolean
27
- onRightOpenChange?: (open: boolean) => void
32
+ side: "left" | "bottom" | "right" | "sidebar"
33
+ }
34
+
35
+ /** The side a panel docks to, as a value union (`IPanel["side"]`).
36
+ * `Pick<IPanel, "side">` would be the object type `{ side: ... }`, which can
37
+ * neither key a Record nor travel as a plain callback argument. */
38
+ export type TPanelSide = IPanel["side"]
39
+
40
+ /** Panel content keyed by the side it docks to. `Side` narrows the map to
41
+ * the sides a surface actually renders (the shell renders left and right).
42
+ * Entries are optional — a shell with no bottom dock simply has no `bottom`
43
+ * key. Where an entry's own `side` field disagrees with its key, the key
44
+ * wins. */
45
+ export type TPanelMap<Side extends TPanelSide = TPanelSide> = Partial<
46
+ Record<Side, IPanel>
47
+ >
48
+
49
+ /** The open/close surface of the shell's panels, lifted straight from
50
+ * AnimatedSidebarProvider — every prop is keyed by side, there are no
51
+ * explicit per-side props. `useShellPanels` produces the controlled subset
52
+ * of these as `providerProps`. */
53
+ export type ShellPanelControlProps = Pick<
54
+ AnimatedSidebarProviderProps,
55
+ | "open"
56
+ | "defaultOpen"
57
+ | "onOpenChange"
58
+ | "openMobile"
59
+ | "defaultOpenMobile"
60
+ | "onOpenMobileChange"
61
+ >
62
+
63
+ export interface UseShellPanelsOptions {
64
+ /** Initial desktop open state per side, merged over
65
+ * `{ left: true, right: false }`. */
66
+ defaultOpen?: AnimatedSidebarProviderProps["defaultOpen"]
67
+ /** Initial mobile overlay state per side — every side starts closed. */
68
+ defaultOpenMobile?: AnimatedSidebarProviderProps["defaultOpenMobile"]
69
+ /** Panel change event returning both the next open state and the side of
70
+ * the panel the change comes from. Mobile overlay changes report the same
71
+ * side as their desktop counterpart. */
72
+ onPanelChange?: (open: boolean, side: TPanelSide) => void
73
+ }
74
+
75
+ export interface ShellPanelControls {
76
+ /** Live desktop open state, keyed by side. */
77
+ open: Record<SidebarSide, boolean>
78
+ /** Live mobile overlay state, keyed by side. */
79
+ openMobile: Record<SidebarSide, boolean>
80
+ setOpen: (open: boolean, side: SidebarSide) => void
81
+ setOpenMobile: (open: boolean, side: SidebarSide) => void
82
+ /** Desktop-only convenience — the in-shell triggers already pick the
83
+ * mobile state themselves when the viewport is narrow. */
84
+ toggle: (side: SidebarSide) => void
85
+ /** Spread onto ShellLayout (or AnimatedSidebarProvider directly). */
86
+ providerProps: ShellPanelControlProps
87
+ }
88
+
89
+ export interface ShellLayoutProps extends ShellPanelControlProps {
90
+ children?: ReactNode
91
+ /** Content for the shell's panels, keyed by the side. The shell renders
92
+ * the `left` rail and the `right` drawer; other sides are reserved. */
93
+ panels?: TPanelMap
28
94
  className?: string
29
- variant: 'web' | 'desktop'
95
+ variant?: "web" | "desktop"
30
96
  }
@@ -0,0 +1,78 @@
1
+ "use client"
2
+
3
+ import { useCallback, useMemo, useState } from "react"
4
+
5
+ import type { SidebarSide } from "@/components/motion/animated-sidebar"
6
+ import type {
7
+ ShellPanelControlProps,
8
+ ShellPanelControls,
9
+ UseShellPanelsOptions,
10
+ } from "./type"
11
+
12
+ /**
13
+ * Owns the open/close state of the shell's panels, keyed by side, and
14
+ * reports every change through a single `onPanelChange(open, side)`
15
+ * callback. Spread the returned `providerProps` onto ShellLayout; the
16
+ * setters and `toggle` are for UI that lives outside the shell (title-bar
17
+ * buttons, command palette, shortcuts).
18
+ */
19
+ export function useShellPanels({
20
+ defaultOpen,
21
+ defaultOpenMobile,
22
+ onPanelChange,
23
+ }: UseShellPanelsOptions = {}): ShellPanelControls {
24
+ const [open, setOpenState] = useState<Record<SidebarSide, boolean>>(() => ({
25
+ left: defaultOpen?.left ?? true,
26
+ right: defaultOpen?.right ?? false,
27
+ }))
28
+ const [openMobile, setOpenMobileState] = useState<
29
+ Record<SidebarSide, boolean>
30
+ >(() => ({
31
+ left: defaultOpenMobile?.left ?? false,
32
+ right: defaultOpenMobile?.right ?? false,
33
+ }))
34
+
35
+ const setOpen = useCallback(
36
+ (nextOpen: boolean, side: SidebarSide) => {
37
+ setOpenState((prev) =>
38
+ prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
39
+ )
40
+ onPanelChange?.(nextOpen, side)
41
+ },
42
+ [onPanelChange]
43
+ )
44
+
45
+ const setOpenMobile = useCallback(
46
+ (nextOpen: boolean, side: SidebarSide) => {
47
+ setOpenMobileState((prev) =>
48
+ prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
49
+ )
50
+ onPanelChange?.(nextOpen, side)
51
+ },
52
+ [onPanelChange]
53
+ )
54
+
55
+ const toggle = useCallback(
56
+ (side: SidebarSide) => setOpen(!open[side], side),
57
+ [open, setOpen]
58
+ )
59
+
60
+ const providerProps = useMemo<ShellPanelControlProps>(
61
+ () => ({
62
+ open,
63
+ onOpenChange: setOpen,
64
+ openMobile,
65
+ onOpenMobileChange: setOpenMobile,
66
+ }),
67
+ [open, openMobile, setOpen, setOpenMobile]
68
+ )
69
+
70
+ return {
71
+ open,
72
+ openMobile,
73
+ setOpen,
74
+ setOpenMobile,
75
+ toggle,
76
+ providerProps,
77
+ }
78
+ }