@exegia/corpora-ui 0.18.0 → 0.19.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/scaffold/constants.d.ts +3 -0
- package/dist-lib/components/blocks/scaffold/index.d.ts +3 -1
- package/dist-lib/components/blocks/scaffold/panel-menu-button.d.ts +4 -3
- package/dist-lib/components/blocks/scaffold/scaffold-actions.d.ts +5 -4
- package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
- package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +9 -0
- package/dist-lib/components/blocks/scaffold/type.d.ts +30 -28
- package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
- package/dist-lib/index.js +870 -848
- package/dist-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +108 -25
- package/src/components/blocks/scaffold/constants.ts +4 -0
- package/src/components/blocks/scaffold/index.ts +4 -1
- package/src/components/blocks/scaffold/panel-menu-button.tsx +93 -115
- package/src/components/blocks/scaffold/scaffold-actions.tsx +41 -50
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +26 -3
- package/src/components/blocks/scaffold/scaffold-panel.tsx +15 -25
- package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-tab.tsx +66 -0
- package/src/components/blocks/scaffold/type.ts +34 -33
- package/src/components/blocks/scaffold/utils.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test"
|
|
2
2
|
import { render, screen, waitFor } from "@testing-library/react"
|
|
3
3
|
import userEvent from "@testing-library/user-event"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
4
5
|
|
|
5
6
|
import { Scaffold, useScaffold } from "../index"
|
|
6
7
|
import type { TScaffoldPanelChild } from "../type"
|
|
@@ -10,16 +11,17 @@ function Strip() {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
function Workspace({
|
|
13
|
-
onClose,
|
|
14
14
|
onSwap,
|
|
15
|
+
onCloseSecondary,
|
|
15
16
|
onAdd,
|
|
16
|
-
|
|
17
|
+
tabs,
|
|
17
18
|
SecondaryPanel = <Strip />,
|
|
18
19
|
}: {
|
|
19
|
-
onClose?: () => void
|
|
20
20
|
onSwap?: () => void
|
|
21
|
+
onCloseSecondary?: () => void
|
|
21
22
|
onAdd?: () => void
|
|
22
|
-
|
|
23
|
+
/** Rendered as Actions children — the panel tabs. */
|
|
24
|
+
tabs?: ReactNode
|
|
23
25
|
/** Pass null to render the panel without a secondary strip. */
|
|
24
26
|
SecondaryPanel?: TScaffoldPanelChild | null
|
|
25
27
|
}) {
|
|
@@ -33,12 +35,12 @@ function Workspace({
|
|
|
33
35
|
</button>
|
|
34
36
|
</Scaffold.Sidebar>
|
|
35
37
|
<Scaffold.Main>
|
|
36
|
-
<Scaffold.Actions onAdd={onAdd}
|
|
38
|
+
<Scaffold.Actions onAdd={onAdd}>{tabs}</Scaffold.Actions>
|
|
37
39
|
<Scaffold.Canvas>
|
|
38
40
|
<Scaffold.Panel
|
|
39
41
|
key="alpha"
|
|
40
42
|
name="Alpha"
|
|
41
|
-
|
|
43
|
+
onCloseSecondary={onCloseSecondary}
|
|
42
44
|
onSwap={onSwap}
|
|
43
45
|
SecondaryPanel={SecondaryPanel ?? undefined}
|
|
44
46
|
>
|
|
@@ -57,6 +59,12 @@ function Workspace({
|
|
|
57
59
|
const menuToggle = () =>
|
|
58
60
|
screen.queryByRole("button", { name: "Swap panel content" })
|
|
59
61
|
|
|
62
|
+
/** Under the id'd `#scaffold-actions` ancestor, happy-dom serves stale
|
|
63
|
+
* selector-cache results to `waitFor` queries that start while an
|
|
64
|
+
* AnimatePresence exit is still running — let the exit settle on real
|
|
65
|
+
* timers, then query once. */
|
|
66
|
+
const settleExit = () => new Promise((resolve) => setTimeout(resolve, 400))
|
|
67
|
+
|
|
60
68
|
describe("Scaffold", () => {
|
|
61
69
|
test("renders rail, panel surfaces and secondary strip", () => {
|
|
62
70
|
render(<Workspace />)
|
|
@@ -67,20 +75,40 @@ describe("Scaffold", () => {
|
|
|
67
75
|
expect(panel.textContent).toContain("Strip")
|
|
68
76
|
})
|
|
69
77
|
|
|
70
|
-
test("
|
|
71
|
-
const user = userEvent.setup()
|
|
72
|
-
const onClose = mock(() => {})
|
|
73
|
-
|
|
78
|
+
test("seam menu renders only with a callback; actions stay hidden until opened", () => {
|
|
74
79
|
const { rerender } = render(<Workspace />)
|
|
75
|
-
expect(screen.queryByRole("button", { name: "Close panel" })).toBeNull()
|
|
76
80
|
expect(menuToggle()).toBeNull()
|
|
77
81
|
|
|
78
|
-
rerender(<Workspace
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
rerender(<Workspace onSwap={() => {}} />)
|
|
83
|
+
expect(menuToggle()).toBeDefined()
|
|
84
|
+
// Collapsed: the action segment is aria-hidden and inert.
|
|
85
|
+
expect(screen.queryByRole("button", { name: "Swap" })).toBeNull()
|
|
86
|
+
|
|
87
|
+
// The Close action alone also earns the menu.
|
|
88
|
+
rerender(<Workspace onCloseSecondary={() => {}} />)
|
|
81
89
|
expect(menuToggle()).toBeDefined()
|
|
82
90
|
})
|
|
83
91
|
|
|
92
|
+
test("seam menu Close renders only with onCloseSecondary, fires and collapses", async () => {
|
|
93
|
+
const user = userEvent.setup()
|
|
94
|
+
const onCloseSecondary = mock(() => {})
|
|
95
|
+
|
|
96
|
+
const { rerender } = render(<Workspace onSwap={() => {}} />)
|
|
97
|
+
await user.click(menuToggle() as HTMLElement)
|
|
98
|
+
expect(await screen.findByRole("button", { name: "Swap" })).toBeDefined()
|
|
99
|
+
expect(screen.queryByRole("button", { name: "Close" })).toBeNull()
|
|
100
|
+
|
|
101
|
+
rerender(
|
|
102
|
+
<Workspace onCloseSecondary={onCloseSecondary} onSwap={() => {}} />
|
|
103
|
+
)
|
|
104
|
+
await user.click(screen.getByRole("button", { name: "Close" }))
|
|
105
|
+
expect(onCloseSecondary).toHaveBeenCalledTimes(1)
|
|
106
|
+
|
|
107
|
+
// Acting collapses the menu back to the compact toggle.
|
|
108
|
+
expect(menuToggle()).toBeDefined()
|
|
109
|
+
expect(screen.queryByRole("button", { name: "Close" })).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
84
112
|
test("seam menu opens; Swap fires onSwap, flips the panel and collapses the menu", async () => {
|
|
85
113
|
const user = userEvent.setup()
|
|
86
114
|
const onSwap = mock(() => {})
|
|
@@ -140,31 +168,86 @@ describe("Scaffold", () => {
|
|
|
140
168
|
expect(bottom.getAttribute("data-expanded")).toBeNull()
|
|
141
169
|
})
|
|
142
170
|
|
|
171
|
+
test("expand chevron mirrors when the panel is swapped", async () => {
|
|
172
|
+
const user = userEvent.setup()
|
|
173
|
+
render(<Workspace onSwap={() => {}} />)
|
|
174
|
+
|
|
175
|
+
// Swap first — column-reverse puts the strip visually on top.
|
|
176
|
+
await user.click(menuToggle() as HTMLElement)
|
|
177
|
+
await user.click(await screen.findByRole("button", { name: "Swap" }))
|
|
178
|
+
|
|
179
|
+
// Primary still holds the flexible slot, but the strip that grows
|
|
180
|
+
// next now sits on top — the chevron points up.
|
|
181
|
+
await user.click(
|
|
182
|
+
await screen.findByRole("button", { name: "Swap panel content" })
|
|
183
|
+
)
|
|
184
|
+
const expandButton = await screen.findByRole("button", { name: "Expand" })
|
|
185
|
+
const chevron = () => expandButton.querySelector("svg.lucide-chevron-down")
|
|
186
|
+
expect(chevron()?.classList.contains("rotate-180")).toBe(true)
|
|
187
|
+
|
|
188
|
+
// After expanding, the primary — now visually at the bottom — grows
|
|
189
|
+
// next: the chevron points down again.
|
|
190
|
+
await user.click(expandButton)
|
|
191
|
+
await user.click(
|
|
192
|
+
await screen.findByRole("button", { name: "Swap panel content" })
|
|
193
|
+
)
|
|
194
|
+
expect(chevron()?.classList.contains("rotate-180")).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
|
|
143
197
|
test("seam menu needs a secondary strip", () => {
|
|
144
198
|
render(<Workspace onSwap={() => {}} SecondaryPanel={null} />)
|
|
145
199
|
|
|
146
200
|
expect(menuToggle()).toBeNull()
|
|
147
201
|
})
|
|
148
202
|
|
|
149
|
-
test("actions cluster: Add
|
|
203
|
+
test("actions cluster: Add renders only with onAdd and fires", async () => {
|
|
150
204
|
const user = userEvent.setup()
|
|
151
205
|
const onAdd = mock(() => {})
|
|
152
206
|
|
|
153
|
-
|
|
154
|
-
|
|
207
|
+
// Consumers omit onAdd at SCAFFOLD_PANEL_CAPACITY — the segment hides.
|
|
208
|
+
const { rerender } = render(<Workspace />)
|
|
209
|
+
expect(screen.queryByRole("button", { name: "Panel" })).toBeNull()
|
|
155
210
|
|
|
211
|
+
rerender(<Workspace onAdd={onAdd} />)
|
|
156
212
|
await user.click(screen.getByRole("button", { name: "Panel" }))
|
|
157
213
|
expect(onAdd).toHaveBeenCalledTimes(1)
|
|
158
214
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
expect(
|
|
162
|
-
|
|
163
|
-
|
|
215
|
+
rerender(<Workspace />)
|
|
216
|
+
await settleExit()
|
|
217
|
+
expect(screen.queryByRole("button", { name: "Panel" })).toBeNull()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test("panel tabs show their label; close renders only with onClose and fires", async () => {
|
|
221
|
+
const user = userEvent.setup()
|
|
222
|
+
const onCloseTab = mock(() => {})
|
|
223
|
+
|
|
224
|
+
// Consumers omit onClose on the last remaining panel's tab.
|
|
225
|
+
const { rerender } = render(
|
|
226
|
+
<Workspace tabs={<Scaffold.Tab key="alpha">Alpha</Scaffold.Tab>} />
|
|
227
|
+
)
|
|
228
|
+
expect(screen.getByText("Alpha")).toBeDefined()
|
|
229
|
+
expect(screen.queryByRole("button", { name: "Close Alpha" })).toBeNull()
|
|
230
|
+
|
|
231
|
+
rerender(
|
|
232
|
+
<Workspace
|
|
233
|
+
tabs={
|
|
234
|
+
<Scaffold.Tab
|
|
235
|
+
key="alpha"
|
|
236
|
+
closeLabel="Close Alpha"
|
|
237
|
+
onClose={onCloseTab}
|
|
238
|
+
>
|
|
239
|
+
Alpha
|
|
240
|
+
</Scaffold.Tab>
|
|
241
|
+
}
|
|
242
|
+
/>
|
|
243
|
+
)
|
|
244
|
+
await user.click(screen.getByRole("button", { name: "Close Alpha" }))
|
|
245
|
+
expect(onCloseTab).toHaveBeenCalledTimes(1)
|
|
164
246
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
247
|
+
// Closing removes the tab — it animates out of the pill.
|
|
248
|
+
rerender(<Workspace tabs={null} />)
|
|
249
|
+
await settleExit()
|
|
250
|
+
expect(screen.queryByText("Alpha")).toBeNull()
|
|
168
251
|
})
|
|
169
252
|
|
|
170
253
|
test("inspector opens from outside state and hides again", async () => {
|
|
@@ -4,6 +4,10 @@ export const SCAFFOLD_SIDEBAR_WIDTH = 64
|
|
|
4
4
|
/** Inspector drawer width in px. */
|
|
5
5
|
export const SCAFFOLD_INSPECTOR_WIDTH = 272
|
|
6
6
|
|
|
7
|
+
/** Most panels the canvas holds at once — omit Actions' `onAdd` when the
|
|
8
|
+
* count reaches it so the Add segment hides. */
|
|
9
|
+
export const SCAFFOLD_PANEL_CAPACITY = 3
|
|
10
|
+
|
|
7
11
|
/** Gap in px between the drawer/actions and the viewport edges. */
|
|
8
12
|
export const SCAFFOLD_EDGE_GUTTER = 10
|
|
9
13
|
|
|
@@ -7,6 +7,7 @@ import { ScaffoldMain } from "./scaffold-main"
|
|
|
7
7
|
import { ScaffoldPanel } from "./scaffold-panel"
|
|
8
8
|
import { ScaffoldRoot } from "./scaffold-root"
|
|
9
9
|
import { ScaffoldSidebar } from "./scaffold-sidebar"
|
|
10
|
+
import { ScaffoldTab } from "./scaffold-tab"
|
|
10
11
|
import { ScaffoldSubPanel } from "@/components/blocks/scaffold/scaffold-sub-panel.tsx"
|
|
11
12
|
|
|
12
13
|
export { useScaffoldContext } from "./scaffold-context"
|
|
@@ -24,6 +25,7 @@ export {
|
|
|
24
25
|
ScaffoldPanel,
|
|
25
26
|
ScaffoldRoot,
|
|
26
27
|
ScaffoldSidebar,
|
|
28
|
+
ScaffoldTab,
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export const Scaffold = {
|
|
@@ -33,6 +35,7 @@ export const Scaffold = {
|
|
|
33
35
|
Actions: ScaffoldActions,
|
|
34
36
|
Canvas: ScaffoldCanvas,
|
|
35
37
|
Panel: ScaffoldPanel,
|
|
38
|
+
Tab: ScaffoldTab,
|
|
36
39
|
Inspector: ScaffoldInspector,
|
|
37
|
-
SubPanel: ScaffoldSubPanel
|
|
40
|
+
SubPanel: ScaffoldSubPanel,
|
|
38
41
|
}
|
|
@@ -2,61 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
AnimatePresence,
|
|
7
|
-
motion,
|
|
8
|
-
useReducedMotion,
|
|
9
|
-
type Variants,
|
|
10
|
-
} from "motion/react"
|
|
11
5
|
import { More } from "iconsax-reactjs"
|
|
12
|
-
import { LucideArrowUpDown, LucideChevronDown } from "lucide-react"
|
|
6
|
+
import { LucideArrowUpDown, LucideChevronDown, LucideX } from "lucide-react"
|
|
13
7
|
|
|
14
8
|
import { cn } from "@/lib/utils"
|
|
15
9
|
import type { PanelMenuButtonProps } from "./type"
|
|
16
10
|
import { floatingButtonClass, revealOnPanelHoverClass } from "./utils"
|
|
17
|
-
import { Button } from "@/components/ui/button"
|
|
11
|
+
import { Button } from "@/components/ui/button"
|
|
18
12
|
import { GlassButtonGroup } from "@/components/ui/glasscn/glass-button-group.tsx"
|
|
19
|
-
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease.ts"
|
|
20
13
|
import { useHoverCapable } from "@/lib/hooks/use-hover-capable"
|
|
21
14
|
import { SCAFFOLD_MENU_COLLAPSE_DELAY } from "./constants"
|
|
22
15
|
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
visible: { opacity: 1, filter: "blur(0px)" },
|
|
30
|
-
exit: {
|
|
31
|
-
opacity: 0,
|
|
32
|
-
filter: "blur(4px)",
|
|
33
|
-
transition: { duration: 0.18, ease: EASE_OUT },
|
|
34
|
-
},
|
|
35
|
-
}
|
|
16
|
+
/** One side of the seam menu. Both sides stay mounted; the pill morphs by
|
|
17
|
+
* collapsing one side's grid column to 0fr while the other grows — pure
|
|
18
|
+
* CSS, driven by `data-expanded` on the group. The hidden side is inert
|
|
19
|
+
* and aria-hidden so it can't be clicked, focused or found by queries. */
|
|
20
|
+
const menuCellClass =
|
|
21
|
+
"grid transition-[grid-template-columns,opacity] duration-300 ease-smooth-out motion-reduce:transition-none"
|
|
36
22
|
|
|
37
23
|
/**
|
|
38
|
-
* Floating
|
|
39
|
-
* surface and its secondary strip
|
|
24
|
+
* Floating menu affordance centered on the seam between a panel's primary
|
|
25
|
+
* surface and its secondary strip: a compact ⋯ toggle that morphs into the
|
|
26
|
+
* Expand | Swap (| Close) actions.
|
|
40
27
|
*/
|
|
41
28
|
export function PanelMenuButton({
|
|
42
29
|
onClick,
|
|
43
30
|
onExpand,
|
|
31
|
+
onCloseSecondary,
|
|
44
32
|
secondaryExpanded = false,
|
|
33
|
+
swapped = false,
|
|
45
34
|
label,
|
|
46
35
|
sound = true,
|
|
47
36
|
className,
|
|
48
37
|
}: PanelMenuButtonProps): React.ReactElement {
|
|
49
|
-
|
|
50
|
-
const reduce = useReducedMotion()
|
|
51
38
|
const canHover = useHoverCapable()
|
|
52
39
|
const actionsId = React.useId()
|
|
53
40
|
const groupRef = React.useRef<HTMLDivElement>(null)
|
|
54
41
|
const actionsRef = React.useRef<HTMLDivElement>(null)
|
|
55
|
-
const actionsLeftRef = React.useRef(0)
|
|
56
42
|
const [expanded, setExpanded] = React.useState(false);
|
|
57
43
|
|
|
58
|
-
const transition = reduce ? { duration: 0 } : SPRING_LAYOUT
|
|
59
|
-
|
|
60
44
|
const collapseTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
61
45
|
const suppressHoverOpenUntilRef = React.useRef(0)
|
|
62
46
|
const focusActionsOnOpenRef = React.useRef(false)
|
|
@@ -94,7 +78,7 @@ export function PanelMenuButton({
|
|
|
94
78
|
}, [expanded, canHover, armCollapse])
|
|
95
79
|
|
|
96
80
|
// Click-opens replace the toggle with the actions — hand focus over so
|
|
97
|
-
// keyboard users aren't dropped onto
|
|
81
|
+
// keyboard users aren't dropped onto an inert control.
|
|
98
82
|
React.useEffect(() => {
|
|
99
83
|
if (!expanded || !focusActionsOnOpenRef.current) return
|
|
100
84
|
focusActionsOnOpenRef.current = false
|
|
@@ -130,111 +114,105 @@ export function PanelMenuButton({
|
|
|
130
114
|
setExpanded(true)
|
|
131
115
|
}
|
|
132
116
|
|
|
133
|
-
// popLayout pops the exiting actions group out of the flow — pin it back to
|
|
134
|
-
// where it sat so it stays visually attached to the toggle while leaving.
|
|
135
|
-
React.useLayoutEffect(() => {
|
|
136
|
-
const actionsNode = actionsRef.current
|
|
137
|
-
if (!actionsNode) return
|
|
138
|
-
|
|
139
|
-
if (!expanded) {
|
|
140
|
-
actionsNode.style.left = `${
|
|
141
|
-
actionsLeftRef.current - actionsNode.getBoundingClientRect().left
|
|
142
|
-
}px`
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
actionsNode.style.left = ""
|
|
147
|
-
actionsLeftRef.current = actionsNode.getBoundingClientRect().left
|
|
148
|
-
}, [expanded])
|
|
149
|
-
|
|
150
117
|
return (
|
|
151
118
|
<GlassButtonGroup
|
|
152
119
|
ref={groupRef}
|
|
153
|
-
layout
|
|
154
|
-
transition={transition}
|
|
155
120
|
onPointerEnter={cancelCollapse}
|
|
156
121
|
onPointerLeave={armCollapse}
|
|
157
122
|
glassVariant="subtle"
|
|
123
|
+
data-expanded={expanded ? "" : undefined}
|
|
158
124
|
className={cn(
|
|
159
125
|
floatingButtonClass,
|
|
160
126
|
revealOnPanelHoverClass,
|
|
161
|
-
|
|
162
|
-
"absolute top-1/2 z-10 -translate-y-1/2 overflow-visible!",
|
|
127
|
+
"group/menu absolute top-1/2 z-10 -translate-y-1/2",
|
|
163
128
|
className
|
|
164
129
|
)}
|
|
165
130
|
>
|
|
166
|
-
<
|
|
167
|
-
{expanded
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
131
|
+
<div
|
|
132
|
+
aria-hidden={expanded || undefined}
|
|
133
|
+
inert={expanded || undefined}
|
|
134
|
+
className={cn(
|
|
135
|
+
menuCellClass,
|
|
136
|
+
"grid-cols-[1fr] opacity-100 group-data-expanded/menu:grid-cols-[0fr] group-data-expanded/menu:opacity-0"
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<div className="flex min-w-0 overflow-hidden">
|
|
140
|
+
<Button
|
|
141
|
+
id="scaffold-panel-menu-toggle-button"
|
|
142
|
+
aria-label={label}
|
|
143
|
+
aria-expanded={expanded}
|
|
144
|
+
aria-controls={actionsId}
|
|
145
|
+
size="icon-sm"
|
|
146
|
+
variant="ghost"
|
|
147
|
+
sound={sound}
|
|
148
|
+
data-slot="scaffold-panel-menu-toggle"
|
|
149
|
+
onClick={handleOnExpand}
|
|
150
|
+
onPointerEnter={handleToggleHover}
|
|
179
151
|
>
|
|
152
|
+
<More size={16} />
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div
|
|
157
|
+
id={actionsId}
|
|
158
|
+
ref={actionsRef}
|
|
159
|
+
aria-hidden={!expanded || undefined}
|
|
160
|
+
inert={!expanded || undefined}
|
|
161
|
+
className={cn(
|
|
162
|
+
menuCellClass,
|
|
163
|
+
"grid-cols-[0fr] opacity-0 group-data-expanded/menu:grid-cols-[1fr] group-data-expanded/menu:opacity-100"
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
<div className="flex min-w-0 overflow-hidden">
|
|
167
|
+
<Button
|
|
168
|
+
id="scaffold-panel-menu-expand"
|
|
169
|
+
size="sm"
|
|
170
|
+
variant="ghost"
|
|
171
|
+
sound={sound}
|
|
172
|
+
data-slot="scaffold-panel-menu-expand"
|
|
173
|
+
className="rounded-r-none"
|
|
174
|
+
onClick={() => handleAction(onExpand)}
|
|
175
|
+
>
|
|
176
|
+
{/* Points at the sub-panel that grows next, in VISUAL space —
|
|
177
|
+
a swapped panel is column-reverse, so the direction mirrors.
|
|
178
|
+
Button's [&_svg]:transition-transform eases the flip. */}
|
|
179
|
+
<LucideChevronDown
|
|
180
|
+
size={16}
|
|
181
|
+
className={cn(secondaryExpanded !== swapped && "rotate-180")}
|
|
182
|
+
/>
|
|
183
|
+
Expand
|
|
184
|
+
</Button>
|
|
185
|
+
<Button
|
|
186
|
+
id="scaffold-panel-menu-swap"
|
|
187
|
+
size="sm"
|
|
188
|
+
variant="ghost"
|
|
189
|
+
sound={sound}
|
|
190
|
+
data-slot="scaffold-panel-menu-swap"
|
|
191
|
+
className={cn(
|
|
192
|
+
"border-l-0",
|
|
193
|
+
onCloseSecondary ? "rounded-none" : "rounded-l-none"
|
|
194
|
+
)}
|
|
195
|
+
onClick={() => handleAction(onClick)}
|
|
196
|
+
>
|
|
197
|
+
<LucideArrowUpDown size={16} />
|
|
198
|
+
Swap
|
|
199
|
+
</Button>
|
|
200
|
+
{onCloseSecondary && (
|
|
180
201
|
<Button
|
|
181
|
-
id="scaffold-panel-menu-
|
|
182
|
-
size="sm"
|
|
183
|
-
variant="ghost"
|
|
184
|
-
sound={sound}
|
|
185
|
-
data-slot="scaffold-panel-menu-swap"
|
|
186
|
-
// The motion wrapper hides these from the group's corner
|
|
187
|
-
// selectors — restore what ButtonGroup would have applied.
|
|
188
|
-
className="rounded-r-none"
|
|
189
|
-
onClick={() => handleAction(onExpand)}
|
|
190
|
-
>
|
|
191
|
-
{/* Button's [&_svg]:transition-transform eases the flip. */}
|
|
192
|
-
<LucideChevronDown
|
|
193
|
-
size={16}
|
|
194
|
-
className={cn(secondaryExpanded && "rotate-180")}
|
|
195
|
-
/>
|
|
196
|
-
Expand
|
|
197
|
-
</Button>
|
|
198
|
-
<Button
|
|
199
|
-
id="scaffold-panel-menu-swap"
|
|
202
|
+
id="scaffold-panel-menu-close"
|
|
200
203
|
size="sm"
|
|
201
204
|
variant="ghost"
|
|
202
205
|
sound={sound}
|
|
203
|
-
data-slot="scaffold-panel-menu-
|
|
206
|
+
data-slot="scaffold-panel-menu-close"
|
|
204
207
|
className="rounded-l-none border-l-0"
|
|
205
|
-
onClick={() => handleAction(
|
|
206
|
-
>
|
|
207
|
-
<LucideArrowUpDown size={16} />
|
|
208
|
-
Swap
|
|
209
|
-
</Button>
|
|
210
|
-
</motion.div>
|
|
211
|
-
) : (
|
|
212
|
-
<motion.div
|
|
213
|
-
key="menu-toggle"
|
|
214
|
-
layout
|
|
215
|
-
variants={MENU_SEGMENT_VARIANTS}
|
|
216
|
-
initial={reduce ? { opacity: 0 } : "hidden"}
|
|
217
|
-
animate={reduce ? { opacity: 1 } : "visible"}
|
|
218
|
-
exit={reduce ? { opacity: 0 } : "exit"}
|
|
219
|
-
transition={transition}
|
|
220
|
-
className="flex shrink-0"
|
|
221
|
-
>
|
|
222
|
-
<Button
|
|
223
|
-
id="scaffold-panel-menu-toggle-button"
|
|
224
|
-
aria-label={label}
|
|
225
|
-
aria-expanded={expanded}
|
|
226
|
-
size="icon-sm"
|
|
227
|
-
variant="ghost"
|
|
228
|
-
sound={sound}
|
|
229
|
-
data-slot="scaffold-panel-menu-toggle"
|
|
230
|
-
onClick={handleOnExpand}
|
|
231
|
-
onPointerEnter={handleToggleHover}
|
|
208
|
+
onClick={() => handleAction(onCloseSecondary)}
|
|
232
209
|
>
|
|
233
|
-
<
|
|
210
|
+
<LucideX size={16} />
|
|
211
|
+
Close
|
|
234
212
|
</Button>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
</
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
238
216
|
</GlassButtonGroup>
|
|
239
217
|
)
|
|
240
218
|
}
|
|
@@ -1,31 +1,28 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { motion, useReducedMotion } from "motion/react"
|
|
3
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
|
|
4
4
|
import * as React from "react"
|
|
5
5
|
|
|
6
6
|
import { cn } from "@/lib/utils"
|
|
7
7
|
import { SCAFFOLD_EASE, SCAFFOLD_EDGE_GUTTER, SCAFFOLD_MORPH_DURATION } from "./constants"
|
|
8
8
|
import { useScaffoldContext } from "./scaffold-context"
|
|
9
9
|
import type { ScaffoldActionsProps } from "./type"
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { segmentVariants } from "./utils"
|
|
11
|
+
import { Button } from "@/components/ui/button"
|
|
12
|
+
import { SPRING_LAYOUT } from "@/lib/ease.ts"
|
|
14
13
|
import { LucidePlus } from "lucide-react"
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* The floating pill cluster in the main region's top-right corner — the
|
|
18
|
-
* design's "Panel Context Menu":
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* design's "Panel Context Menu": one tab per open panel (`Scaffold.Tab`
|
|
18
|
+
* children) plus an Add segment. Add renders only while `onAdd` is
|
|
19
|
+
* present — omit it once the canvas holds `SCAFFOLD_PANEL_CAPACITY`
|
|
20
|
+
* panels. Slides left in step with the inspector opening.
|
|
21
21
|
*/
|
|
22
22
|
export function ScaffoldActions({
|
|
23
23
|
onAdd,
|
|
24
24
|
addLabel = "Panel",
|
|
25
25
|
addIcon,
|
|
26
|
-
overflowCount,
|
|
27
|
-
onBrowse,
|
|
28
|
-
browseLabel = "Browse panels",
|
|
29
26
|
sound = true,
|
|
30
27
|
className,
|
|
31
28
|
children,
|
|
@@ -33,12 +30,7 @@ export function ScaffoldActions({
|
|
|
33
30
|
}: ScaffoldActionsProps): React.ReactElement {
|
|
34
31
|
const { inspectorOpen, inspectorWidth } = useScaffoldContext()
|
|
35
32
|
const reducedMotion = useReducedMotion()
|
|
36
|
-
|
|
37
|
-
const soundProps = {
|
|
38
|
-
"data-cuelume-press": sound ? "" : undefined,
|
|
39
|
-
"data-cuelume-release": sound ? "" : undefined,
|
|
40
|
-
}
|
|
41
|
-
const showBrowse = overflowCount !== undefined || onBrowse !== undefined
|
|
33
|
+
const transition = reducedMotion ? { duration: 0 } : SPRING_LAYOUT
|
|
42
34
|
|
|
43
35
|
return (
|
|
44
36
|
<motion.div
|
|
@@ -47,7 +39,7 @@ export function ScaffoldActions({
|
|
|
47
39
|
x: inspectorOpen ? -(inspectorWidth + SCAFFOLD_EDGE_GUTTER) : 0,
|
|
48
40
|
}}
|
|
49
41
|
className={cn(
|
|
50
|
-
"sticky inline-flex items-center gap-0.5
|
|
42
|
+
"sticky inline-flex h-12 items-center gap-0.5",
|
|
51
43
|
"justify-end",
|
|
52
44
|
className
|
|
53
45
|
)}
|
|
@@ -59,39 +51,38 @@ export function ScaffoldActions({
|
|
|
59
51
|
}}
|
|
60
52
|
{...rest}
|
|
61
53
|
>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
type="button"
|
|
79
|
-
{...soundProps}
|
|
54
|
+
<motion.div
|
|
55
|
+
layout
|
|
56
|
+
transition={transition}
|
|
57
|
+
className="flex items-center gap-1.5"
|
|
58
|
+
>
|
|
59
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
60
|
+
{onAdd && (
|
|
61
|
+
<motion.div
|
|
62
|
+
key="scaffold-add"
|
|
63
|
+
layout
|
|
64
|
+
animate={reducedMotion ? { opacity: 1 } : "visible"}
|
|
65
|
+
className="flex shrink-0"
|
|
66
|
+
exit={reducedMotion ? { opacity: 0 } : "exit"}
|
|
67
|
+
initial={reducedMotion ? { opacity: 0 } : "hidden"}
|
|
68
|
+
transition={transition}
|
|
69
|
+
variants={segmentVariants}
|
|
80
70
|
>
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
71
|
+
<Button
|
|
72
|
+
onClick={onAdd}
|
|
73
|
+
size="sm"
|
|
74
|
+
sound={sound}
|
|
75
|
+
variant="ghost"
|
|
76
|
+
className="h-full gap-2 rounded-sm py-1.5 pr-3 pl-2"
|
|
77
|
+
>
|
|
78
|
+
{addIcon ?? <LucidePlus className="stroke-2" />}
|
|
79
|
+
{addLabel}
|
|
80
|
+
</Button>
|
|
81
|
+
</motion.div>
|
|
82
|
+
)}
|
|
83
|
+
{children}
|
|
84
|
+
</AnimatePresence>
|
|
85
|
+
</motion.div>
|
|
95
86
|
</motion.div>
|
|
96
87
|
)
|
|
97
88
|
}
|