@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/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/shell-layout.d.ts +1 -1
- package/dist-lib/components/blocks/shell/type.d.ts +21 -1
- package/dist-lib/components/ui/button-group.d.ts +1 -1
- package/dist-lib/index.js +1280 -1273
- package/dist-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/blocks/scaffold/scaffold-sub-panel.tsx +1 -1
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +65 -0
- package/src/components/blocks/shell/animated-panel-provider.tsx +2 -0
- package/src/components/blocks/shell/shell-layout.tsx +28 -16
- package/src/components/blocks/shell/type.ts +22 -0
- package/src/components/blocks/shell/use-shell-panels.ts +30 -1
- package/src/components/ui/button-group.tsx +1 -1
package/package.json
CHANGED
|
@@ -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 ? "
|
|
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 {
|
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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-
|
|
81
|
+
<div className="flex min-w-0 flex-1 items-center">{header}</div>
|
|
75
82
|
)}
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
{
|
|
103
|
+
{rightContent && (
|
|
92
104
|
<AnimatedPanel
|
|
93
|
-
ariaLabel={panels
|
|
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
|
-
{
|
|
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
|
-
[
|
|
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 "
|
|
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"
|