@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/dist/index.js +3956 -3766
- package/package.json +3 -2
- package/src/components/Extension/Extension.jsx +317 -0
- package/src/components/Extension/Extension.stories.jsx +368 -0
- package/src/components/SetlistList/SetlistList.jsx +23 -0
- package/src/components/SetlistList/SetlistList.module.css +8 -3
- package/src/components/useForm/useForm.jsx +204 -0
- package/src/components/useForm/useForm.stories.jsx +459 -0
- package/src/index.jsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moises.ai/design-system",
|
|
3
|
-
"version": "3.
|
|
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
|
+
}
|