@exegia/corpora-ui 0.13.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/dist-lib/components/blocks/layout.d.ts +5 -0
- package/dist-lib/components/{agents → blocks/nav/sidebar}/ai-sidebar.d.ts +1 -1
- package/dist-lib/components/{agents → blocks/nav/sidebar}/sidebar-row.d.ts +1 -1
- package/dist-lib/components/blocks/shell/__tests__/shell-layout.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/index.d.ts +3 -1
- package/dist-lib/components/blocks/shell/shell-layout.d.ts +1 -1
- package/dist-lib/components/blocks/shell/type.d.ts +58 -15
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +9 -0
- package/dist-lib/components/motion/animated-sidebar.d.ts +25 -25
- package/dist-lib/index.d.ts +3 -1
- package/dist-lib/index.js +1107 -1029
- package/dist-lib/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/blocks/layout.ts +7 -0
- package/src/components/{agents → blocks/nav/sidebar}/ai-sidebar.tsx +13 -17
- package/src/components/{agents → blocks/nav/sidebar}/marquee-label.tsx +1 -1
- package/src/components/{agents → blocks/nav/sidebar}/sidebar-icon.tsx +1 -1
- package/src/components/{agents → blocks/nav/sidebar}/sidebar-row.tsx +4 -4
- package/src/components/{agents → blocks/nav/sidebar}/use-ai-sidebar.ts +2 -2
- package/src/components/{agents → blocks/nav/sidebar}/utils.ts +1 -1
- package/src/components/blocks/nav/sidebar-block.tsx +15 -7
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +178 -0
- package/src/components/blocks/shell/index.ts +5 -1
- package/src/components/blocks/shell/shell-layout.tsx +29 -24
- package/src/components/blocks/shell/type.ts +77 -35
- package/src/components/blocks/shell/use-shell-panels.ts +78 -0
- package/src/components/motion/animated-sidebar.tsx +113 -90
- package/src/index.css +2 -1
- package/src/index.ts +3 -3
- /package/dist-lib/components/{agents → blocks/nav/sidebar}/__tests__/ai-sidebar.test.d.ts +0 -0
- /package/dist-lib/components/{agents → blocks/nav/sidebar}/index.d.ts +0 -0
- /package/dist-lib/components/{agents → blocks/nav/sidebar}/marquee-label.d.ts +0 -0
- /package/dist-lib/components/{agents → blocks/nav/sidebar}/sidebar-icon.d.ts +0 -0
- /package/dist-lib/components/{agents → blocks/nav/sidebar}/type.d.ts +0 -0
- /package/dist-lib/components/{agents → blocks/nav/sidebar}/use-ai-sidebar.d.ts +0 -0
- /package/dist-lib/components/{agents → blocks/nav/sidebar}/utils.d.ts +0 -0
- /package/src/components/{agents → blocks/nav/sidebar}/__tests__/ai-sidebar.test.tsx +0 -0
- /package/src/components/{agents → blocks/nav/sidebar}/index.ts +0 -0
- /package/src/components/{agents → blocks/nav/sidebar}/type.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exegia/corpora-ui",
|
|
3
|
-
"version": "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.
|
|
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": {
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { AnimatePresence } from "motion/react";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
|
-
import type { AISidebarProps } from "
|
|
6
|
-
import { useAISidebar } from "
|
|
7
|
-
import { ResourceRow } from "
|
|
5
|
+
import type { AISidebarProps, FlatResource } from "./type"
|
|
6
|
+
import { useAISidebar, type UseAISidebarOptions } from "./use-ai-sidebar"
|
|
7
|
+
import { ResourceRow } from "./sidebar-row"
|
|
8
8
|
|
|
9
9
|
export function AISidebar({
|
|
10
10
|
renderIcon,
|
|
@@ -41,7 +41,7 @@ export function AISidebar({
|
|
|
41
41
|
commitRename,
|
|
42
42
|
handleMenuOpenChange,
|
|
43
43
|
setRowRef,
|
|
44
|
-
} = useAISidebar(options);
|
|
44
|
+
} = useAISidebar(options as UseAISidebarOptions);
|
|
45
45
|
|
|
46
46
|
return (
|
|
47
47
|
<>
|
|
@@ -53,13 +53,13 @@ export function AISidebar({
|
|
|
53
53
|
onDrop={handleDrop}
|
|
54
54
|
onMouseLeave={clearHover}
|
|
55
55
|
className={cn(
|
|
56
|
-
"relative
|
|
57
|
-
draggingId && "select-none
|
|
58
|
-
className
|
|
56
|
+
"relative min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden",
|
|
57
|
+
draggingId && "pb-9 select-none",
|
|
58
|
+
className
|
|
59
59
|
)}
|
|
60
60
|
>
|
|
61
61
|
<AnimatePresence initial={false}>
|
|
62
|
-
{flat.map((row) => (
|
|
62
|
+
{flat.map((row: FlatResource) => (
|
|
63
63
|
<ResourceRow
|
|
64
64
|
key={row.item.id}
|
|
65
65
|
row={row}
|
|
@@ -72,14 +72,10 @@ export function AISidebar({
|
|
|
72
72
|
renaming={renamingId === row.item.id}
|
|
73
73
|
hoverActive={hoveredId !== null && !draggingId}
|
|
74
74
|
hoverPill={
|
|
75
|
-
hoveredId === row.item.id &&
|
|
76
|
-
!draggingId &&
|
|
77
|
-
!row.item.disabled
|
|
75
|
+
hoveredId === row.item.id && !draggingId && !row.item.disabled
|
|
78
76
|
}
|
|
79
77
|
hoverLayoutId={hoverLayoutId}
|
|
80
|
-
onHoverChange={(hovered) =>
|
|
81
|
-
handleRowHover(row.item.id, hovered)
|
|
82
|
-
}
|
|
78
|
+
onHoverChange={(hovered) => handleRowHover(row.item.id, hovered)}
|
|
83
79
|
onFocus={() => setFocusedId(row.item.id)}
|
|
84
80
|
onSelect={() => select(row.item.id)}
|
|
85
81
|
onToggle={() => toggle(row.item.id)}
|
|
@@ -100,7 +96,7 @@ export function AISidebar({
|
|
|
100
96
|
))}
|
|
101
97
|
</AnimatePresence>
|
|
102
98
|
|
|
103
|
-
{draggingId
|
|
99
|
+
{draggingId && (
|
|
104
100
|
<div
|
|
105
101
|
aria-hidden="true"
|
|
106
102
|
data-active={dropTarget?.id === null || undefined}
|
|
@@ -108,11 +104,11 @@ export function AISidebar({
|
|
|
108
104
|
>
|
|
109
105
|
Move to top level
|
|
110
106
|
</div>
|
|
111
|
-
)
|
|
107
|
+
)}
|
|
112
108
|
</div>
|
|
113
109
|
<span className="sr-only" aria-live="polite">
|
|
114
110
|
{announcement}
|
|
115
111
|
</span>
|
|
116
112
|
</>
|
|
117
|
-
)
|
|
113
|
+
)
|
|
118
114
|
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
useState,
|
|
6
6
|
type MouseEvent,
|
|
7
7
|
} from "react"
|
|
8
|
-
import type { ResourceRowProps } from "
|
|
8
|
+
import type { ResourceRowProps } from "./type"
|
|
9
9
|
import {
|
|
10
10
|
AnimatePresence,
|
|
11
11
|
motion,
|
|
@@ -15,9 +15,9 @@ import {
|
|
|
15
15
|
import { ChevronRight, MoreHorizontal, Pencil } from "lucide-react"
|
|
16
16
|
import { SPRING_LAYOUT } from "@/lib/ease.ts"
|
|
17
17
|
import { cn } from "@/lib/utils.ts"
|
|
18
|
-
import { canContain } from "
|
|
19
|
-
import { SidebarIcon } from "
|
|
20
|
-
import { MarqueeLabel } from "
|
|
18
|
+
import { canContain } from "./utils"
|
|
19
|
+
import { SidebarIcon } from "./sidebar-icon"
|
|
20
|
+
import { MarqueeLabel } from "./marquee-label"
|
|
21
21
|
import {
|
|
22
22
|
MorphPopover,
|
|
23
23
|
MorphPopoverContent,
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
FlatResource,
|
|
17
17
|
SidebarResource,
|
|
18
18
|
SidebarResourceMove,
|
|
19
|
-
} from "
|
|
19
|
+
} from "./type.ts"
|
|
20
20
|
import {
|
|
21
21
|
canContain,
|
|
22
22
|
containsResource,
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
flattenResources,
|
|
25
25
|
moveResource,
|
|
26
26
|
renameResource,
|
|
27
|
-
} from "
|
|
27
|
+
} from "./utils.ts"
|
|
28
28
|
|
|
29
29
|
export type UseAISidebarOptions = Pick<
|
|
30
30
|
AISidebarProps,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EASE_OUT } from "@/lib/ease.ts"
|
|
2
|
-
import type { FlatResource, SidebarResource, SidebarResourceDropPosition, SidebarResourceMove } from "
|
|
2
|
+
import type { FlatResource, SidebarResource, SidebarResourceDropPosition, SidebarResourceMove } from "./type.ts"
|
|
3
3
|
|
|
4
4
|
export const ROW_REVEAL = {
|
|
5
5
|
duration: 0.16,
|
|
@@ -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={
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
34
|
-
defaultOpen={
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
{
|
|
100
|
+
{panels?.right?.component}
|
|
96
101
|
</AnimatedSidebar>
|
|
97
102
|
</AnimatedSidebarProvider>
|
|
98
103
|
)
|
|
@@ -1,50 +1,92 @@
|
|
|
1
|
-
|
|
2
|
-
// ISidebarNavItem,
|
|
3
|
-
// ISidebarNavSection,
|
|
4
|
-
// ISidebarNavSubItem,
|
|
5
|
-
// } from "@/components/blocks/nav/types"
|
|
1
|
+
import type { ReactNode } from "react"
|
|
6
2
|
|
|
3
|
+
import type {
|
|
4
|
+
AnimatedSidebarProviderProps,
|
|
5
|
+
SidebarSide,
|
|
6
|
+
} from "@/components/motion/animated-sidebar"
|
|
7
7
|
|
|
8
8
|
export interface ShellAction {
|
|
9
9
|
id: string
|
|
10
10
|
label: string
|
|
11
|
-
icon:
|
|
12
|
-
badge?:
|
|
11
|
+
icon: ReactNode
|
|
12
|
+
badge?: ReactNode
|
|
13
13
|
onSelect?: () => void
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface ShellWorkspace {
|
|
17
17
|
name: string
|
|
18
|
-
logo?:
|
|
19
|
-
meta?:
|
|
18
|
+
logo?: ReactNode
|
|
19
|
+
meta?: ReactNode
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export interface
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// workspace?: ShellWorkspace
|
|
28
|
-
//sidebarFooter?: React.ReactNode
|
|
29
|
-
// topActions?: ShellAction[]
|
|
30
|
-
//title?: React.ReactNode
|
|
31
|
-
//eyebrow?: React.ReactNode
|
|
32
|
-
//searchPlaceholder?: string
|
|
33
|
-
//searchShortcut?: React.ReactNode
|
|
34
|
-
open?: boolean
|
|
22
|
+
export interface IPanel {
|
|
23
|
+
id: string
|
|
24
|
+
name: string
|
|
25
|
+
component: ReactNode
|
|
26
|
+
open: boolean
|
|
35
27
|
defaultOpen?: boolean
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
46
90
|
className?: string
|
|
47
|
-
|
|
48
|
-
//sidebarClassName?: string
|
|
49
|
-
//contentClassName?: string
|
|
91
|
+
variant?: "web" | "desktop"
|
|
50
92
|
}
|