component-previewer 0.1.1 → 0.2.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/README.md +67 -12
- package/lib/module/PreviewControls.js +159 -14
- package/lib/module/PreviewControls.js.map +1 -1
- package/lib/module/PreviewList.js +3 -0
- package/lib/module/PreviewList.js.map +1 -1
- package/lib/module/PreviewStage.js +47 -12
- package/lib/module/PreviewStage.js.map +1 -1
- package/lib/module/Previewer.js +22 -3
- package/lib/module/Previewer.js.map +1 -1
- package/lib/module/controls.js +32 -6
- package/lib/module/controls.js.map +1 -1
- package/lib/module/csf.js +5 -2
- package/lib/module/csf.js.map +1 -1
- package/lib/module/globals.js +14 -0
- package/lib/module/globals.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/persistence.js +45 -0
- package/lib/module/persistence.js.map +1 -0
- package/lib/module/shell.js +13 -3
- package/lib/module/shell.js.map +1 -1
- package/lib/typescript/src/PreviewControls.d.ts +6 -1
- package/lib/typescript/src/PreviewControls.d.ts.map +1 -1
- package/lib/typescript/src/PreviewList.d.ts.map +1 -1
- package/lib/typescript/src/PreviewStage.d.ts +8 -2
- package/lib/typescript/src/PreviewStage.d.ts.map +1 -1
- package/lib/typescript/src/Previewer.d.ts +6 -2
- package/lib/typescript/src/Previewer.d.ts.map +1 -1
- package/lib/typescript/src/controls.d.ts +8 -0
- package/lib/typescript/src/controls.d.ts.map +1 -1
- package/lib/typescript/src/csf.d.ts.map +1 -1
- package/lib/typescript/src/globals.d.ts +3 -0
- package/lib/typescript/src/globals.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/persistence.d.ts +11 -0
- package/lib/typescript/src/persistence.d.ts.map +1 -0
- package/lib/typescript/src/shell.d.ts +3 -2
- package/lib/typescript/src/shell.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +9 -1
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/PreviewControls.tsx +109 -6
- package/src/PreviewList.tsx +7 -1
- package/src/PreviewStage.tsx +54 -9
- package/src/Previewer.tsx +30 -3
- package/src/controls.ts +24 -4
- package/src/csf.ts +6 -1
- package/src/globals.ts +13 -0
- package/src/index.ts +6 -0
- package/src/persistence.ts +49 -0
- package/src/shell.tsx +15 -5
- package/src/types.ts +19 -1
package/src/PreviewControls.tsx
CHANGED
|
@@ -1,27 +1,76 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import { Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native';
|
|
3
3
|
import type { Control } from './controls';
|
|
4
|
+
import type { GlobalTypes, Globals } from './types';
|
|
4
5
|
|
|
5
6
|
// Dependency-free controls panel (bottom sheet). Neutral chrome — the host theme
|
|
6
|
-
// applies to the previewed story, not here.
|
|
7
|
+
// applies to the previewed story, not here. A top "Globals" section switches the
|
|
8
|
+
// shell (theme/locale, …); below it, each control edits one arg live.
|
|
7
9
|
export function PreviewControls({
|
|
8
10
|
controls,
|
|
9
11
|
onChange,
|
|
10
12
|
onReset,
|
|
13
|
+
onCopy,
|
|
14
|
+
globalTypes,
|
|
15
|
+
globals,
|
|
16
|
+
onGlobalChange,
|
|
11
17
|
}: {
|
|
12
18
|
controls: Control[];
|
|
13
19
|
onChange: (name: string, value: unknown) => void;
|
|
14
20
|
onReset: () => void;
|
|
21
|
+
onCopy?: () => void;
|
|
22
|
+
globalTypes?: GlobalTypes;
|
|
23
|
+
globals?: Globals;
|
|
24
|
+
onGlobalChange?: (key: string, value: unknown) => void;
|
|
15
25
|
}) {
|
|
26
|
+
const globalKeys = globalTypes ? Object.keys(globalTypes) : [];
|
|
16
27
|
return (
|
|
17
28
|
<View style={styles.sheet}>
|
|
18
29
|
<View style={styles.header}>
|
|
19
30
|
<Text style={styles.title}>Props</Text>
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
<View style={styles.headerActions}>
|
|
32
|
+
{onCopy && (
|
|
33
|
+
<Pressable onPress={onCopy} accessibilityRole="button" accessibilityLabel="Copy props">
|
|
34
|
+
<Text style={styles.action}>Copy</Text>
|
|
35
|
+
</Pressable>
|
|
36
|
+
)}
|
|
37
|
+
<Pressable onPress={onReset} accessibilityRole="button" accessibilityLabel="Reset props">
|
|
38
|
+
<Text style={styles.action}>Reset</Text>
|
|
39
|
+
</Pressable>
|
|
40
|
+
</View>
|
|
23
41
|
</View>
|
|
24
42
|
<ScrollView keyboardShouldPersistTaps="handled">
|
|
43
|
+
{globalKeys.length > 0 && globalTypes && globals && onGlobalChange && (
|
|
44
|
+
<View style={styles.globals}>
|
|
45
|
+
<Text style={styles.sectionLabel}>Globals</Text>
|
|
46
|
+
{globalKeys.map((key) => {
|
|
47
|
+
const gt = globalTypes[key];
|
|
48
|
+
return (
|
|
49
|
+
<View key={key} style={styles.row}>
|
|
50
|
+
<Text style={styles.label}>{gt.label ?? key}</Text>
|
|
51
|
+
<View style={styles.control}>
|
|
52
|
+
<View style={styles.options}>
|
|
53
|
+
{gt.options.map((opt) => {
|
|
54
|
+
const selected = opt === globals[key];
|
|
55
|
+
return (
|
|
56
|
+
<Pressable
|
|
57
|
+
key={String(opt)}
|
|
58
|
+
style={[styles.pill, selected && styles.pillSelected]}
|
|
59
|
+
onPress={() => onGlobalChange(key, opt)}
|
|
60
|
+
accessibilityRole="button"
|
|
61
|
+
accessibilityLabel={`${gt.label ?? key}: ${String(opt)}`}
|
|
62
|
+
>
|
|
63
|
+
<Text style={[styles.pillText, selected && styles.pillTextSelected]}>{String(opt)}</Text>
|
|
64
|
+
</Pressable>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</View>
|
|
68
|
+
</View>
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</View>
|
|
73
|
+
)}
|
|
25
74
|
{controls.map((c) => (
|
|
26
75
|
<View key={c.name} style={styles.row}>
|
|
27
76
|
<Text style={styles.label}>{c.name}</Text>
|
|
@@ -75,6 +124,25 @@ function ControlInput({ control, onChange }: { control: Control; onChange: (valu
|
|
|
75
124
|
</View>
|
|
76
125
|
);
|
|
77
126
|
|
|
127
|
+
case 'color':
|
|
128
|
+
return (
|
|
129
|
+
<View style={styles.colorRow}>
|
|
130
|
+
<View style={[styles.swatch, { backgroundColor: control.value || 'transparent' }]} />
|
|
131
|
+
<TextInput
|
|
132
|
+
style={[styles.input, styles.colorInput]}
|
|
133
|
+
value={control.value}
|
|
134
|
+
autoCapitalize="none"
|
|
135
|
+
autoCorrect={false}
|
|
136
|
+
placeholder="#rrggbb"
|
|
137
|
+
placeholderTextColor="#666"
|
|
138
|
+
onChangeText={onChange}
|
|
139
|
+
/>
|
|
140
|
+
</View>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
case 'object':
|
|
144
|
+
return <ObjectInput value={control.value} onChange={onChange} />;
|
|
145
|
+
|
|
78
146
|
default: // 'text'
|
|
79
147
|
return (
|
|
80
148
|
<TextInput
|
|
@@ -88,6 +156,33 @@ function ControlInput({ control, onChange }: { control: Control; onChange: (valu
|
|
|
88
156
|
}
|
|
89
157
|
}
|
|
90
158
|
|
|
159
|
+
// Object/array editor. Holds the raw text locally so a transiently-invalid edit
|
|
160
|
+
// (mid-typing) isn't reverted keystroke-by-keystroke; commits only on valid JSON.
|
|
161
|
+
// Reset reseeds it because the panel is keyed by a reset nonce in PreviewStage.
|
|
162
|
+
function ObjectInput({ value, onChange }: { value: unknown; onChange: (value: unknown) => void }) {
|
|
163
|
+
const [text, setText] = useState(() => JSON.stringify(value, null, 2));
|
|
164
|
+
const [valid, setValid] = useState(true);
|
|
165
|
+
return (
|
|
166
|
+
<TextInput
|
|
167
|
+
style={[styles.input, styles.objectInput, !valid && styles.objectInvalid]}
|
|
168
|
+
value={text}
|
|
169
|
+
multiline
|
|
170
|
+
autoCapitalize="none"
|
|
171
|
+
autoCorrect={false}
|
|
172
|
+
onChangeText={(t) => {
|
|
173
|
+
setText(t);
|
|
174
|
+
try {
|
|
175
|
+
const parsed: unknown = JSON.parse(t);
|
|
176
|
+
setValid(true);
|
|
177
|
+
onChange(parsed);
|
|
178
|
+
} catch {
|
|
179
|
+
setValid(false); // keep the text; don't commit until it parses
|
|
180
|
+
}
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
91
186
|
const styles = StyleSheet.create({
|
|
92
187
|
sheet: {
|
|
93
188
|
position: 'absolute',
|
|
@@ -106,7 +201,10 @@ const styles = StyleSheet.create({
|
|
|
106
201
|
},
|
|
107
202
|
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
|
|
108
203
|
title: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
|
109
|
-
|
|
204
|
+
headerActions: { flexDirection: 'row', gap: 16 },
|
|
205
|
+
action: { color: '#7aa2ff', fontSize: 14, fontWeight: '600' },
|
|
206
|
+
globals: { marginBottom: 8, paddingBottom: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#333' },
|
|
207
|
+
sectionLabel: { color: '#7f7f88', fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.6 },
|
|
110
208
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 12 },
|
|
111
209
|
label: { color: '#cbcbd2', fontSize: 14, width: 96 },
|
|
112
210
|
control: { flex: 1, alignItems: 'flex-end' },
|
|
@@ -120,6 +218,11 @@ const styles = StyleSheet.create({
|
|
|
120
218
|
width: '100%',
|
|
121
219
|
textAlign: 'right',
|
|
122
220
|
},
|
|
221
|
+
colorRow: { flexDirection: 'row', alignItems: 'center', gap: 8, width: '100%' },
|
|
222
|
+
swatch: { width: 26, height: 26, borderRadius: 6, borderWidth: StyleSheet.hairlineWidth, borderColor: '#555' },
|
|
223
|
+
colorInput: { flex: 1, minWidth: 0 },
|
|
224
|
+
objectInput: { textAlign: 'left', minHeight: 88, fontFamily: 'Courier', fontSize: 12 },
|
|
225
|
+
objectInvalid: { borderWidth: 1, borderColor: '#e0566f' },
|
|
123
226
|
options: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 6 },
|
|
124
227
|
pill: { backgroundColor: '#262629', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6 },
|
|
125
228
|
pillSelected: { backgroundColor: '#7aa2ff' },
|
package/src/PreviewList.tsx
CHANGED
|
@@ -39,7 +39,13 @@ export function PreviewList({
|
|
|
39
39
|
data={filtered}
|
|
40
40
|
keyExtractor={(e) => e.id}
|
|
41
41
|
renderItem={({ item }) => (
|
|
42
|
-
<Pressable
|
|
42
|
+
<Pressable
|
|
43
|
+
style={styles.row}
|
|
44
|
+
onPress={() => onSelect(item)}
|
|
45
|
+
accessibilityRole="button"
|
|
46
|
+
accessibilityLabel={`${item.title} / ${item.name}`}
|
|
47
|
+
testID={`story-row-${item.id}`}
|
|
48
|
+
>
|
|
43
49
|
<Text style={styles.rowTitle}>
|
|
44
50
|
{item.title} <Text style={styles.rowName}>/ {item.name}</Text>
|
|
45
51
|
</Text>
|
package/src/PreviewStage.tsx
CHANGED
|
@@ -2,45 +2,90 @@ import React, { useMemo, useState } from 'react';
|
|
|
2
2
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
3
|
import { inferControls } from './controls';
|
|
4
4
|
import { PreviewControls } from './PreviewControls';
|
|
5
|
+
import { loadArgs, resetArgs, saveArgs, type ArgsPersistence } from './persistence';
|
|
5
6
|
import { composeStory, type ShellComponent } from './shell';
|
|
6
|
-
import type { Args, StoryEntry } from './types';
|
|
7
|
+
import type { Args, GlobalTypes, Globals, StoryEntry } from './types';
|
|
7
8
|
|
|
8
9
|
// Renders the selected story full-bleed, composed with the real shell (outer)
|
|
9
10
|
// and the story's decorators (inner). The picker chrome stays outside the shell.
|
|
10
|
-
// Live props controls edit `args` and re-render the story in place.
|
|
11
|
+
// Live props controls edit `args` and re-render the story in place. Edits persist
|
|
12
|
+
// per story id (session by default; cross-reload if a `persistence` adapter is
|
|
13
|
+
// passed), so navigating away and back keeps your tweaks. `globals` parameterize
|
|
14
|
+
// the shell (theme/locale) from the same panel.
|
|
11
15
|
export function PreviewStage({
|
|
12
16
|
entry,
|
|
13
17
|
shell,
|
|
14
18
|
onBack,
|
|
19
|
+
persistence,
|
|
20
|
+
onCopyArgs,
|
|
21
|
+
globalTypes,
|
|
22
|
+
globals,
|
|
23
|
+
onGlobalChange,
|
|
15
24
|
}: {
|
|
16
25
|
entry: StoryEntry;
|
|
17
26
|
shell?: ShellComponent;
|
|
18
27
|
onBack: () => void;
|
|
28
|
+
persistence?: ArgsPersistence;
|
|
29
|
+
onCopyArgs?: (args: Args, entry: StoryEntry) => void;
|
|
30
|
+
globalTypes?: GlobalTypes;
|
|
31
|
+
globals?: Globals;
|
|
32
|
+
onGlobalChange?: (key: string, value: unknown) => void;
|
|
19
33
|
}) {
|
|
20
|
-
const [args, setArgs] = useState<Args>(entry.args);
|
|
34
|
+
const [args, setArgs] = useState<Args>(() => loadArgs(entry.id, entry.args, persistence));
|
|
21
35
|
const [controlsOpen, setControlsOpen] = useState(false);
|
|
36
|
+
// Bumped on Reset to remount the controls panel so the JSON object editor
|
|
37
|
+
// reseeds its local text from the restored args.
|
|
38
|
+
const [resetNonce, setResetNonce] = useState(0);
|
|
22
39
|
const controls = useMemo(() => inferControls(args, entry.argTypes), [args, entry.argTypes]);
|
|
23
40
|
|
|
41
|
+
const change = (name: string, value: unknown) =>
|
|
42
|
+
setArgs((a) => {
|
|
43
|
+
const next = { ...a, [name]: value };
|
|
44
|
+
saveArgs(entry.id, next, persistence);
|
|
45
|
+
return next;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const reset = () => {
|
|
49
|
+
resetArgs(entry.id, persistence);
|
|
50
|
+
setArgs(entry.args);
|
|
51
|
+
setResetNonce((n) => n + 1);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Default copy logs the args JSON (zero-dep); a host can wire a real clipboard.
|
|
55
|
+
const copy = () => {
|
|
56
|
+
if (onCopyArgs) onCopyArgs(args, entry);
|
|
57
|
+
else console.log(`[previewer] ${entry.id} args:\n${JSON.stringify(args, null, 2)}`);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const hasGlobals = !!globalTypes && Object.keys(globalTypes).length > 0;
|
|
61
|
+
const hasPanel = controls.length > 0 || hasGlobals;
|
|
62
|
+
|
|
24
63
|
return (
|
|
25
64
|
<View style={styles.root}>
|
|
26
|
-
<View style={styles.content}>{composeStory(entry, shell, args)}</View>
|
|
27
|
-
<Pressable style={styles.back} onPress={onBack} accessibilityLabel="Back to stories">
|
|
65
|
+
<View style={styles.content}>{composeStory(entry, shell, args, globals)}</View>
|
|
66
|
+
<Pressable style={styles.back} onPress={onBack} accessibilityRole="button" accessibilityLabel="Back to stories">
|
|
28
67
|
<Text style={styles.backText}>‹ {entry.title} / {entry.name}</Text>
|
|
29
68
|
</Pressable>
|
|
30
|
-
{
|
|
69
|
+
{hasPanel && (
|
|
31
70
|
<Pressable
|
|
32
71
|
style={styles.toggle}
|
|
33
72
|
onPress={() => setControlsOpen((v) => !v)}
|
|
73
|
+
accessibilityRole="button"
|
|
34
74
|
accessibilityLabel="Toggle props controls"
|
|
35
75
|
>
|
|
36
76
|
<Text style={styles.backText}>{controlsOpen ? 'Controls ▾' : 'Controls ▴'}</Text>
|
|
37
77
|
</Pressable>
|
|
38
78
|
)}
|
|
39
|
-
{controlsOpen &&
|
|
79
|
+
{controlsOpen && hasPanel && (
|
|
40
80
|
<PreviewControls
|
|
81
|
+
key={resetNonce}
|
|
41
82
|
controls={controls}
|
|
42
|
-
onChange={
|
|
43
|
-
onReset={
|
|
83
|
+
onChange={change}
|
|
84
|
+
onReset={reset}
|
|
85
|
+
onCopy={copy}
|
|
86
|
+
globalTypes={globalTypes}
|
|
87
|
+
globals={globals}
|
|
88
|
+
onGlobalChange={onGlobalChange}
|
|
44
89
|
/>
|
|
45
90
|
)}
|
|
46
91
|
</View>
|
package/src/Previewer.tsx
CHANGED
|
@@ -1,28 +1,55 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
+
import { initGlobals } from './globals';
|
|
2
3
|
import { PreviewList } from './PreviewList';
|
|
3
4
|
import { PreviewStage } from './PreviewStage';
|
|
5
|
+
import type { ArgsPersistence } from './persistence';
|
|
4
6
|
import type { ShellComponent } from './shell';
|
|
5
|
-
import type { StoryEntry } from './types';
|
|
7
|
+
import type { Args, GlobalTypes, StoryEntry } from './types';
|
|
6
8
|
|
|
7
9
|
// The reusable picker + stage UI. The host builds `stories` via
|
|
8
10
|
// fromRequireContext/fromGlob and passes its real `shell` (AppProviders).
|
|
9
11
|
// `initialStoryId` boots straight into one story (useful for deep links /
|
|
10
|
-
// screenshots / "jump to the thing I'm editing").
|
|
12
|
+
// screenshots / "jump to the thing I'm editing"). `persistence` (optional) makes
|
|
13
|
+
// edited args survive a cold reload; `onCopyArgs` (optional) wires a real
|
|
14
|
+
// clipboard for the Copy button (defaults to console.log). `globalTypes`
|
|
15
|
+
// (optional) declares toolbar params (theme/locale) that the shell reads — one
|
|
16
|
+
// real shell parameterized, instead of forked light/dark copies.
|
|
11
17
|
export function Previewer({
|
|
12
18
|
stories,
|
|
13
19
|
shell,
|
|
14
20
|
initialStoryId,
|
|
21
|
+
persistence,
|
|
22
|
+
onCopyArgs,
|
|
23
|
+
globalTypes,
|
|
15
24
|
}: {
|
|
16
25
|
stories: StoryEntry[];
|
|
17
26
|
shell?: ShellComponent;
|
|
18
27
|
initialStoryId?: string;
|
|
28
|
+
persistence?: ArgsPersistence;
|
|
29
|
+
onCopyArgs?: (args: Args, entry: StoryEntry) => void;
|
|
30
|
+
globalTypes?: GlobalTypes;
|
|
19
31
|
}) {
|
|
20
32
|
const initial = initialStoryId ? (stories.find((s) => s.id === initialStoryId) ?? null) : null;
|
|
21
33
|
const [selected, setSelected] = useState<StoryEntry | null>(initial);
|
|
34
|
+
// Globals live above the selected story so they survive list <-> stage nav.
|
|
35
|
+
const [globals, setGlobals] = useState(() => initGlobals(globalTypes));
|
|
36
|
+
const changeGlobal = (key: string, value: unknown) => setGlobals((g) => ({ ...g, [key]: value }));
|
|
22
37
|
|
|
23
38
|
if (selected) {
|
|
24
39
|
// key by story id so live-edited arg state resets cleanly per story.
|
|
25
|
-
return
|
|
40
|
+
return (
|
|
41
|
+
<PreviewStage
|
|
42
|
+
key={selected.id}
|
|
43
|
+
entry={selected}
|
|
44
|
+
shell={shell}
|
|
45
|
+
onBack={() => setSelected(null)}
|
|
46
|
+
persistence={persistence}
|
|
47
|
+
onCopyArgs={onCopyArgs}
|
|
48
|
+
globalTypes={globalTypes}
|
|
49
|
+
globals={globals}
|
|
50
|
+
onGlobalChange={changeGlobal}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
26
53
|
}
|
|
27
54
|
return <PreviewList entries={stories} onSelect={setSelected} />;
|
|
28
55
|
}
|
package/src/controls.ts
CHANGED
|
@@ -3,9 +3,14 @@ import type { Args, ArgType, ArgTypes, ControlKind } from './types';
|
|
|
3
3
|
// A render-ready control descriptor. Pure data — the UI maps these to inputs.
|
|
4
4
|
export type Control =
|
|
5
5
|
| { name: string; kind: 'text'; value: string }
|
|
6
|
+
| { name: string; kind: 'color'; value: string }
|
|
6
7
|
| { name: string; kind: 'boolean'; value: boolean }
|
|
7
8
|
| { name: string; kind: 'number'; value: number; min?: number; max?: number; step?: number }
|
|
8
|
-
| { name: string; kind: 'select'; value: unknown; options: unknown[] }
|
|
9
|
+
| { name: string; kind: 'select'; value: unknown; options: unknown[] }
|
|
10
|
+
| { name: string; kind: 'object'; value: unknown };
|
|
11
|
+
|
|
12
|
+
// A full hex color: #rgb, #rgba, #rrggbb, or #rrggbbaa.
|
|
13
|
+
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
9
14
|
|
|
10
15
|
type Bounds = { min?: number; max?: number; step?: number };
|
|
11
16
|
|
|
@@ -59,16 +64,31 @@ export function inferControls(args: Args, argTypes: ArgTypes = {}): Control[] {
|
|
|
59
64
|
controls.push({ name, kind: 'boolean', value: Boolean(value) });
|
|
60
65
|
continue;
|
|
61
66
|
}
|
|
67
|
+
if (kind === 'color') {
|
|
68
|
+
controls.push({ name, kind: 'color', value: toText(value) });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (kind === 'object') {
|
|
72
|
+
// Editable as JSON; the value passes through as-is until edited.
|
|
73
|
+
controls.push({ name, kind: 'object', value });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
62
76
|
if (kind === 'text') {
|
|
63
77
|
controls.push({ name, kind: 'text', value: toText(value) });
|
|
64
78
|
continue;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
// No explicit control — infer from the value type.
|
|
68
|
-
if (typeof value === 'string')
|
|
69
|
-
|
|
82
|
+
if (typeof value === 'string') {
|
|
83
|
+
// A bare hex string is unambiguously a color; everything else is text.
|
|
84
|
+
controls.push(
|
|
85
|
+
HEX_COLOR.test(value)
|
|
86
|
+
? { name, kind: 'color', value }
|
|
87
|
+
: { name, kind: 'text', value },
|
|
88
|
+
);
|
|
89
|
+
} else if (typeof value === 'number') controls.push({ name, kind: 'number', value });
|
|
70
90
|
else if (typeof value === 'boolean') controls.push({ name, kind: 'boolean', value });
|
|
71
|
-
// else: skipped (function/object/array/null/undefined).
|
|
91
|
+
// else: skipped (function/object/array/null/undefined) — pass through untouched.
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
return controls;
|
package/src/csf.ts
CHANGED
|
@@ -43,11 +43,16 @@ export function parseCsfModule(id: string, mod: CsfModule): StoryEntry[] {
|
|
|
43
43
|
const decorators = [...(story.decorators ?? []), ...(meta?.decorators ?? [])];
|
|
44
44
|
const name = story.name ?? exportName;
|
|
45
45
|
|
|
46
|
+
// Resolution order (Storybook CSF3): the story's own render, then a
|
|
47
|
+
// meta-level default render, then the meta `component` itself.
|
|
46
48
|
const render =
|
|
47
49
|
story.render ??
|
|
50
|
+
meta?.render ??
|
|
48
51
|
((a: Args) => {
|
|
49
52
|
if (!meta?.component) {
|
|
50
|
-
throw new Error(
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Story "${exportName}" in ${id}: no story render, no meta render, and meta has no component`,
|
|
55
|
+
);
|
|
51
56
|
}
|
|
52
57
|
return createElement(meta.component, a);
|
|
53
58
|
});
|
package/src/globals.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GlobalTypes, Globals } from './types';
|
|
2
|
+
|
|
3
|
+
// Build the initial globals object from the declared globalTypes: each key takes
|
|
4
|
+
// its `default`, falling back to the first option. Pure; the Previewer seeds its
|
|
5
|
+
// state from this and the shell reads the result.
|
|
6
|
+
export function initGlobals(globalTypes: GlobalTypes = {}): Globals {
|
|
7
|
+
const globals: Globals = {};
|
|
8
|
+
for (const key of Object.keys(globalTypes)) {
|
|
9
|
+
const gt = globalTypes[key];
|
|
10
|
+
globals[key] = gt.default !== undefined ? gt.default : gt.options[0];
|
|
11
|
+
}
|
|
12
|
+
return globals;
|
|
13
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,9 @@ export { composeStory } from './shell';
|
|
|
5
5
|
export type { ShellComponent } from './shell';
|
|
6
6
|
export { inferControls } from './controls';
|
|
7
7
|
export type { Control } from './controls';
|
|
8
|
+
export { initGlobals } from './globals';
|
|
9
|
+
export { loadArgs, saveArgs, resetArgs } from './persistence';
|
|
10
|
+
export type { ArgsPersistence } from './persistence';
|
|
8
11
|
export type {
|
|
9
12
|
Args,
|
|
10
13
|
ArgType,
|
|
@@ -13,6 +16,9 @@ export type {
|
|
|
13
16
|
CsfModule,
|
|
14
17
|
Decorator,
|
|
15
18
|
GlobModules,
|
|
19
|
+
Globals,
|
|
20
|
+
GlobalType,
|
|
21
|
+
GlobalTypes,
|
|
16
22
|
Meta,
|
|
17
23
|
RequireContext,
|
|
18
24
|
Story,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Args } from './types';
|
|
2
|
+
|
|
3
|
+
// Optional host-provided adapter for cross-reload persistence. The package stays
|
|
4
|
+
// dependency-free, so it never imports AsyncStorage/MMKV itself — the host wires
|
|
5
|
+
// one if it wants edited args to survive a COLD reload:
|
|
6
|
+
//
|
|
7
|
+
// import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
8
|
+
// // ...a thin synchronous cache around AsyncStorage, or any sync store...
|
|
9
|
+
// <Previewer persistence={myAdapter} ... />
|
|
10
|
+
export type ArgsPersistence = {
|
|
11
|
+
get(storyId: string): Args | undefined;
|
|
12
|
+
set(storyId: string, args: Args): void;
|
|
13
|
+
remove?(storyId: string): void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// In-memory session store. Survives list <-> stage navigation AND Fast Refresh
|
|
17
|
+
// (this module is untouched when a *.stories.tsx file edits), with zero deps.
|
|
18
|
+
// Cleared only on a cold reload; pass `persistence` to outlast that too.
|
|
19
|
+
const session = new Map<string, Args>();
|
|
20
|
+
|
|
21
|
+
// Resolve the args to seed a story with: a live session edit wins, then a
|
|
22
|
+
// host-persisted value (cached back into the session), then the declared args.
|
|
23
|
+
export function loadArgs(storyId: string, declared: Args, persistence?: ArgsPersistence): Args {
|
|
24
|
+
const live = session.get(storyId);
|
|
25
|
+
if (live) return live;
|
|
26
|
+
const stored = persistence?.get(storyId);
|
|
27
|
+
if (stored) {
|
|
28
|
+
session.set(storyId, stored);
|
|
29
|
+
return stored;
|
|
30
|
+
}
|
|
31
|
+
return declared;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Record an edit. Always updates the session; mirrors to the host adapter.
|
|
35
|
+
export function saveArgs(storyId: string, args: Args, persistence?: ArgsPersistence): void {
|
|
36
|
+
session.set(storyId, args);
|
|
37
|
+
persistence?.set(storyId, args);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Drop the override so the story falls back to its declared args.
|
|
41
|
+
export function resetArgs(storyId: string, persistence?: ArgsPersistence): void {
|
|
42
|
+
session.delete(storyId);
|
|
43
|
+
persistence?.remove?.(storyId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Test seam: forget every session edit.
|
|
47
|
+
export function _clearSession(): void {
|
|
48
|
+
session.clear();
|
|
49
|
+
}
|
package/src/shell.tsx
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import type { ComponentType, ReactElement } from 'react';
|
|
3
|
-
import type { Args, StoryEntry } from './types';
|
|
3
|
+
import type { Args, Globals, StoryEntry } from './types';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// The host's real app shell. Receives `children` always, and `globals` (the
|
|
6
|
+
// toolbar params) so the SAME shell can switch theme/locale instead of being
|
|
7
|
+
// forked. Shells written before globals existed (`{ children }` only) keep
|
|
8
|
+
// working — the extra prop is simply ignored.
|
|
9
|
+
export type ShellComponent = ComponentType<{ children: ReactElement; globals?: Globals }>;
|
|
6
10
|
|
|
7
11
|
// Compose the full element for a story:
|
|
8
|
-
// AppShell (outer, single source of truth) > decorators >
|
|
12
|
+
// AppShell(globals) (outer, single source of truth) > decorators > render(args).
|
|
9
13
|
// The real-shell contract: the host's AppShell ALWAYS wraps outside; CSF
|
|
10
14
|
// decorators run inside it. Pure — returns an element tree, does not mount.
|
|
11
15
|
// `args` defaults to the story's declared args; pass an override for live edits.
|
|
12
|
-
|
|
16
|
+
// `globals` parameterize the shell (theme, locale, ...).
|
|
17
|
+
export function composeStory(
|
|
18
|
+
entry: StoryEntry,
|
|
19
|
+
Shell?: ShellComponent,
|
|
20
|
+
args: Args = entry.args,
|
|
21
|
+
globals?: Globals,
|
|
22
|
+
): ReactElement {
|
|
13
23
|
let node: ReactElement = entry.render(args);
|
|
14
24
|
|
|
15
25
|
for (const decorate of entry.decorators) {
|
|
@@ -18,5 +28,5 @@ export function composeStory(entry: StoryEntry, Shell?: ShellComponent, args: Ar
|
|
|
18
28
|
node = decorate(Story);
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
return Shell ? createElement(Shell,
|
|
31
|
+
return Shell ? createElement(Shell, { children: node, globals }) : node;
|
|
22
32
|
}
|
package/src/types.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type Decorator = (Story: ComponentType) => ReactElement;
|
|
|
10
10
|
|
|
11
11
|
// CSF-compatible control hints. Optional: controls default to value-inference.
|
|
12
12
|
// `control` accepts the Storybook string shorthand or an object { type, ... }.
|
|
13
|
-
export type ControlKind = 'text' | 'number' | 'boolean' | 'select' | 'range';
|
|
13
|
+
export type ControlKind = 'text' | 'number' | 'boolean' | 'select' | 'range' | 'color' | 'object';
|
|
14
14
|
|
|
15
15
|
export type ArgType = {
|
|
16
16
|
control?: ControlKind | { type: ControlKind; min?: number; max?: number; step?: number };
|
|
@@ -22,6 +22,21 @@ export type ArgType = {
|
|
|
22
22
|
|
|
23
23
|
export type ArgTypes = Record<string, ArgType>;
|
|
24
24
|
|
|
25
|
+
// --- Globals: toolbar-level params that parameterize the SHELL across every
|
|
26
|
+
// story (theme, locale, ...). Unlike args (per-component props), globals let the
|
|
27
|
+
// one real shell vary instead of being forked into light/dark copies or a
|
|
28
|
+
// lighter hand-built lab. Declared once on the host <Previewer globalTypes=…>. ---
|
|
29
|
+
|
|
30
|
+
export type Globals = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
export type GlobalType = {
|
|
33
|
+
options: unknown[]; // the toolbar choices (rendered as a pill select)
|
|
34
|
+
default?: unknown; // initial value; defaults to options[0]
|
|
35
|
+
label?: string; // toolbar label; defaults to the key
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type GlobalTypes = Record<string, GlobalType>;
|
|
39
|
+
|
|
25
40
|
// `export default` of a *.stories.tsx file.
|
|
26
41
|
export type Meta = {
|
|
27
42
|
title?: string;
|
|
@@ -31,6 +46,9 @@ export type Meta = {
|
|
|
31
46
|
args?: Args;
|
|
32
47
|
argTypes?: ArgTypes;
|
|
33
48
|
decorators?: Decorator[];
|
|
49
|
+
// A default render shared by every story in the file (CSF3). A story's own
|
|
50
|
+
// `render` overrides it; this overrides the `component` fallback.
|
|
51
|
+
render?: (args: Args) => ReactElement;
|
|
34
52
|
};
|
|
35
53
|
|
|
36
54
|
// A named export of a *.stories.tsx file (CSF3 object, or a CSF2 render fn).
|