@exegia/corpora-ui 0.24.0 → 0.26.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.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@ export function ScaffoldSubPanel({
40
40
  subPanelVariant[variant],
41
41
  // basis-14 + clip let the primary squeeze down to the strip height;
42
42
  // the strip keeps its natural (basis-auto) height when collapsed.
43
- primary ? "basis-14 min-h-14 overflow-clip" : "basis-auto min-h-14",
43
+ primary ? "min-h-14 basis-14 overflow-clip" : "min-h-14 basis-auto",
44
44
  className
45
45
  )}
46
46
  data-slot={`scaffold-sub-panel-${variant}`}
@@ -114,6 +114,46 @@ describe("ShellLayout", () => {
114
114
  expect(rightDrawer("Inspector")).toBeDefined()
115
115
  })
116
116
 
117
+ test("trailing actions stay in the right cluster without a header", () => {
118
+ render(
119
+ <ShellLayout
120
+ panels={PANELS}
121
+ trailing={<button type="button">Upload</button>}
122
+ variant="web"
123
+ />
124
+ )
125
+
126
+ const upload = screen.getByRole("button", { name: "Upload" })
127
+ const cluster = upload.parentElement
128
+ // Pinned by margin, not by flex-filling: the cluster hugs the right edge
129
+ // even with nothing beside it to push against.
130
+ expect(cluster?.className).toContain("ml-auto")
131
+ // The right panel's trigger sits after the actions, at the far edge.
132
+ expect(
133
+ cluster?.contains(screen.getByRole("button", { name: "Toggle panel" }))
134
+ ).toBe(true)
135
+ })
136
+
137
+ test("trailing renders the cluster even with no right panel", () => {
138
+ render(
139
+ <ShellLayout trailing={<button type="button">Upload</button>} variant="web" />
140
+ )
141
+
142
+ expect(screen.getByRole("button", { name: "Upload" })).toBeDefined()
143
+ expect(screen.queryByRole("button", { name: "Toggle panel" })).toBeNull()
144
+ })
145
+
146
+ test("panelComponents renders a side with no static panels entry", () => {
147
+ render(
148
+ <ShellLayout
149
+ panelComponents={{ right: <div>Dynamic body</div> }}
150
+ variant="web"
151
+ />
152
+ )
153
+
154
+ expect(rightDrawer("Secondary panel").textContent).toContain("Dynamic body")
155
+ })
156
+
117
157
  test("the header triggers toggle their own side", async () => {
118
158
  const user = userEvent.setup()
119
159
  render(<ShellLayout panels={PANELS} variant="web" />)
@@ -268,6 +308,12 @@ function HookedShell({
268
308
  <button onClick={() => panels.toggle("right")} type="button">
269
309
  External toggle
270
310
  </button>
311
+ <button
312
+ onClick={() => panels.openPanel("right", <div>Details body</div>)}
313
+ type="button"
314
+ >
315
+ Open details
316
+ </button>
271
317
  {panels.isNarrow && <p>No room for the inspector</p>}
272
318
  </ShellLayout>
273
319
  )
@@ -301,6 +347,25 @@ describe("useShellPanels", () => {
301
347
  expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
302
348
  })
303
349
 
350
+ test("openPanel opens the side with the content it was handed", async () => {
351
+ const user = userEvent.setup()
352
+ render(<HookedShell />)
353
+
354
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
355
+
356
+ await user.click(screen.getByRole("button", { name: "Open details" }))
357
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
358
+ // The dynamic content replaces the static panels entry for that side…
359
+ expect(rightDrawer().textContent).toContain("Details body")
360
+ expect(rightDrawer().textContent).not.toContain("Inspector body")
361
+
362
+ // …and sticks across a close/reopen instead of reverting.
363
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
364
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
365
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
366
+ expect(rightDrawer().textContent).toContain("Details body")
367
+ })
368
+
304
369
  test("mirrors the shell's too-narrow verdict back into the hook", () => {
305
370
  const restore = shellViewport(800)
306
371
  try {
@@ -21,6 +21,8 @@ export function AnimatedPanelProvider({
21
21
  defaultOpenMobile,
22
22
  onOpenMobileChange,
23
23
  onNarrowChange,
24
+ // ShellLayout renders this; the bare provider only keeps it off the DOM.
25
+ panelComponents: _panelComponents,
24
26
  className,
25
27
  style,
26
28
  ...props
@@ -18,11 +18,18 @@ export function ShellLayout({
18
18
  panels,
19
19
  className,
20
20
  header,
21
+ trailing,
21
22
  defaultOpen,
23
+ panelComponents,
22
24
  ...panelControlProps
23
25
  }: ShellLayoutProps): React.ReactElement {
24
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`
25
27
 
28
+ // Content pushed through `openPanel(side, component)` wins over the static
29
+ // `panels` entry; a side renders its panel when either supplies content.
30
+ const leftContent = panelComponents?.left ?? panels?.left?.component
31
+ const rightContent = panelComponents?.right ?? panels?.right?.component
32
+
26
33
  // Each panel seeds its own side's initial state (`defaultOpen ?? open`);
27
34
  // an explicit `defaultOpen` record — usually from useShellPanels — wins
28
35
  // per side.
@@ -50,20 +57,20 @@ export function ShellLayout({
50
57
  paddingTop: variant === "desktop" ? TITLE_BAR_HEIGHT : 0,
51
58
  }}
52
59
  >
53
- {panels?.left?.component && (
60
+ {leftContent && (
54
61
  <AnimatedPanel
55
62
  ariaLabel="Primary navigation"
56
63
  collapsible="icon"
57
64
  role="navigation"
58
65
  variant="inset"
59
66
  >
60
- {panels?.left?.component}
67
+ {leftContent}
61
68
  </AnimatedPanel>
62
69
  )}
63
70
 
64
71
  <AnimatedPanelInset>
65
72
  <header className="flex h-12 flex-row! items-center justify-between gap-2 border-b px-2">
66
- {panels?.left?.component && (
73
+ {leftContent && (
67
74
  <div className="flex min-w-0 shrink-0 items-center justify-start gap-2">
68
75
  <AnimatedPanelTrigger side="left">
69
76
  <MotionIcon name="PanelLeft" size={24} animation="press" />
@@ -71,26 +78,31 @@ export function ShellLayout({
71
78
  </div>
72
79
  )}
73
80
  {header && (
74
- <div className="flex w-full flex-1 items-center">{header}</div>
81
+ <div className="flex min-w-0 flex-1 items-center">{header}</div>
75
82
  )}
76
- {panels?.right?.component && (
77
- <div className="flex w-full shrink items-center justify-end gap-2">
78
- <AnimatedPanelTrigger aria-label="Toggle panel" side="right">
79
- <MotionIcon
80
- className="opacity-70"
81
- name="PanelRight"
82
- size={24}
83
- />
84
- </AnimatedPanelTrigger>
83
+ {(trailing || rightContent) && (
84
+ // ml-auto, not flex-fill: the cluster hugs the trailing edge even
85
+ // when there is no header (or left trigger) to push against.
86
+ <div className="ml-auto flex shrink-0 items-center justify-end gap-2">
87
+ {trailing}
88
+ {rightContent && (
89
+ <AnimatedPanelTrigger aria-label="Toggle panel" side="right">
90
+ <MotionIcon
91
+ className="opacity-70"
92
+ name="PanelRight"
93
+ size={24}
94
+ />
95
+ </AnimatedPanelTrigger>
96
+ )}
85
97
  </div>
86
98
  )}
87
99
  </header>
88
100
  <div className="min-h-24 flex-1 overflow-auto">{children}</div>
89
101
  </AnimatedPanelInset>
90
102
 
91
- {panels?.right?.component && (
103
+ {rightContent && (
92
104
  <AnimatedPanel
93
- ariaLabel={panels.right.name ?? "Secondary panel"}
105
+ ariaLabel={panels?.right?.name ?? "Secondary panel"}
94
106
  // Below md the panel is portal led over the page, so it carries the
95
107
  // surface itself; the desktop rail keeps it on the inner panel.
96
108
  className={cn(
@@ -103,7 +115,7 @@ export function ShellLayout({
103
115
  side="right"
104
116
  variant="inset"
105
117
  >
106
- {panels.right.component}
118
+ {rightContent}
107
119
  </AnimatedPanel>
108
120
  )}
109
121
  </AnimatedPanelProvider>
@@ -125,6 +125,11 @@ export type TPanelMap<Side extends TPanelSide = TPanelSide> = Partial<
125
125
  Record<Side, IPanel>
126
126
  >
127
127
 
128
+ /** Dynamic panel content keyed by side — filled by
129
+ * `useShellPanels().openPanel(side, component)` and read by ShellLayout,
130
+ * where a side's entry wins over the static `panels` map's `component`. */
131
+ export type SidebarComponentMap = Partial<Record<SidebarSide, ReactNode>>
132
+
128
133
  /** The open/close surface of the shell's panels, lifted straight from
129
134
  * AnimatedPanelProvider — every prop is keyed by side, there are no
130
135
  * explicit per-side props. `useShellPanels` produces the controlled subset
@@ -139,6 +144,7 @@ export type ShellPanelControlProps = Pick<
139
144
  | "onOpenMobileChange"
140
145
  | "shellId"
141
146
  | "defaultPanelWidth"
147
+ | "panelComponents"
142
148
  >
143
149
 
144
150
  export interface UseShellPanelsOptions {
@@ -189,6 +195,13 @@ export interface ShellPanelControls {
189
195
  * mobile state themselves when the viewport is narrow. Carries the same
190
196
  * `isNarrow` refusal as `setOpen`. */
191
197
  toggle: (side: SidebarSide) => void
198
+ /** Open a side's panel and, when given, swap in the content it shows.
199
+ * The component travels through `providerProps.panelComponents` into
200
+ * ShellLayout, where it wins over the static `panels` entry for that
201
+ * side, and sticks until the next `openPanel(side, component)` replaces
202
+ * it — closing the panel leaves it in place for the exit animation and
203
+ * the next open. Carries the same `isNarrow` refusal as `setOpen`. */
204
+ openPanel: (side: SidebarSide, component?: ReactNode) => void
192
205
  /** Spread onto ShellLayout (or AnimatedPanelProvider directly). */
193
206
  providerProps: ShellPanelControlProps
194
207
  }
@@ -199,6 +212,10 @@ export interface ShellLayoutProps extends ShellPanelControlProps {
199
212
  * the `left` rail and the `right` drawer; other sides are reserved. */
200
213
  panels?: TPanelMap
201
214
  header?: ReactNode
215
+ /** Actions pinned to the header's right edge, rendered just before the
216
+ * right panel's trigger. The cluster hugs the trailing edge no matter
217
+ * which of `header` / the left rail / the right panel are present. */
218
+ trailing?: ReactNode
202
219
  className?: string
203
220
  variant?: "web" | "desktop"
204
221
  }
@@ -249,6 +266,11 @@ export interface AnimatedSidebarProviderProps extends HTMLAttributes<HTMLDivElem
249
266
  /** Initial mobile overlay state — every side starts closed. */
250
267
  defaultOpenMobile?: SidebarOpenState
251
268
  onOpenMobileChange?: (open: boolean, side: SidebarSide) => void
269
+ /** Content pushed into a side's panel by `useShellPanels().openPanel`. It
270
+ * rides along in `providerProps` so the record can be spread on either
271
+ * surface: ShellLayout renders it in that side's panel; the bare provider
272
+ * renders no panels, so it only swallows the prop to keep it off the DOM. */
273
+ panelComponents?: SidebarComponentMap
252
274
  /** Fires when the shell crosses the width a secondary panel needs (rail +
253
275
  * body + panel at their floors). An imperative escape hatch for a consumer
254
276
  * that mounts the provider on its own; anything under `ExegiaProvider` can
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useCallback, useEffect, useId, useMemo, useState } from "react"
4
+ import type { ReactNode } from "react"
4
5
  import { useAtomValue, useSetAtom } from "jotai"
5
6
 
6
7
  import {
@@ -12,6 +13,7 @@ import {
12
13
  import type {
13
14
  ShellPanelControlProps,
14
15
  ShellPanelControls,
16
+ SidebarComponentMap,
15
17
  SidebarSide,
16
18
  UseShellPanelsOptions,
17
19
  } from "./type"
@@ -104,6 +106,23 @@ export function useShellPanels({
104
106
  [open, setOpen]
105
107
  )
106
108
 
109
+ // The content sticks even when the open is refused (too narrow): it is
110
+ // what the panel shows whenever it next gets to open, not a one-shot.
111
+ const [panelComponents, setPanelComponents] = useState<SidebarComponentMap>(
112
+ {}
113
+ )
114
+ const openPanel = useCallback(
115
+ (side: SidebarSide, component?: ReactNode) => {
116
+ if (component !== undefined) {
117
+ setPanelComponents((prev) =>
118
+ prev[side] === component ? prev : { ...prev, [side]: component }
119
+ )
120
+ }
121
+ setOpen(true, side)
122
+ },
123
+ [setOpen]
124
+ )
125
+
107
126
  const providerProps = useMemo<ShellPanelControlProps>(
108
127
  () => ({
109
128
  shellId,
@@ -112,8 +131,17 @@ export function useShellPanels({
112
131
  onOpenChange: setOpen,
113
132
  openMobile,
114
133
  onOpenMobileChange: setOpenMobile,
134
+ panelComponents,
115
135
  }),
116
- [shellId, defaultPanelWidth, open, openMobile, setOpen, setOpenMobile]
136
+ [
137
+ shellId,
138
+ defaultPanelWidth,
139
+ open,
140
+ openMobile,
141
+ setOpen,
142
+ setOpenMobile,
143
+ panelComponents,
144
+ ]
117
145
  )
118
146
 
119
147
  return {
@@ -126,6 +154,7 @@ export function useShellPanels({
126
154
  setOpen,
127
155
  setOpenMobile,
128
156
  toggle,
157
+ openPanel,
129
158
  providerProps,
130
159
  }
131
160
  }
@@ -1,7 +1,7 @@
1
1
  import { mergeProps } from "@base-ui/react/merge-props"
2
2
  import { useRender } from "@base-ui/react/use-render"
3
3
  import { cva, type VariantProps } from "class-variance-authority"
4
- import { motion, type HTMLMotionProps } from "framer-motion"
4
+ import { motion, type HTMLMotionProps } from "motion/react"
5
5
 
6
6
  import { cn } from "@/lib/utils"
7
7
  import { Separator } from "@/components/ui/separator"