@exegia/corpora-ui 0.14.0 → 0.15.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.15.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,178 @@
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 own open flag seeds its side's initial state", () => {
77
+ render(
78
+ <ShellLayout
79
+ panels={{
80
+ ...PANELS,
81
+ right: { ...PANELS.right!, open: true },
82
+ }}
83
+ variant="web"
84
+ />
85
+ )
86
+
87
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
88
+ })
89
+
90
+ test("an explicit defaultOpen record wins over the panel's flag", () => {
91
+ render(
92
+ <ShellLayout
93
+ defaultOpen={{ left: false, right: false }}
94
+ panels={{
95
+ ...PANELS,
96
+ right: { ...PANELS.right!, open: true },
97
+ }}
98
+ variant="web"
99
+ />
100
+ )
101
+
102
+ expect(leftRail().getAttribute("data-state")).toBe("collapsed")
103
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
104
+ })
105
+
106
+ test("⌘B toggles the left rail", async () => {
107
+ const user = userEvent.setup()
108
+ render(<ShellLayout panels={PANELS} variant="web" />)
109
+
110
+ expect(leftRail().getAttribute("data-state")).toBe("expanded")
111
+
112
+ await user.keyboard("{Meta>}b{/Meta}")
113
+ expect(leftRail().getAttribute("data-state")).toBe("collapsed")
114
+
115
+ await user.keyboard("{Meta>}b{/Meta}")
116
+ expect(leftRail().getAttribute("data-state")).toBe("expanded")
117
+ })
118
+ })
119
+
120
+ function HookedShell({
121
+ onPanelChange,
122
+ }: {
123
+ onPanelChange?: UseShellPanelsOptions["onPanelChange"]
124
+ }) {
125
+ const panels = useShellPanels({ onPanelChange })
126
+
127
+ return (
128
+ <ShellLayout {...panels.providerProps} panels={PANELS} variant="web">
129
+ <button onClick={() => panels.toggle("right")} type="button">
130
+ External toggle
131
+ </button>
132
+ </ShellLayout>
133
+ )
134
+ }
135
+
136
+ describe("useShellPanels", () => {
137
+ test("reports every change as (open, side)", async () => {
138
+ const user = userEvent.setup()
139
+ const onPanelChange = mock(() => {})
140
+ render(<HookedShell onPanelChange={onPanelChange} />)
141
+
142
+ await user.click(screen.getByRole("button", { name: "Toggle panel" }))
143
+ expect(onPanelChange).toHaveBeenLastCalledWith(true, "right")
144
+
145
+ await user.click(screen.getByRole("button", { name: "Toggle sidebar" }))
146
+ expect(onPanelChange).toHaveBeenLastCalledWith(false, "left")
147
+
148
+ expect(onPanelChange).toHaveBeenCalledTimes(2)
149
+ })
150
+
151
+ test("controls the shell from outside it", async () => {
152
+ const user = userEvent.setup()
153
+ render(<HookedShell />)
154
+
155
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
156
+
157
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
158
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
159
+
160
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
161
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
162
+ })
163
+
164
+ test("the shell's own triggers round-trip through the hook's state", async () => {
165
+ const user = userEvent.setup()
166
+ render(<HookedShell />)
167
+
168
+ // Trigger click flows through providerProps into hook state and back —
169
+ // the drawer only opens because the hook re-rendered the controlled
170
+ // provider.
171
+ await user.click(screen.getByRole("button", { name: "Toggle panel" }))
172
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
173
+
174
+ // The external toggle sees that state, so it closes rather than opens.
175
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
176
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
177
+ })
178
+ })
@@ -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,12 @@ 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
+ <MotionIcon name="PanelLeft" size={24} animation="press" />
62
69
  </AnimatedSidebarTrigger>
63
70
  </div>
64
71
  <div className="flex shrink-0 items-center gap-3">
65
72
  <AnimatedSidebarTrigger aria-label="Toggle panel" side="right">
66
- <PanelRightIcon className="size-5" />
73
+ <MotionIcon name="PanelRight" size={24} animation="press" />
67
74
  </AnimatedSidebarTrigger>
68
75
  </div>
69
76
  </header>
@@ -72,10 +79,8 @@ export function ShellLayout({
72
79
  </div>
73
80
  </AnimatedSidebarInset>
74
81
 
75
-
76
-
77
82
  <AnimatedSidebar
78
- ariaLabel="Secondary panel"
83
+ ariaLabel={panels?.right?.name ?? "Secondary panel"}
79
84
  // Below md the panel is portal led over the page, so it carries the
80
85
  // surface itself; the desktop rail keeps it on the inner panel.
81
86
  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 +97,7 @@ export function ShellLayout({
92
97
  side="right"
93
98
  variant="inset"
94
99
  >
95
- {rightDrawer}
100
+ {panels?.right?.component}
96
101
  </AnimatedSidebar>
97
102
  </AnimatedSidebarProvider>
98
103
  )
@@ -1,30 +1,92 @@
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
+ open: boolean
20
27
  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
28
+ side: "left" | "bottom" | "right" | "sidebar"
29
+ }
30
+
31
+ /** The side a panel docks to, as a value union (`IPanel["side"]`).
32
+ * `Pick<IPanel, "side">` would be the object type `{ side: ... }`, which can
33
+ * neither key a Record nor travel as a plain callback argument. */
34
+ export type TPanelSide = IPanel["side"]
35
+
36
+ /** Panel content keyed by the side it docks to. `Side` narrows the map to
37
+ * the sides a surface actually renders (the shell renders left and right).
38
+ * Entries are optional — a shell with no bottom dock simply has no `bottom`
39
+ * key. Where an entry's own `side` field disagrees with its key, the key
40
+ * wins. */
41
+ export type TPanelMap<Side extends TPanelSide = TPanelSide> = Partial<
42
+ Record<Side, IPanel>
43
+ >
44
+
45
+ /** The open/close surface of the shell's panels, lifted straight from
46
+ * AnimatedSidebarProvider — every prop is keyed by side, there are no
47
+ * explicit per-side props. `useShellPanels` produces the controlled subset
48
+ * of these as `providerProps`. */
49
+ export type ShellPanelControlProps = Pick<
50
+ AnimatedSidebarProviderProps,
51
+ | "open"
52
+ | "defaultOpen"
53
+ | "onOpenChange"
54
+ | "openMobile"
55
+ | "defaultOpenMobile"
56
+ | "onOpenMobileChange"
57
+ >
58
+
59
+ export interface UseShellPanelsOptions {
60
+ /** Initial desktop open state per side, merged over
61
+ * `{ left: true, right: false }`. */
62
+ defaultOpen?: AnimatedSidebarProviderProps["defaultOpen"]
63
+ /** Initial mobile overlay state per side — every side starts closed. */
64
+ defaultOpenMobile?: AnimatedSidebarProviderProps["defaultOpenMobile"]
65
+ /** Panel change event returning both the next open state and the side of
66
+ * the panel the change comes from. Mobile overlay changes report the same
67
+ * side as their desktop counterpart. */
68
+ onPanelChange?: (open: boolean, side: TPanelSide) => void
69
+ }
70
+
71
+ export interface ShellPanelControls {
72
+ /** Live desktop open state, keyed by side. */
73
+ open: Record<SidebarSide, boolean>
74
+ /** Live mobile overlay state, keyed by side. */
75
+ openMobile: Record<SidebarSide, boolean>
76
+ setOpen: (open: boolean, side: SidebarSide) => void
77
+ setOpenMobile: (open: boolean, side: SidebarSide) => void
78
+ /** Desktop-only convenience — the in-shell triggers already pick the
79
+ * mobile state themselves when the viewport is narrow. */
80
+ toggle: (side: SidebarSide) => void
81
+ /** Spread onto ShellLayout (or AnimatedSidebarProvider directly). */
82
+ providerProps: ShellPanelControlProps
83
+ }
84
+
85
+ export interface ShellLayoutProps extends ShellPanelControlProps {
86
+ children?: ReactNode
87
+ /** Content for the shell's panels, keyed by the side. The shell renders
88
+ * the `left` rail and the `right` drawer; other sides are reserved. */
89
+ panels?: TPanelMap
28
90
  className?: string
29
- variant: 'web' | 'desktop'
91
+ variant?: "web" | "desktop"
30
92
  }
@@ -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
+ }