@moises.ai/design-system 3.5.7 → 3.6.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": "@moises.ai/design-system",
3
- "version": "3.5.7",
3
+ "version": "3.6.0",
4
4
  "description": "Design System package based on @radix-ui/themes with custom defaults",
5
5
  "private": false,
6
6
  "type": "module",
@@ -72,7 +72,8 @@
72
72
  "react-dom": "^19.0.0",
73
73
  "storybook": "^8.6.14",
74
74
  "vite": "^5.1.0",
75
- "vite-plugin-css-injected-by-js": "^3.5.2"
75
+ "vite-plugin-css-injected-by-js": "^3.5.2",
76
+ "zod": "^4.3.6"
76
77
  },
77
78
  "publishConfig": {
78
79
  "access": "public"
@@ -0,0 +1,317 @@
1
+ import { Children, createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
2
+ import { Flex, Heading, IconButton } from '../../index'
3
+ import { ArrowLeftIcon, Cross1Icon } from '../../icons'
4
+
5
+ const ExtensionContext = createContext(null)
6
+ const NavigationContext = createContext(null)
7
+ const ScreenContext = createContext(null)
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Hooks
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Returns the `moises` object from the nearest `<Extension>` ancestor.
15
+ * @returns {object} The moises extension API instance.
16
+ */
17
+ export function useExtension() {
18
+ return useContext(ExtensionContext)
19
+ }
20
+
21
+ /**
22
+ * Stack-level navigation controls.
23
+ * Works anywhere inside an `<Extension>` with `initialScreen`.
24
+ */
25
+ export function useNavigation() {
26
+ const ctx = useContext(NavigationContext)
27
+ if (!ctx) throw new Error('useNavigation must be used inside an <Extension> with initialScreen')
28
+ return ctx
29
+ }
30
+
31
+ /**
32
+ * Screen-specific context (name, params, setOptions).
33
+ * Only available inside a component rendered by `<Extension.Screen>`.
34
+ */
35
+ export function useScreen() {
36
+ const ctx = useContext(ScreenContext)
37
+ if (!ctx) throw new Error('useScreen must be used inside an <Extension.Screen> component')
38
+ return ctx
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Sub-components
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Fixed header bar pinned to the top of the extension panel.
47
+ *
48
+ * @param {object} props
49
+ * @param {string} props.title - Heading text displayed in the header.
50
+ * @param {Function} [props.onBack] - Callback fired when the back button is clicked.
51
+ * When provided, an arrow-left `IconButton` is rendered on the left side.
52
+ * @param {React.ReactNode} [props.headerLeft] - Custom left-side content. Replaces the
53
+ * default back button when provided (even as `null`).
54
+ * @param {React.ReactNode} [props.headerRight] - Right-side action content. Takes priority
55
+ * over `children` when both are provided.
56
+ * @param {boolean} [props.allowClose=true] - Whether to show the close (x) button on the right.
57
+ * @param {Function} [props.onClose] - Custom close handler. Defaults to `moises.extension.close()`.
58
+ * @param {React.ReactNode} [props.children] - Action elements rendered between the title and the close button.
59
+ */
60
+ function Header({ title, onBack, headerLeft, headerRight, allowClose = true, onClose, children }) {
61
+ const moises = useContext(ExtensionContext)
62
+
63
+ const handleClose = onClose || (() => moises?.extension?.close?.())
64
+ const rightContent = headerRight ?? children
65
+
66
+ return (
67
+ <Flex
68
+ align="center"
69
+ gap="1"
70
+ mx="4"
71
+ py="3"
72
+ style={{ borderBottom: '1px solid var(--gray-a4)' }}
73
+ >
74
+ {headerLeft !== undefined ? (
75
+ headerLeft
76
+ ) : onBack ? (
77
+ <IconButton variant="ghost" color="gray" size="1" onClick={onBack}>
78
+ <ArrowLeftIcon />
79
+ </IconButton>
80
+ ) : null}
81
+ <Flex align="center" gap="2" width="100%" style={{ flex: 2 }}>
82
+ <Heading
83
+ size="3"
84
+ weight="medium"
85
+ style={{ width: '100%', minWidth: 0 }}
86
+ trim="both"
87
+ >
88
+ {title}
89
+ </Heading>
90
+ </Flex>
91
+ {rightContent && (
92
+ <Flex align="center" gap="1" justify="end" style={{ flex: 1, flexShrink: 0 }}>
93
+ {rightContent}
94
+ </Flex>
95
+ )}
96
+ {allowClose && (
97
+ <IconButton variant="ghost" color="gray" size="1" onClick={handleClose}>
98
+ <Cross1Icon />
99
+ </IconButton>
100
+ )}
101
+ </Flex>
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Scrollable content area that fills all remaining vertical space.
107
+ *
108
+ * @param {object} props
109
+ * @param {React.ReactNode} props.children - Content to render inside the scrollable area.
110
+ */
111
+ function Content({ children }) {
112
+ return (
113
+ <div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
114
+ <Flex direction="column" p="4" gap="3">
115
+ {children}
116
+ </Flex>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Fixed footer bar pinned to the bottom of the extension panel.
123
+ * Only renders when children are provided.
124
+ *
125
+ * @param {object} props
126
+ * @param {React.ReactNode} [props.children] - Footer content (e.g. action buttons).
127
+ */
128
+ function Footer({ children }) {
129
+ if (!children) return null
130
+
131
+ return (
132
+ <Flex
133
+ align="center"
134
+ justify="center"
135
+ mx="4"
136
+ py="3"
137
+ >
138
+ {children}
139
+ </Flex>
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Declarative screen registration. Renders nothing — the navigator
145
+ * controls mounting based on the active stack.
146
+ *
147
+ * @param {object} props
148
+ * @param {string} props.name - Unique screen identifier for navigation.
149
+ * @param {string} props.title - Default header title.
150
+ * @param {React.ComponentType} props.component - Component to render when active.
151
+ * @param {React.ReactNode} [props.headerRight] - Default right-side header content.
152
+ * @param {React.ReactNode} [props.headerLeft] - Override for the default back button.
153
+ * @param {boolean} [props.allowClose=true] - Whether to show the close (x) button.
154
+ * @param {Function} [props.onClose] - Custom close handler.
155
+ */
156
+ function Screen() {
157
+ return null
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Navigator (internal)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ const ANIM_DURATION = 200
165
+ const ANIM_DISTANCE = 80
166
+
167
+ function animateTransition(el, direction) {
168
+ if (!el) return
169
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
170
+ el.getAnimations().forEach((a) => a.cancel())
171
+ const x = direction === 'forward' ? ANIM_DISTANCE : -ANIM_DISTANCE
172
+ el.animate(
173
+ [
174
+ { opacity: 0, transform: `translateX(${x}px)` },
175
+ { opacity: 1, transform: 'translateX(0)' },
176
+ ],
177
+ { duration: reduced ? 150 : ANIM_DURATION, easing: 'ease-out' },
178
+ )
179
+ }
180
+
181
+ function Navigator({ initialScreen, animated, children }) {
182
+ const [stack, setStack] = useState([{ name: initialScreen, params: {} }])
183
+ const [screenOptions, setScreenOptions] = useState({})
184
+ const [gen, setGen] = useState(0)
185
+ const stackRef = useRef(stack)
186
+ const wrapperRef = useRef(null)
187
+ stackRef.current = stack
188
+
189
+ const screens = useMemo(() => {
190
+ const map = {}
191
+ Children.forEach(children, (child) => {
192
+ if (child?.type === Screen) map[child.props.name] = child.props
193
+ })
194
+ return map
195
+ }, [children])
196
+
197
+ const navigate = useCallback((updater, dir = null) => {
198
+ if (animated && dir) animateTransition(wrapperRef.current, dir)
199
+ setStack(updater)
200
+ setScreenOptions({})
201
+ setGen((g) => g + 1)
202
+ }, [animated])
203
+
204
+ const push = useCallback(
205
+ (name, params = {}) => navigate((prev) => [...prev, { name, params }], 'forward'),
206
+ [navigate],
207
+ )
208
+
209
+ const pop = useCallback(
210
+ () => navigate((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev), 'back'),
211
+ [navigate],
212
+ )
213
+
214
+ const popTo = useCallback(
215
+ (name) =>
216
+ navigate((prev) => {
217
+ const idx = prev.findIndex((e) => e.name === name)
218
+ if (idx === -1) throw new Error(`Screen "${name}" is not in the stack`)
219
+ return prev.slice(0, idx + 1)
220
+ }, 'back'),
221
+ [navigate],
222
+ )
223
+
224
+ const reset = useCallback(
225
+ (name) => navigate(() => [{ name: name || initialScreen, params: {} }]),
226
+ [navigate, initialScreen],
227
+ )
228
+
229
+ const canGoBack = stack.length > 1
230
+
231
+ const navigation = useMemo(
232
+ () => ({ push, pop, popTo, reset, canGoBack, stack }),
233
+ [push, pop, popTo, reset, canGoBack, stack],
234
+ )
235
+
236
+ const current = stack[stack.length - 1]
237
+ const def = screens[current.name]
238
+ if (!def) return null
239
+
240
+ const opt = (key, fallback) => (key in screenOptions ? screenOptions[key] : (def[key] ?? fallback))
241
+ const Component = def.component
242
+
243
+ const handleSetOptions = useCallback(
244
+ (opts) => {
245
+ const top = stackRef.current[stackRef.current.length - 1]
246
+ if (top.name === current.name) setScreenOptions((prev) => ({ ...prev, ...opts }))
247
+ },
248
+ [current.name],
249
+ )
250
+
251
+ const screenCtx = useMemo(
252
+ () => ({ name: current.name, params: current.params || {}, setOptions: handleSetOptions }),
253
+ [current.name, current.params, handleSetOptions],
254
+ )
255
+
256
+ return (
257
+ <NavigationContext.Provider value={navigation}>
258
+ <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
259
+ <Header
260
+ title={opt('title')}
261
+ onBack={canGoBack ? pop : undefined}
262
+ headerLeft={opt('headerLeft')}
263
+ headerRight={opt('headerRight')}
264
+ allowClose={opt('allowClose', true)}
265
+ onClose={opt('onClose')}
266
+ />
267
+ <div
268
+ ref={wrapperRef}
269
+ style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'hidden' }}
270
+ >
271
+ <ScreenContext.Provider value={screenCtx} key={gen}>
272
+ <Component />
273
+ </ScreenContext.Provider>
274
+ </div>
275
+ </div>
276
+ </NavigationContext.Provider>
277
+ )
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Extension
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Root layout component for extension panels.
286
+ * Provides the `moises` API via context and renders a full-height flex column.
287
+ *
288
+ * When `initialScreen` is provided, enters navigator mode: scans children for
289
+ * `<Extension.Screen>` elements and manages a navigation stack automatically.
290
+ * Otherwise, passes children through as-is (simple mode).
291
+ *
292
+ * @param {object} props
293
+ * @param {object} props.moises - The moises extension API instance.
294
+ * @param {string} [props.initialScreen] - Activates navigator mode with this screen.
295
+ * @param {boolean} [props.animated] - Enable slide+fade transitions (navigator mode only).
296
+ * @param {React.ReactNode} props.children - Extension sub-components or Screen elements.
297
+ */
298
+ function Extension({ moises, initialScreen, animated, children }) {
299
+ return (
300
+ <ExtensionContext.Provider value={moises}>
301
+ <Flex direction="column" style={{ height: '100%' }}>
302
+ {initialScreen ? (
303
+ <Navigator initialScreen={initialScreen} animated={animated}>
304
+ {children}
305
+ </Navigator>
306
+ ) : children}
307
+ </Flex>
308
+ </ExtensionContext.Provider>
309
+ )
310
+ }
311
+
312
+ Extension.Header = Header
313
+ Extension.Content = Content
314
+ Extension.Footer = Footer
315
+ Extension.Screen = Screen
316
+
317
+ export { Extension }
@@ -0,0 +1,368 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Badge, Button, Card, Flex, IconButton, Text } from '../../index'
3
+ import { DotsVerticalIcon } from '../../icons'
4
+ import { Extension, useNavigation, useScreen } from './Extension'
5
+
6
+ const mockMoises = {
7
+ extension: { close: () => console.log('extension.close()') },
8
+ }
9
+
10
+ const sidebarDecorator = (Story) => (
11
+ <div
12
+ style={{
13
+ width: 300,
14
+ height: 500,
15
+ border: '1px solid var(--gray-a6)',
16
+ borderRadius: 8,
17
+ overflow: 'hidden',
18
+ display: 'flex',
19
+ flexDirection: 'column',
20
+ }}
21
+ >
22
+ <Story />
23
+ </div>
24
+ )
25
+
26
+ export default {
27
+ title: 'Extensions/Extension',
28
+ decorators: [sidebarDecorator],
29
+ parameters: { layout: 'centered' },
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Simple mode (flat layout, no navigation)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export const Simple = {
37
+ name: 'Simple (Header + Content + Footer)',
38
+ render: () => (
39
+ <Extension moises={mockMoises}>
40
+ <Extension.Header title="My Extension">
41
+ <Button variant="ghost" size="1">Action</Button>
42
+ </Extension.Header>
43
+ <Extension.Content>
44
+ <Card>Card content 1</Card>
45
+ <Card>Card content 2</Card>
46
+ <Card>Card content 3</Card>
47
+ </Extension.Content>
48
+ <Extension.Footer>
49
+ <Button variant="ghost" size="1">Footer Action</Button>
50
+ </Extension.Footer>
51
+ </Extension>
52
+ ),
53
+ }
54
+
55
+ export const HeaderOnly = {
56
+ name: 'Header Only',
57
+ render: () => (
58
+ <Extension moises={mockMoises}>
59
+ <Extension.Header title="Minimal" />
60
+ <Extension.Content>
61
+ <Text size="2">Content with a simple header above.</Text>
62
+ </Extension.Content>
63
+ </Extension>
64
+ ),
65
+ }
66
+
67
+ export const HeaderWithBackButton = {
68
+ name: 'Header with Back Button',
69
+ render: () => (
70
+ <Extension moises={mockMoises}>
71
+ <Extension.Header title="Detail View" onBack={() => console.log('onBack')} />
72
+ <Extension.Content>
73
+ <Text size="2">Passing `onBack` renders a back arrow on the left.</Text>
74
+ </Extension.Content>
75
+ </Extension>
76
+ ),
77
+ }
78
+
79
+ export const HeaderCustomLeft = {
80
+ name: 'Header with Custom Left',
81
+ render: () => (
82
+ <Extension moises={mockMoises}>
83
+ <Extension.Header
84
+ title="Custom Left"
85
+ headerLeft={<Badge variant="soft" size="1">v2</Badge>}
86
+ />
87
+ <Extension.Content>
88
+ <Text size="2">`headerLeft` replaces the default back button.</Text>
89
+ </Extension.Content>
90
+ </Extension>
91
+ ),
92
+ }
93
+
94
+ export const HeaderCustomRight = {
95
+ name: 'Header with Custom Right',
96
+ render: () => (
97
+ <Extension moises={mockMoises}>
98
+ <Extension.Header
99
+ title="Options"
100
+ headerRight={
101
+ <IconButton variant="ghost" color="gray" size="1">
102
+ <DotsVerticalIcon width={16} />
103
+ </IconButton>
104
+ }
105
+ />
106
+ <Extension.Content>
107
+ <Text size="2">`headerRight` adds actions before the close button.</Text>
108
+ </Extension.Content>
109
+ </Extension>
110
+ ),
111
+ }
112
+
113
+ export const HeaderNoClose = {
114
+ name: 'Header without Close Button',
115
+ render: () => (
116
+ <Extension moises={mockMoises}>
117
+ <Extension.Header title="No Close" allowClose={false} />
118
+ <Extension.Content>
119
+ <Text size="2">Close button hidden via `allowClose={'{false}'}`.</Text>
120
+ </Extension.Content>
121
+ </Extension>
122
+ ),
123
+ }
124
+
125
+ export const ScrollableContent = {
126
+ name: 'Scrollable Content',
127
+ render: () => (
128
+ <Extension moises={mockMoises}>
129
+ <Extension.Header title="Scrollable" />
130
+ <Extension.Content>
131
+ {Array.from({ length: 20 }, (_, i) => (
132
+ <Card key={i}>Item {i + 1}</Card>
133
+ ))}
134
+ </Extension.Content>
135
+ <Extension.Footer>
136
+ <Text size="1" color="gray">Footer stays pinned</Text>
137
+ </Extension.Footer>
138
+ </Extension>
139
+ ),
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Navigator mode (screen stack with push/pop)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ const ITEMS = [
147
+ { id: 'drums', name: 'Drums', desc: 'Acoustic drum kit with multiple mic positions' },
148
+ { id: 'bass', name: 'Bass', desc: 'Electric bass recorded through DI and amp' },
149
+ { id: 'guitar', name: 'Guitar', desc: 'Clean electric guitar with stereo chorus' },
150
+ { id: 'vocals', name: 'Vocals', desc: 'Lead vocals with studio compression' },
151
+ { id: 'keys', name: 'Keys', desc: 'Rhodes piano through a rotary speaker sim' },
152
+ ]
153
+
154
+ function HomeScreen() {
155
+ const { push } = useNavigation()
156
+
157
+ return (
158
+ <>
159
+ <Extension.Content>
160
+ {ITEMS.map((item) => (
161
+ <Card
162
+ key={item.id}
163
+ style={{ cursor: 'pointer' }}
164
+ onClick={() => push('detail', { itemId: item.id, itemName: item.name })}
165
+ >
166
+ <Flex direction="column" gap="1">
167
+ <Text size="2" weight="medium">{item.name}</Text>
168
+ <Text size="1" color="gray">{item.desc}</Text>
169
+ </Flex>
170
+ </Card>
171
+ ))}
172
+ </Extension.Content>
173
+ <Extension.Footer>
174
+ <Button variant="ghost" size="1" onClick={() => push('settings')}>
175
+ View Stack
176
+ </Button>
177
+ </Extension.Footer>
178
+ </>
179
+ )
180
+ }
181
+
182
+ function DetailScreen() {
183
+ const { push } = useNavigation()
184
+ const { params, setOptions } = useScreen()
185
+ const item = ITEMS.find((i) => i.id === params.itemId)
186
+
187
+ useEffect(() => {
188
+ setOptions({ title: params.itemName })
189
+ }, [params.itemName])
190
+
191
+ if (!item) {
192
+ return (
193
+ <Extension.Content>
194
+ <Text size="2" color="gray">Item not found.</Text>
195
+ </Extension.Content>
196
+ )
197
+ }
198
+
199
+ return (
200
+ <Extension.Content>
201
+ <Flex direction="column" gap="2">
202
+ <Text size="2" weight="medium">{item.name}</Text>
203
+ <Text size="2" color="gray">{item.desc}</Text>
204
+ </Flex>
205
+ <Button variant="soft" size="2" onClick={() => push('editor', { itemId: item.id })}>
206
+ Open Editor
207
+ </Button>
208
+ </Extension.Content>
209
+ )
210
+ }
211
+
212
+ function EditorScreen() {
213
+ const { setOptions } = useScreen()
214
+ const [dirty, setDirty] = useState(false)
215
+
216
+ useEffect(() => {
217
+ setOptions({
218
+ headerRight: dirty ? (
219
+ <Button variant="soft" size="1" onClick={() => setDirty(false)}>Save</Button>
220
+ ) : null,
221
+ })
222
+ }, [dirty])
223
+
224
+ return (
225
+ <Extension.Content>
226
+ <Text size="2" color="gray">
227
+ {dirty ? 'You have unsaved changes.' : 'No changes yet.'}
228
+ </Text>
229
+ {!dirty && (
230
+ <Button variant="soft" size="2" onClick={() => setDirty(true)}>
231
+ Make a change
232
+ </Button>
233
+ )}
234
+ </Extension.Content>
235
+ )
236
+ }
237
+
238
+ function SettingsScreen() {
239
+ const { stack } = useNavigation()
240
+
241
+ return (
242
+ <Extension.Content>
243
+ <Text size="2" weight="medium">Navigation Stack</Text>
244
+ <Flex direction="column" gap="1">
245
+ {stack.map((entry, i) => (
246
+ <Flex key={i} align="center" gap="2">
247
+ <Badge variant="soft" size="1">{i}</Badge>
248
+ <Text size="1">{entry.name}</Text>
249
+ </Flex>
250
+ ))}
251
+ </Flex>
252
+ </Extension.Content>
253
+ )
254
+ }
255
+
256
+ export const NavigatorFull = {
257
+ name: 'Navigator (Multi-Screen)',
258
+ render: () => (
259
+ <Extension moises={mockMoises} initialScreen="home" animated>
260
+ <Extension.Screen name="home" title="Navigator Demo" component={HomeScreen}
261
+ headerRight={
262
+ <IconButton variant="ghost" color="gray" size="1">
263
+ <DotsVerticalIcon width={16} />
264
+ </IconButton>
265
+ }
266
+ />
267
+ <Extension.Screen name="detail" title="Detail" component={DetailScreen} />
268
+ <Extension.Screen name="editor" title="Editor" component={EditorScreen} />
269
+ <Extension.Screen name="settings" title="Settings" component={SettingsScreen} />
270
+ </Extension>
271
+ ),
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Navigator — minimal two-screen example
276
+ // ---------------------------------------------------------------------------
277
+
278
+ function ListScreen() {
279
+ const { push } = useNavigation()
280
+
281
+ return (
282
+ <Extension.Content>
283
+ <Text size="2" color="gray">A minimal two-screen navigator.</Text>
284
+ <Button variant="soft" size="2" onClick={() => push('about')}>
285
+ Go to About
286
+ </Button>
287
+ </Extension.Content>
288
+ )
289
+ }
290
+
291
+ function AboutScreen() {
292
+ return (
293
+ <Extension.Content>
294
+ <Text size="2">This is the about screen. Press the back arrow to return.</Text>
295
+ </Extension.Content>
296
+ )
297
+ }
298
+
299
+ export const NavigatorMinimal = {
300
+ name: 'Navigator (Minimal)',
301
+ render: () => (
302
+ <Extension moises={mockMoises} initialScreen="list" animated>
303
+ <Extension.Screen name="list" title="Home" component={ListScreen} />
304
+ <Extension.Screen name="about" title="About" component={AboutScreen} />
305
+ </Extension>
306
+ ),
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Navigator — dynamic header via setOptions
311
+ // ---------------------------------------------------------------------------
312
+
313
+ function CounterScreen() {
314
+ const { setOptions } = useScreen()
315
+ const [count, setCount] = useState(0)
316
+
317
+ useEffect(() => {
318
+ setOptions({
319
+ title: `Count: ${count}`,
320
+ headerRight: (
321
+ <Button variant="soft" size="1" onClick={() => setCount(0)}>Reset</Button>
322
+ ),
323
+ })
324
+ }, [count])
325
+
326
+ return (
327
+ <Extension.Content>
328
+ <Button variant="soft" size="2" onClick={() => setCount((c) => c + 1)}>
329
+ Increment
330
+ </Button>
331
+ </Extension.Content>
332
+ )
333
+ }
334
+
335
+ export const DynamicHeader = {
336
+ name: 'Navigator (Dynamic Header)',
337
+ render: () => (
338
+ <Extension moises={mockMoises} initialScreen="counter">
339
+ <Extension.Screen name="counter" title="Count: 0" component={CounterScreen} />
340
+ </Extension>
341
+ ),
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Navigator — custom close handler
346
+ // ---------------------------------------------------------------------------
347
+
348
+ function CloseInfoScreen() {
349
+ return (
350
+ <Extension.Content>
351
+ <Text size="2">The close button on this screen triggers a custom handler instead of `moises.extension.close()`.</Text>
352
+ </Extension.Content>
353
+ )
354
+ }
355
+
356
+ export const CustomClose = {
357
+ name: 'Navigator (Custom Close)',
358
+ render: () => (
359
+ <Extension moises={mockMoises} initialScreen="info">
360
+ <Extension.Screen
361
+ name="info"
362
+ title="Custom Close"
363
+ component={CloseInfoScreen}
364
+ onClose={() => console.log('custom onClose handler')}
365
+ />
366
+ </Extension>
367
+ ),
368
+ }