@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- overflowCount,
17
+ tabs,
17
18
  SecondaryPanel = <Strip />,
18
19
  }: {
19
- onClose?: () => void
20
20
  onSwap?: () => void
21
+ onCloseSecondary?: () => void
21
22
  onAdd?: () => void
22
- overflowCount?: number
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} overflowCount={overflowCount} />
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
- onClose={onClose}
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("close and menu buttons render only with their callbacks", async () => {
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 onClose={onClose} onSwap={() => {}} />)
79
- await user.click(screen.getByRole("button", { name: "Close panel" }))
80
- expect(onClose).toHaveBeenCalledTimes(1)
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 fires, overflow segment follows the count", async () => {
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
- const { rerender } = render(<Workspace onAdd={onAdd} />)
154
- expect(screen.queryByRole("button", { name: "Browse panels" })).toBeNull()
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
- // Zero overflowed panels: the segment shows without a badge.
160
- rerender(<Workspace overflowCount={0} onAdd={onAdd} />)
161
- expect(
162
- screen.getByRole("button", { name: "Browse panels" }).textContent
163
- ).toBe("")
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
- rerender(<Workspace overflowCount={2} onAdd={onAdd} />)
166
- const browse = screen.getByRole("button", { name: "Browse panels" })
167
- expect(browse.textContent).toContain("2")
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
- /** Menu segments blur/fade in place while the track morphs around them. The
24
- * exit is a fast tween, not the layout spring — the track stays
25
- * overflow-visible, so the popped-out buttons must be gone before the
26
- * collapsing pill leaves them behind as a ghost. */
27
- const MENU_SEGMENT_VARIANTS: Variants = {
28
- hidden: { opacity: 0, filter: "blur(4px)" },
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 swap affordance centered on the seam between a panel's primary
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 <body>.
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
- // Centered on the seam: secondary height (52px) + half the 10px gap.
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
- <AnimatePresence mode="popLayout" initial={false}>
167
- {expanded ? (
168
- <motion.div
169
- key="menu-actions"
170
- ref={actionsRef}
171
- id={actionsId}
172
- layout
173
- variants={MENU_SEGMENT_VARIANTS}
174
- initial={reduce ? { opacity: 0 } : "hidden"}
175
- animate={reduce ? { opacity: 1 } : "visible"}
176
- exit={reduce ? { opacity: 0 } : "exit"}
177
- transition={transition}
178
- className="relative flex w-max items-stretch"
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-expand"
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-swap"
206
+ data-slot="scaffold-panel-menu-close"
204
207
  className="rounded-l-none border-l-0"
205
- onClick={() => handleAction(onClick)}
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
- <More size={16} />
210
+ <LucideX size={16} />
211
+ Close
234
212
  </Button>
235
- </motion.div>
236
- )}
237
- </AnimatePresence>
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 { actionSegmentClass } from "./utils"
11
- import { GroupSeparator } from "@/components/ui/group"
12
- import { GlassButtonGroup } from "@/components/ui/glasscn/glass-button-group"
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": an Add segment plus an overflow segment
19
- * whose badge counts the non-visible panels. Slides left in step with the
20
- * inspector opening.
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 h-12",
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
- {children}
63
- <GlassButtonGroup glassVariant="liquid-refract" className="rounded-md">
64
- <Button
65
- onClick={onAdd}
66
- size="sm"
67
- variant="ghost"
68
- >
69
- {addIcon ?? <LucidePlus className="stroke-3" />}
70
- {addLabel}
71
- </Button>
72
- {!showBrowse && <GroupSeparator />}
73
- {showBrowse && (
74
- <button
75
- aria-label={browseLabel}
76
- className={cn(actionSegmentClass, "gap-0.5 px-2")}
77
- onClick={onBrowse}
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
- <span className="relative">
82
-
83
- {overflowCount !== undefined && overflowCount > 0 && (
84
- <span className="absolute -top-1.5 -right-1.5 flex size-3.5 items-center justify-center rounded-full bg-neutral-700 text-[9px] font-semibold text-white dark:bg-neutral-200 dark:text-neutral-900">
85
- {overflowCount}
86
- </span>
87
- )}
88
- </span>
89
-
90
- </button>
91
- )}
92
-
93
- {children}
94
- </GlassButtonGroup>
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
  }