component-previewer 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/lib/module/PreviewControls.js +177 -0
- package/lib/module/PreviewControls.js.map +1 -0
- package/lib/module/PreviewList.js +99 -0
- package/lib/module/PreviewList.js.map +1 -0
- package/lib/module/PreviewStage.js +82 -0
- package/lib/module/PreviewStage.js.map +1 -0
- package/lib/module/Previewer.js +31 -0
- package/lib/module/Previewer.js.map +1 -0
- package/lib/module/controls.js +112 -0
- package/lib/module/controls.js.map +1 -0
- package/lib/module/csf.js +63 -0
- package/lib/module/csf.js.map +1 -0
- package/lib/module/index.js +10 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/registry.js +28 -0
- package/lib/module/registry.js.map +1 -0
- package/lib/module/shell.js +18 -0
- package/lib/module/shell.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/PreviewControls.d.ts +8 -0
- package/lib/typescript/src/PreviewControls.d.ts.map +1 -0
- package/lib/typescript/src/PreviewList.d.ts +7 -0
- package/lib/typescript/src/PreviewList.d.ts.map +1 -0
- package/lib/typescript/src/PreviewStage.d.ts +9 -0
- package/lib/typescript/src/PreviewStage.d.ts.map +1 -0
- package/lib/typescript/src/Previewer.d.ts +9 -0
- package/lib/typescript/src/Previewer.d.ts.map +1 -0
- package/lib/typescript/src/controls.d.ts +24 -0
- package/lib/typescript/src/controls.d.ts.map +1 -0
- package/lib/typescript/src/csf.d.ts +3 -0
- package/lib/typescript/src/csf.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +9 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/registry.d.ts +8 -0
- package/lib/typescript/src/registry.d.ts.map +1 -0
- package/lib/typescript/src/shell.d.ts +7 -0
- package/lib/typescript/src/shell.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +49 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +88 -0
- package/src/PreviewControls.tsx +128 -0
- package/src/PreviewList.tsx +71 -0
- package/src/PreviewStage.tsx +72 -0
- package/src/Previewer.tsx +28 -0
- package/src/controls.ts +75 -0
- package/src/csf.ts +59 -0
- package/src/index.ts +23 -0
- package/src/registry.ts +21 -0
- package/src/shell.tsx +22 -0
- package/src/types.ts +68 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import type { Control } from './controls';
|
|
4
|
+
|
|
5
|
+
// Dependency-free controls panel (bottom sheet). Neutral chrome — the host theme
|
|
6
|
+
// applies to the previewed story, not here. Each control edits one arg live.
|
|
7
|
+
export function PreviewControls({
|
|
8
|
+
controls,
|
|
9
|
+
onChange,
|
|
10
|
+
onReset,
|
|
11
|
+
}: {
|
|
12
|
+
controls: Control[];
|
|
13
|
+
onChange: (name: string, value: unknown) => void;
|
|
14
|
+
onReset: () => void;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<View style={styles.sheet}>
|
|
18
|
+
<View style={styles.header}>
|
|
19
|
+
<Text style={styles.title}>Props</Text>
|
|
20
|
+
<Pressable onPress={onReset} accessibilityLabel="Reset props">
|
|
21
|
+
<Text style={styles.reset}>Reset</Text>
|
|
22
|
+
</Pressable>
|
|
23
|
+
</View>
|
|
24
|
+
<ScrollView keyboardShouldPersistTaps="handled">
|
|
25
|
+
{controls.map((c) => (
|
|
26
|
+
<View key={c.name} style={styles.row}>
|
|
27
|
+
<Text style={styles.label}>{c.name}</Text>
|
|
28
|
+
<View style={styles.control}>
|
|
29
|
+
<ControlInput control={c} onChange={(v) => onChange(c.name, v)} />
|
|
30
|
+
</View>
|
|
31
|
+
</View>
|
|
32
|
+
))}
|
|
33
|
+
</ScrollView>
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ControlInput({ control, onChange }: { control: Control; onChange: (value: unknown) => void }) {
|
|
39
|
+
switch (control.kind) {
|
|
40
|
+
case 'boolean':
|
|
41
|
+
return <Switch value={control.value} onValueChange={onChange} />;
|
|
42
|
+
|
|
43
|
+
case 'number':
|
|
44
|
+
return (
|
|
45
|
+
<TextInput
|
|
46
|
+
style={styles.input}
|
|
47
|
+
keyboardType="numeric"
|
|
48
|
+
value={String(control.value)}
|
|
49
|
+
onChangeText={(text) => {
|
|
50
|
+
const n = Number(text);
|
|
51
|
+
if (text.trim() === '' || Number.isNaN(n)) return; // ignore non-numeric input
|
|
52
|
+
let next = n;
|
|
53
|
+
if (control.min != null) next = Math.max(control.min, next);
|
|
54
|
+
if (control.max != null) next = Math.min(control.max, next);
|
|
55
|
+
onChange(next);
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
case 'select':
|
|
61
|
+
return (
|
|
62
|
+
<View style={styles.options}>
|
|
63
|
+
{control.options.map((opt) => {
|
|
64
|
+
const selected = opt === control.value;
|
|
65
|
+
return (
|
|
66
|
+
<Pressable
|
|
67
|
+
key={String(opt)}
|
|
68
|
+
style={[styles.pill, selected && styles.pillSelected]}
|
|
69
|
+
onPress={() => onChange(opt)}
|
|
70
|
+
>
|
|
71
|
+
<Text style={[styles.pillText, selected && styles.pillTextSelected]}>{String(opt)}</Text>
|
|
72
|
+
</Pressable>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</View>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
default: // 'text'
|
|
79
|
+
return (
|
|
80
|
+
<TextInput
|
|
81
|
+
style={styles.input}
|
|
82
|
+
value={control.value}
|
|
83
|
+
autoCapitalize="none"
|
|
84
|
+
autoCorrect={false}
|
|
85
|
+
onChangeText={onChange}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = StyleSheet.create({
|
|
92
|
+
sheet: {
|
|
93
|
+
position: 'absolute',
|
|
94
|
+
left: 0,
|
|
95
|
+
right: 0,
|
|
96
|
+
bottom: 0,
|
|
97
|
+
maxHeight: '45%',
|
|
98
|
+
backgroundColor: '#1a1a1c',
|
|
99
|
+
borderTopLeftRadius: 16,
|
|
100
|
+
borderTopRightRadius: 16,
|
|
101
|
+
paddingHorizontal: 16,
|
|
102
|
+
paddingTop: 12,
|
|
103
|
+
paddingBottom: 28,
|
|
104
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
105
|
+
borderTopColor: '#333',
|
|
106
|
+
},
|
|
107
|
+
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
|
|
108
|
+
title: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
|
109
|
+
reset: { color: '#7aa2ff', fontSize: 14, fontWeight: '600' },
|
|
110
|
+
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 12 },
|
|
111
|
+
label: { color: '#cbcbd2', fontSize: 14, width: 96 },
|
|
112
|
+
control: { flex: 1, alignItems: 'flex-end' },
|
|
113
|
+
input: {
|
|
114
|
+
color: '#fff',
|
|
115
|
+
backgroundColor: '#262629',
|
|
116
|
+
borderRadius: 8,
|
|
117
|
+
paddingHorizontal: 10,
|
|
118
|
+
paddingVertical: 8,
|
|
119
|
+
minWidth: 120,
|
|
120
|
+
width: '100%',
|
|
121
|
+
textAlign: 'right',
|
|
122
|
+
},
|
|
123
|
+
options: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 6 },
|
|
124
|
+
pill: { backgroundColor: '#262629', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6 },
|
|
125
|
+
pillSelected: { backgroundColor: '#7aa2ff' },
|
|
126
|
+
pillText: { color: '#cbcbd2', fontSize: 13, fontWeight: '600' },
|
|
127
|
+
pillTextSelected: { color: '#10131a' },
|
|
128
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import type { StoryEntry } from './types';
|
|
4
|
+
|
|
5
|
+
// Neutral, dependency-free picker. The host's theme applies only to the
|
|
6
|
+
// previewed story (via the shell), not this chrome.
|
|
7
|
+
export function PreviewList({
|
|
8
|
+
entries,
|
|
9
|
+
onSelect,
|
|
10
|
+
}: {
|
|
11
|
+
entries: StoryEntry[];
|
|
12
|
+
onSelect: (entry: StoryEntry) => void;
|
|
13
|
+
}) {
|
|
14
|
+
const [query, setQuery] = useState('');
|
|
15
|
+
const filtered = useMemo(() => {
|
|
16
|
+
const q = query.trim().toLowerCase();
|
|
17
|
+
if (!q) return entries;
|
|
18
|
+
return entries.filter((e) => `${e.title} ${e.name}`.toLowerCase().includes(q));
|
|
19
|
+
}, [entries, query]);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<View style={styles.root}>
|
|
23
|
+
<Text style={styles.heading}>Stories ({entries.length})</Text>
|
|
24
|
+
<TextInput
|
|
25
|
+
style={styles.search}
|
|
26
|
+
placeholder="Filter…"
|
|
27
|
+
placeholderTextColor="#888"
|
|
28
|
+
autoCapitalize="none"
|
|
29
|
+
autoCorrect={false}
|
|
30
|
+
value={query}
|
|
31
|
+
onChangeText={setQuery}
|
|
32
|
+
/>
|
|
33
|
+
{filtered.length === 0 ? (
|
|
34
|
+
<Text style={styles.empty}>
|
|
35
|
+
{entries.length === 0 ? 'No *.stories.tsx found.' : 'No matches.'}
|
|
36
|
+
</Text>
|
|
37
|
+
) : (
|
|
38
|
+
<FlatList
|
|
39
|
+
data={filtered}
|
|
40
|
+
keyExtractor={(e) => e.id}
|
|
41
|
+
renderItem={({ item }) => (
|
|
42
|
+
<Pressable style={styles.row} onPress={() => onSelect(item)}>
|
|
43
|
+
<Text style={styles.rowTitle}>
|
|
44
|
+
{item.title} <Text style={styles.rowName}>/ {item.name}</Text>
|
|
45
|
+
</Text>
|
|
46
|
+
<Text style={styles.rowId}>{item.id}</Text>
|
|
47
|
+
</Pressable>
|
|
48
|
+
)}
|
|
49
|
+
/>
|
|
50
|
+
)}
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
root: { flex: 1, paddingTop: 64, paddingHorizontal: 16, backgroundColor: '#111' },
|
|
57
|
+
heading: { color: '#fff', fontSize: 20, fontWeight: '600', marginBottom: 12 },
|
|
58
|
+
search: {
|
|
59
|
+
color: '#fff',
|
|
60
|
+
backgroundColor: '#222',
|
|
61
|
+
borderRadius: 10,
|
|
62
|
+
paddingHorizontal: 12,
|
|
63
|
+
paddingVertical: 10,
|
|
64
|
+
marginBottom: 12,
|
|
65
|
+
},
|
|
66
|
+
empty: { color: '#888', marginTop: 24 },
|
|
67
|
+
row: { paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#333' },
|
|
68
|
+
rowTitle: { color: '#fff', fontSize: 16 },
|
|
69
|
+
rowName: { color: '#9a9aa2' },
|
|
70
|
+
rowId: { color: '#666', fontSize: 12, marginTop: 2 },
|
|
71
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { inferControls } from './controls';
|
|
4
|
+
import { PreviewControls } from './PreviewControls';
|
|
5
|
+
import { composeStory, type ShellComponent } from './shell';
|
|
6
|
+
import type { Args, StoryEntry } from './types';
|
|
7
|
+
|
|
8
|
+
// Renders the selected story full-bleed, composed with the real shell (outer)
|
|
9
|
+
// 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
|
+
export function PreviewStage({
|
|
12
|
+
entry,
|
|
13
|
+
shell,
|
|
14
|
+
onBack,
|
|
15
|
+
}: {
|
|
16
|
+
entry: StoryEntry;
|
|
17
|
+
shell?: ShellComponent;
|
|
18
|
+
onBack: () => void;
|
|
19
|
+
}) {
|
|
20
|
+
const [args, setArgs] = useState<Args>(entry.args);
|
|
21
|
+
const [controlsOpen, setControlsOpen] = useState(false);
|
|
22
|
+
const controls = useMemo(() => inferControls(args, entry.argTypes), [args, entry.argTypes]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<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">
|
|
28
|
+
<Text style={styles.backText}>‹ {entry.title} / {entry.name}</Text>
|
|
29
|
+
</Pressable>
|
|
30
|
+
{controls.length > 0 && (
|
|
31
|
+
<Pressable
|
|
32
|
+
style={styles.toggle}
|
|
33
|
+
onPress={() => setControlsOpen((v) => !v)}
|
|
34
|
+
accessibilityLabel="Toggle props controls"
|
|
35
|
+
>
|
|
36
|
+
<Text style={styles.backText}>{controlsOpen ? 'Controls ▾' : 'Controls ▴'}</Text>
|
|
37
|
+
</Pressable>
|
|
38
|
+
)}
|
|
39
|
+
{controlsOpen && controls.length > 0 && (
|
|
40
|
+
<PreviewControls
|
|
41
|
+
controls={controls}
|
|
42
|
+
onChange={(name, value) => setArgs((a) => ({ ...a, [name]: value }))}
|
|
43
|
+
onReset={() => setArgs(entry.args)}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
root: { flex: 1 },
|
|
52
|
+
content: { flex: 1 },
|
|
53
|
+
back: {
|
|
54
|
+
position: 'absolute',
|
|
55
|
+
top: 56,
|
|
56
|
+
left: 12,
|
|
57
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
58
|
+
borderRadius: 999,
|
|
59
|
+
paddingHorizontal: 14,
|
|
60
|
+
paddingVertical: 8,
|
|
61
|
+
},
|
|
62
|
+
toggle: {
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
top: 56,
|
|
65
|
+
right: 12,
|
|
66
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
67
|
+
borderRadius: 999,
|
|
68
|
+
paddingHorizontal: 14,
|
|
69
|
+
paddingVertical: 8,
|
|
70
|
+
},
|
|
71
|
+
backText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
|
72
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { PreviewList } from './PreviewList';
|
|
3
|
+
import { PreviewStage } from './PreviewStage';
|
|
4
|
+
import type { ShellComponent } from './shell';
|
|
5
|
+
import type { StoryEntry } from './types';
|
|
6
|
+
|
|
7
|
+
// The reusable picker + stage UI. The host builds `stories` via
|
|
8
|
+
// fromRequireContext/fromGlob and passes its real `shell` (AppProviders).
|
|
9
|
+
// `initialStoryId` boots straight into one story (useful for deep links /
|
|
10
|
+
// screenshots / "jump to the thing I'm editing").
|
|
11
|
+
export function Previewer({
|
|
12
|
+
stories,
|
|
13
|
+
shell,
|
|
14
|
+
initialStoryId,
|
|
15
|
+
}: {
|
|
16
|
+
stories: StoryEntry[];
|
|
17
|
+
shell?: ShellComponent;
|
|
18
|
+
initialStoryId?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const initial = initialStoryId ? (stories.find((s) => s.id === initialStoryId) ?? null) : null;
|
|
21
|
+
const [selected, setSelected] = useState<StoryEntry | null>(initial);
|
|
22
|
+
|
|
23
|
+
if (selected) {
|
|
24
|
+
// key by story id so live-edited arg state resets cleanly per story.
|
|
25
|
+
return <PreviewStage key={selected.id} entry={selected} shell={shell} onBack={() => setSelected(null)} />;
|
|
26
|
+
}
|
|
27
|
+
return <PreviewList entries={stories} onSelect={setSelected} />;
|
|
28
|
+
}
|
package/src/controls.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Args, ArgType, ArgTypes, ControlKind } from './types';
|
|
2
|
+
|
|
3
|
+
// A render-ready control descriptor. Pure data — the UI maps these to inputs.
|
|
4
|
+
export type Control =
|
|
5
|
+
| { name: string; kind: 'text'; value: string }
|
|
6
|
+
| { name: string; kind: 'boolean'; value: boolean }
|
|
7
|
+
| { name: string; kind: 'number'; value: number; min?: number; max?: number; step?: number }
|
|
8
|
+
| { name: string; kind: 'select'; value: unknown; options: unknown[] };
|
|
9
|
+
|
|
10
|
+
type Bounds = { min?: number; max?: number; step?: number };
|
|
11
|
+
|
|
12
|
+
// Normalize an ArgType's `control` (string shorthand or { type, ... }) into a
|
|
13
|
+
// kind plus any numeric bounds. Returns kind: undefined when no control given.
|
|
14
|
+
function explicit(argType?: ArgType): { kind?: ControlKind } & Bounds {
|
|
15
|
+
const c = argType?.control;
|
|
16
|
+
if (!c) return {};
|
|
17
|
+
if (typeof c === 'string') {
|
|
18
|
+
return { kind: c, min: argType?.min, max: argType?.max, step: argType?.step };
|
|
19
|
+
}
|
|
20
|
+
return { kind: c.type, min: c.min ?? argType?.min, max: c.max ?? argType?.max, step: c.step ?? argType?.step };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toNumber(value: unknown): number {
|
|
24
|
+
return typeof value === 'number' ? value : Number(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toText(value: unknown): string {
|
|
28
|
+
return typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build controls for a story's editable args. Explicit `argTypes` win; otherwise
|
|
32
|
+
// the kind is inferred from the runtime value type. `range` becomes a number
|
|
33
|
+
// control with bounds. `select` needs `options`, else it degrades to text.
|
|
34
|
+
// Non-primitive values (function/object/array/null/undefined) with no explicit
|
|
35
|
+
// control are skipped — they pass through to render untouched.
|
|
36
|
+
export function inferControls(args: Args, argTypes: ArgTypes = {}): Control[] {
|
|
37
|
+
const controls: Control[] = [];
|
|
38
|
+
|
|
39
|
+
for (const name of Object.keys(args)) {
|
|
40
|
+
const value = args[name];
|
|
41
|
+
const { kind, min, max, step } = explicit(argTypes[name]);
|
|
42
|
+
|
|
43
|
+
if (kind === 'select') {
|
|
44
|
+
const options = argTypes[name]?.options;
|
|
45
|
+
if (options && options.length > 0) {
|
|
46
|
+
controls.push({ name, kind: 'select', value, options });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// No options to choose from — fall back to a text control.
|
|
50
|
+
controls.push({ name, kind: 'text', value: toText(value) });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (kind === 'number' || kind === 'range') {
|
|
55
|
+
controls.push({ name, kind: 'number', value: toNumber(value), min, max, step });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (kind === 'boolean') {
|
|
59
|
+
controls.push({ name, kind: 'boolean', value: Boolean(value) });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (kind === 'text') {
|
|
63
|
+
controls.push({ name, kind: 'text', value: toText(value) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// No explicit control — infer from the value type.
|
|
68
|
+
if (typeof value === 'string') controls.push({ name, kind: 'text', value });
|
|
69
|
+
else if (typeof value === 'number') controls.push({ name, kind: 'number', value });
|
|
70
|
+
else if (typeof value === 'boolean') controls.push({ name, kind: 'boolean', value });
|
|
71
|
+
// else: skipped (function/object/array/null/undefined).
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return controls;
|
|
75
|
+
}
|
package/src/csf.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import type { Args, ArgTypes, CsfModule, Meta, Story, StoryEntry } from './types';
|
|
3
|
+
|
|
4
|
+
const RESERVED = new Set(['default', '__esModule']);
|
|
5
|
+
|
|
6
|
+
function slug(s: string): string {
|
|
7
|
+
return s
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
10
|
+
.replace(/^-|-$/g, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function deriveTitle(meta: Meta | undefined, id: string): string {
|
|
14
|
+
if (meta?.title) return meta.title;
|
|
15
|
+
const c = meta?.component as { displayName?: string; name?: string } | undefined;
|
|
16
|
+
if (c?.displayName) return c.displayName;
|
|
17
|
+
if (c?.name) return c.name;
|
|
18
|
+
return id.replace(/^.*\//, '').replace(/\.stories\.[jt]sx?$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeStory(raw: unknown): Exclude<Story, (args: Args) => unknown> | null {
|
|
22
|
+
if (typeof raw === 'function') return { render: raw as (args: Args) => ReturnType<typeof createElement> };
|
|
23
|
+
if (raw && typeof raw === 'object') return raw as Exclude<Story, (args: Args) => unknown>;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Parse one CSF module into normalized story entries. Pure: no rendering, no
|
|
28
|
+
// host libraries. This is the heart of the surface-agnostic core.
|
|
29
|
+
export function parseCsfModule(id: string, mod: CsfModule): StoryEntry[] {
|
|
30
|
+
const meta = mod.default;
|
|
31
|
+
const title = deriveTitle(meta, id);
|
|
32
|
+
const entries: StoryEntry[] = [];
|
|
33
|
+
|
|
34
|
+
for (const exportName of Object.keys(mod)) {
|
|
35
|
+
if (RESERVED.has(exportName)) continue;
|
|
36
|
+
const story = normalizeStory(mod[exportName]);
|
|
37
|
+
if (!story) continue;
|
|
38
|
+
|
|
39
|
+
const args: Args = { ...(meta?.args ?? {}), ...(story.args ?? {}) };
|
|
40
|
+
// argTypes merge with the same precedence as args (story wins over meta).
|
|
41
|
+
const argTypes: ArgTypes = { ...(meta?.argTypes ?? {}), ...(story.argTypes ?? {}) };
|
|
42
|
+
// Story decorators wrap innermost, meta decorators outer (Storybook order).
|
|
43
|
+
const decorators = [...(story.decorators ?? []), ...(meta?.decorators ?? [])];
|
|
44
|
+
const name = story.name ?? exportName;
|
|
45
|
+
|
|
46
|
+
const render =
|
|
47
|
+
story.render ??
|
|
48
|
+
((a: Args) => {
|
|
49
|
+
if (!meta?.component) {
|
|
50
|
+
throw new Error(`Story "${exportName}" in ${id}: no render fn and meta has no component`);
|
|
51
|
+
}
|
|
52
|
+
return createElement(meta.component, a);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
entries.push({ id: `${slug(title)}--${slug(name)}`, title, name, args, argTypes, decorators, render });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Surface-agnostic core
|
|
2
|
+
export { parseCsfModule } from './csf';
|
|
3
|
+
export { buildRegistry, fromRequireContext, fromGlob } from './registry';
|
|
4
|
+
export { composeStory } from './shell';
|
|
5
|
+
export type { ShellComponent } from './shell';
|
|
6
|
+
export { inferControls } from './controls';
|
|
7
|
+
export type { Control } from './controls';
|
|
8
|
+
export type {
|
|
9
|
+
Args,
|
|
10
|
+
ArgType,
|
|
11
|
+
ArgTypes,
|
|
12
|
+
ControlKind,
|
|
13
|
+
CsfModule,
|
|
14
|
+
Decorator,
|
|
15
|
+
GlobModules,
|
|
16
|
+
Meta,
|
|
17
|
+
RequireContext,
|
|
18
|
+
Story,
|
|
19
|
+
StoryEntry,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
// UI (reused by backends)
|
|
23
|
+
export { Previewer } from './Previewer';
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parseCsfModule } from './csf';
|
|
2
|
+
import type { CsfModule, GlobModules, RequireContext, StoryEntry } from './types';
|
|
3
|
+
|
|
4
|
+
// Build a sorted, flattened registry from already-loaded story modules.
|
|
5
|
+
export function buildRegistry(modules: Array<{ id: string; module: CsfModule }>): StoryEntry[] {
|
|
6
|
+
return modules
|
|
7
|
+
.slice()
|
|
8
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
9
|
+
.flatMap(({ id, module }) => parseCsfModule(id, module));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Discovery adapter — Metro's require.context (native + web/Metro).
|
|
13
|
+
export function fromRequireContext(ctx: RequireContext): StoryEntry[] {
|
|
14
|
+
return buildRegistry(ctx.keys().map((id) => ({ id, module: ctx(id) as CsfModule })));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Discovery adapter — Vite's eager import.meta.glob (web/Vite backend).
|
|
18
|
+
// Pass `import.meta.glob('...', { eager: true })`.
|
|
19
|
+
export function fromGlob(glob: GlobModules): StoryEntry[] {
|
|
20
|
+
return buildRegistry(Object.entries(glob).map(([id, module]) => ({ id, module })));
|
|
21
|
+
}
|
package/src/shell.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import type { ComponentType, ReactElement } from 'react';
|
|
3
|
+
import type { Args, StoryEntry } from './types';
|
|
4
|
+
|
|
5
|
+
export type ShellComponent = ComponentType<{ children: ReactElement }>;
|
|
6
|
+
|
|
7
|
+
// Compose the full element for a story:
|
|
8
|
+
// AppShell (outer, single source of truth) > decorators > story.render(args).
|
|
9
|
+
// The real-shell contract: the host's AppShell ALWAYS wraps outside; CSF
|
|
10
|
+
// decorators run inside it. Pure — returns an element tree, does not mount.
|
|
11
|
+
// `args` defaults to the story's declared args; pass an override for live edits.
|
|
12
|
+
export function composeStory(entry: StoryEntry, Shell?: ShellComponent, args: Args = entry.args): ReactElement {
|
|
13
|
+
let node: ReactElement = entry.render(args);
|
|
14
|
+
|
|
15
|
+
for (const decorate of entry.decorators) {
|
|
16
|
+
const inner = node;
|
|
17
|
+
const Story: ComponentType = () => inner;
|
|
18
|
+
node = decorate(Story);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Shell ? createElement(Shell, null, node) : node;
|
|
22
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
// --- CSF (Component Story Format) — compatible with Storybook stories ---
|
|
4
|
+
|
|
5
|
+
export type Args = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
// A decorator receives the story as a component and returns a wrapped element,
|
|
8
|
+
// e.g. (Story) => <Provider><Story /></Provider>.
|
|
9
|
+
export type Decorator = (Story: ComponentType) => ReactElement;
|
|
10
|
+
|
|
11
|
+
// CSF-compatible control hints. Optional: controls default to value-inference.
|
|
12
|
+
// `control` accepts the Storybook string shorthand or an object { type, ... }.
|
|
13
|
+
export type ControlKind = 'text' | 'number' | 'boolean' | 'select' | 'range';
|
|
14
|
+
|
|
15
|
+
export type ArgType = {
|
|
16
|
+
control?: ControlKind | { type: ControlKind; min?: number; max?: number; step?: number };
|
|
17
|
+
options?: unknown[]; // for 'select'
|
|
18
|
+
min?: number;
|
|
19
|
+
max?: number;
|
|
20
|
+
step?: number; // for 'range' shorthand (alongside control: 'range')
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ArgTypes = Record<string, ArgType>;
|
|
24
|
+
|
|
25
|
+
// `export default` of a *.stories.tsx file.
|
|
26
|
+
export type Meta = {
|
|
27
|
+
title?: string;
|
|
28
|
+
// Components have varied prop shapes; CSF passes args at runtime. Permissive
|
|
29
|
+
// by design so story files need no casts.
|
|
30
|
+
component?: ComponentType<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
31
|
+
args?: Args;
|
|
32
|
+
argTypes?: ArgTypes;
|
|
33
|
+
decorators?: Decorator[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// A named export of a *.stories.tsx file (CSF3 object, or a CSF2 render fn).
|
|
37
|
+
export type Story =
|
|
38
|
+
| {
|
|
39
|
+
name?: string;
|
|
40
|
+
args?: Args;
|
|
41
|
+
argTypes?: ArgTypes;
|
|
42
|
+
decorators?: Decorator[];
|
|
43
|
+
render?: (args: Args) => ReactElement;
|
|
44
|
+
}
|
|
45
|
+
| ((args: Args) => ReactElement);
|
|
46
|
+
|
|
47
|
+
// A loaded story module: default = meta, named exports = stories.
|
|
48
|
+
export type CsfModule = { default?: Meta } & Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
// One normalized, render-ready story. Backend-agnostic: native and web both
|
|
51
|
+
// consume this identical shape.
|
|
52
|
+
export type StoryEntry = {
|
|
53
|
+
id: string; // 'button--primary'
|
|
54
|
+
title: string; // group, e.g. 'Button'
|
|
55
|
+
name: string; // story name, e.g. 'Primary'
|
|
56
|
+
args: Args;
|
|
57
|
+
argTypes: ArgTypes; // merged meta+story argTypes (possibly empty)
|
|
58
|
+
decorators: Decorator[];
|
|
59
|
+
render: (args: Args) => ReactElement;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// --- Discovery inputs (host-side, zero-codegen) ---
|
|
63
|
+
|
|
64
|
+
// Metro's require.context.
|
|
65
|
+
export type RequireContext = { keys(): string[]; (id: string): unknown };
|
|
66
|
+
|
|
67
|
+
// Vite's eager import.meta.glob result: path -> module.
|
|
68
|
+
export type GlobModules = Record<string, CsfModule>;
|