@component-anatomy/storybook 0.0.2 → 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/README.md +42 -1
- package/dist/AnatomyTable.d.ts +39 -0
- package/dist/AnatomyTable.d.ts.map +1 -0
- package/dist/Panel.d.ts.map +1 -1
- package/dist/blocks.d.ts +48 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +242 -0
- package/dist/blocks.js.map +7 -0
- package/dist/channel.d.ts +39 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/constants.d.ts +13 -7
- package/dist/constants.d.ts.map +1 -1
- package/dist/index.cjs +6 -6
- package/dist/index.cjs.map +2 -2
- package/dist/index.js +6 -6
- package/dist/index.js.map +2 -2
- package/dist/manager.js +114 -50
- package/dist/manager.js.map +4 -4
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +27 -13
- package/dist/preview.js.map +3 -3
- package/package.json +24 -6
- package/src/AnatomyTable.tsx +210 -0
- package/src/Panel.tsx +39 -130
- package/src/blocks.tsx +187 -0
- package/src/channel.ts +35 -0
- package/src/constants.ts +13 -7
- package/src/index.ts +5 -0
- package/src/preview.ts +32 -9
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The anatomy part table — pure presentation, no Storybook imports.
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately free of `storybook/manager-api` and
|
|
5
|
+
* `storybook/preview-api`: it is bundled into *both* the manager entry (the
|
|
6
|
+
* addon panel) and the blocks entry (the `<Anatomy>` MDX doc block), which
|
|
7
|
+
* run in different runtimes and cannot share either of those APIs. Only the
|
|
8
|
+
* data acquisition differs between the two; the markup must not.
|
|
9
|
+
*/
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { useTheme } from 'storybook/theming';
|
|
12
|
+
import type { AnatomyPartDefinition } from '@component-anatomy/core';
|
|
13
|
+
|
|
14
|
+
/** Fallback accent when the story sets no `anatomy.theme.accent`. */
|
|
15
|
+
export const ACCENT_FALLBACK = '#4f46e5';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The subset of Storybook's theme this table reads. Typed loosely on purpose:
|
|
19
|
+
* `useTheme()` resolves to an empty `Theme` interface (Emotion augmentation),
|
|
20
|
+
* and outside a ThemeProvider it returns `{}` — so every read is optional and
|
|
21
|
+
* every value has a literal fallback below.
|
|
22
|
+
*/
|
|
23
|
+
type PartialStorybookTheme = {
|
|
24
|
+
fgColor?: { default?: string; muted?: string };
|
|
25
|
+
bgColor?: { muted?: string };
|
|
26
|
+
borderColor?: { default?: string };
|
|
27
|
+
typography?: { fonts?: { base?: string; mono?: string } };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const FONT_BASE_FALLBACK =
|
|
31
|
+
'"Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif';
|
|
32
|
+
const FONT_MONO_FALLBACK = 'ui-monospace, "Cascadia Code", "Fira Mono", monospace';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the table's chrome colors from Storybook's theme so the same
|
|
36
|
+
* markup reads correctly in the manager panel *and* in a docs page under a
|
|
37
|
+
* dark theme. The accent is intentionally not theme-derived — it is the
|
|
38
|
+
* addon's identity color and stays stable unless a story overrides it.
|
|
39
|
+
*/
|
|
40
|
+
function usePalette() {
|
|
41
|
+
const theme = useTheme() as PartialStorybookTheme;
|
|
42
|
+
return {
|
|
43
|
+
text: theme.fgColor?.default,
|
|
44
|
+
muted: theme.fgColor?.muted ?? '#6b7280',
|
|
45
|
+
mutedBg: theme.bgColor?.muted ?? 'rgba(0,0,0,0.06)',
|
|
46
|
+
border: theme.borderColor?.default ?? 'rgba(0,0,0,0.08)',
|
|
47
|
+
fontBase: theme.typography?.fonts?.base ?? FONT_BASE_FALLBACK,
|
|
48
|
+
fontMono: theme.typography?.fonts?.mono ?? FONT_MONO_FALLBACK,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Palette = ReturnType<typeof usePalette>;
|
|
53
|
+
|
|
54
|
+
const makeStyles = (p: Palette): Record<string, React.CSSProperties> => ({
|
|
55
|
+
container: {
|
|
56
|
+
padding: '12px 16px',
|
|
57
|
+
fontFamily: p.fontBase,
|
|
58
|
+
fontSize: 13,
|
|
59
|
+
lineHeight: 1.5,
|
|
60
|
+
color: p.text,
|
|
61
|
+
},
|
|
62
|
+
empty: {
|
|
63
|
+
color: p.muted,
|
|
64
|
+
margin: 0,
|
|
65
|
+
},
|
|
66
|
+
code: {
|
|
67
|
+
fontFamily: p.fontMono,
|
|
68
|
+
fontSize: '0.85em',
|
|
69
|
+
background: p.mutedBg,
|
|
70
|
+
borderRadius: 3,
|
|
71
|
+
padding: '1px 5px',
|
|
72
|
+
},
|
|
73
|
+
list: {
|
|
74
|
+
display: 'flex',
|
|
75
|
+
flexDirection: 'column',
|
|
76
|
+
gap: 2,
|
|
77
|
+
margin: 0,
|
|
78
|
+
padding: 0,
|
|
79
|
+
listStyle: 'none',
|
|
80
|
+
},
|
|
81
|
+
entry: {
|
|
82
|
+
padding: '8px 10px',
|
|
83
|
+
borderRadius: 6,
|
|
84
|
+
border: '1px solid transparent',
|
|
85
|
+
cursor: 'default',
|
|
86
|
+
outline: 'none',
|
|
87
|
+
transition: 'background 120ms ease, border-color 120ms ease',
|
|
88
|
+
},
|
|
89
|
+
header: {
|
|
90
|
+
display: 'flex',
|
|
91
|
+
alignItems: 'center',
|
|
92
|
+
gap: 8,
|
|
93
|
+
},
|
|
94
|
+
indicator: {
|
|
95
|
+
width: 8,
|
|
96
|
+
height: 8,
|
|
97
|
+
borderRadius: '50%',
|
|
98
|
+
border: `2px solid ${p.border}`,
|
|
99
|
+
flexShrink: 0,
|
|
100
|
+
transition: 'background 120ms ease, border-color 120ms ease',
|
|
101
|
+
},
|
|
102
|
+
name: {
|
|
103
|
+
fontWeight: 700,
|
|
104
|
+
flex: 1,
|
|
105
|
+
},
|
|
106
|
+
id: {
|
|
107
|
+
fontFamily: p.fontMono,
|
|
108
|
+
fontSize: 10,
|
|
109
|
+
padding: '1px 6px',
|
|
110
|
+
borderRadius: 4,
|
|
111
|
+
background: p.mutedBg,
|
|
112
|
+
border: `1px solid ${p.border}`,
|
|
113
|
+
color: p.muted,
|
|
114
|
+
flexShrink: 0,
|
|
115
|
+
},
|
|
116
|
+
description: {
|
|
117
|
+
margin: '4px 0 0',
|
|
118
|
+
paddingLeft: 16,
|
|
119
|
+
color: p.muted,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* An inline `<code>` styled for the surrounding message text. Exported so
|
|
125
|
+
* both runtimes can compose their own empty-state copy without duplicating
|
|
126
|
+
* the style object.
|
|
127
|
+
*/
|
|
128
|
+
export const AnatomyCode: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
129
|
+
const styles = makeStyles(usePalette());
|
|
130
|
+
return <code style={styles.code}>{children}</code>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** A padded, muted paragraph — used for every "nothing to show" state. */
|
|
134
|
+
export const AnatomyMessage: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
135
|
+
const styles = makeStyles(usePalette());
|
|
136
|
+
return (
|
|
137
|
+
<div style={styles.container}>
|
|
138
|
+
<p style={styles.empty}>{children}</p>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type AnatomyTableProps = {
|
|
144
|
+
/** Parts to list, in the order they should be shown. */
|
|
145
|
+
parts: AnatomyPartDefinition[];
|
|
146
|
+
/** Id of the part currently highlighted in the canvas, if any. */
|
|
147
|
+
activeId?: string | null;
|
|
148
|
+
/** Accent color for the active state. Defaults to {@link ACCENT_FALLBACK}. */
|
|
149
|
+
accent?: string;
|
|
150
|
+
/** Called when the user hovers or focuses an entry. */
|
|
151
|
+
onItemEnter?: (partId: string) => void;
|
|
152
|
+
/** Called when the user leaves or blurs an entry. */
|
|
153
|
+
onItemLeave?: () => void;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const AnatomyTable: React.FC<AnatomyTableProps> = ({
|
|
157
|
+
parts,
|
|
158
|
+
activeId = null,
|
|
159
|
+
accent = ACCENT_FALLBACK,
|
|
160
|
+
onItemEnter,
|
|
161
|
+
onItemLeave,
|
|
162
|
+
}) => {
|
|
163
|
+
const palette = usePalette();
|
|
164
|
+
const styles = makeStyles(palette);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div style={styles.container}>
|
|
168
|
+
<ul style={styles.list} role="list" aria-label="Anatomy parts">
|
|
169
|
+
{parts.map((part) => {
|
|
170
|
+
const active = activeId === part.id;
|
|
171
|
+
return (
|
|
172
|
+
<li
|
|
173
|
+
key={part.id}
|
|
174
|
+
role="listitem"
|
|
175
|
+
tabIndex={0}
|
|
176
|
+
aria-label={part.name}
|
|
177
|
+
style={{
|
|
178
|
+
...styles.entry,
|
|
179
|
+
background: active ? `color-mix(in srgb, ${accent} 7%, transparent)` : undefined,
|
|
180
|
+
borderColor: active
|
|
181
|
+
? `color-mix(in srgb, ${accent} 20%, transparent)`
|
|
182
|
+
: 'transparent',
|
|
183
|
+
}}
|
|
184
|
+
onMouseEnter={() => onItemEnter?.(part.id)}
|
|
185
|
+
onMouseLeave={() => onItemLeave?.()}
|
|
186
|
+
onFocus={() => onItemEnter?.(part.id)}
|
|
187
|
+
onBlur={() => onItemLeave?.()}
|
|
188
|
+
>
|
|
189
|
+
<div style={styles.header}>
|
|
190
|
+
<span
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
style={{
|
|
193
|
+
...styles.indicator,
|
|
194
|
+
background: active ? accent : undefined,
|
|
195
|
+
borderColor: active ? accent : palette.border,
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
<span style={{ ...styles.name, color: active ? accent : undefined }}>
|
|
199
|
+
{part.name}
|
|
200
|
+
</span>
|
|
201
|
+
<code style={styles.id}>{part.id}</code>
|
|
202
|
+
</div>
|
|
203
|
+
{part.description && <p style={styles.description}>{part.description}</p>}
|
|
204
|
+
</li>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</ul>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
};
|
package/src/Panel.tsx
CHANGED
|
@@ -3,78 +3,11 @@ import { useChannel, useParameter, useStorybookApi } from 'storybook/manager-api
|
|
|
3
3
|
import type { AnatomyPartDefinition } from '@component-anatomy/core';
|
|
4
4
|
|
|
5
5
|
import { EVENTS, PARAM_KEY } from './constants.js';
|
|
6
|
+
import { matchesStory } from './channel.js';
|
|
7
|
+
import type { PartEnterEvent, PartsEvent, StoryScopedEvent } from './channel.js';
|
|
8
|
+
import { ACCENT_FALLBACK, AnatomyCode, AnatomyMessage, AnatomyTable } from './AnatomyTable.js';
|
|
6
9
|
import type { AnatomyParameters } from './types.js';
|
|
7
10
|
|
|
8
|
-
const ACCENT_FALLBACK = '#4f46e5';
|
|
9
|
-
|
|
10
|
-
const styles: Record<string, React.CSSProperties> = {
|
|
11
|
-
container: {
|
|
12
|
-
padding: '12px 16px',
|
|
13
|
-
fontFamily:
|
|
14
|
-
'"Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
15
|
-
fontSize: 13,
|
|
16
|
-
lineHeight: 1.5,
|
|
17
|
-
},
|
|
18
|
-
empty: {
|
|
19
|
-
color: '#6b7280',
|
|
20
|
-
},
|
|
21
|
-
code: {
|
|
22
|
-
fontFamily: 'ui-monospace, "Cascadia Code", "Fira Mono", monospace',
|
|
23
|
-
fontSize: '0.85em',
|
|
24
|
-
background: 'rgba(0,0,0,0.06)',
|
|
25
|
-
borderRadius: 3,
|
|
26
|
-
padding: '1px 5px',
|
|
27
|
-
},
|
|
28
|
-
list: {
|
|
29
|
-
display: 'flex',
|
|
30
|
-
flexDirection: 'column',
|
|
31
|
-
gap: 2,
|
|
32
|
-
margin: 0,
|
|
33
|
-
padding: 0,
|
|
34
|
-
listStyle: 'none',
|
|
35
|
-
},
|
|
36
|
-
entry: {
|
|
37
|
-
padding: '8px 10px',
|
|
38
|
-
borderRadius: 6,
|
|
39
|
-
border: '1px solid transparent',
|
|
40
|
-
cursor: 'default',
|
|
41
|
-
outline: 'none',
|
|
42
|
-
transition: 'background 120ms ease, border-color 120ms ease',
|
|
43
|
-
},
|
|
44
|
-
header: {
|
|
45
|
-
display: 'flex',
|
|
46
|
-
alignItems: 'center',
|
|
47
|
-
gap: 8,
|
|
48
|
-
},
|
|
49
|
-
indicator: {
|
|
50
|
-
width: 8,
|
|
51
|
-
height: 8,
|
|
52
|
-
borderRadius: '50%',
|
|
53
|
-
border: '2px solid #d1d5db',
|
|
54
|
-
flexShrink: 0,
|
|
55
|
-
transition: 'background 120ms ease, border-color 120ms ease',
|
|
56
|
-
},
|
|
57
|
-
name: {
|
|
58
|
-
fontWeight: 700,
|
|
59
|
-
flex: 1,
|
|
60
|
-
},
|
|
61
|
-
id: {
|
|
62
|
-
fontFamily: 'ui-monospace, "Cascadia Code", "Fira Mono", monospace',
|
|
63
|
-
fontSize: 10,
|
|
64
|
-
padding: '1px 6px',
|
|
65
|
-
borderRadius: 4,
|
|
66
|
-
background: 'rgba(0,0,0,0.05)',
|
|
67
|
-
border: '1px solid rgba(0,0,0,0.08)',
|
|
68
|
-
color: '#6b7280',
|
|
69
|
-
flexShrink: 0,
|
|
70
|
-
},
|
|
71
|
-
description: {
|
|
72
|
-
margin: '4px 0 0',
|
|
73
|
-
paddingLeft: 16,
|
|
74
|
-
color: '#6b7280',
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
|
|
78
11
|
export const Panel: React.FC = () => {
|
|
79
12
|
const params = useParameter<AnatomyParameters | undefined>(PARAM_KEY, undefined);
|
|
80
13
|
const api = useStorybookApi();
|
|
@@ -83,17 +16,31 @@ export const Panel: React.FC = () => {
|
|
|
83
16
|
const [discovered, setDiscovered] = useState<AnatomyPartDefinition[]>([]);
|
|
84
17
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
85
18
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
19
|
+
// `useChannel` captures its handlers on the given deps — `storyId` has to be
|
|
20
|
+
// listed or the filters below would close over a stale story.
|
|
21
|
+
const emit = useChannel(
|
|
22
|
+
{
|
|
23
|
+
[EVENTS.PARTS]: (event: PartsEvent) => {
|
|
24
|
+
if (!matchesStory(event?.storyId, storyId)) return;
|
|
25
|
+
setDiscovered(event.parts);
|
|
26
|
+
},
|
|
27
|
+
[EVENTS.PART_ENTER]: (event: PartEnterEvent) => {
|
|
28
|
+
if (!matchesStory(event?.storyId, storyId)) return;
|
|
29
|
+
setActiveId(event.partId);
|
|
30
|
+
},
|
|
31
|
+
[EVENTS.PART_LEAVE]: (event: StoryScopedEvent = {}) => {
|
|
32
|
+
if (!matchesStory(event?.storyId, storyId)) return;
|
|
33
|
+
setActiveId(null);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
[storyId]
|
|
37
|
+
);
|
|
91
38
|
|
|
92
39
|
// Ask the preview for the current part list on mount / story change.
|
|
93
40
|
useEffect(() => {
|
|
94
41
|
setDiscovered([]);
|
|
95
42
|
setActiveId(null);
|
|
96
|
-
emit(EVENTS.PARTS_REQUEST);
|
|
43
|
+
emit(EVENTS.PARTS_REQUEST, { storyId } satisfies StoryScopedEvent);
|
|
97
44
|
}, [storyId]);
|
|
98
45
|
|
|
99
46
|
const parts = params?.parts ?? discovered;
|
|
@@ -101,68 +48,30 @@ export const Panel: React.FC = () => {
|
|
|
101
48
|
|
|
102
49
|
if (!params || params.disable) {
|
|
103
50
|
return (
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<code style={styles.code}>data-part="name"</code>.
|
|
109
|
-
</p>
|
|
110
|
-
</div>
|
|
51
|
+
<AnatomyMessage>
|
|
52
|
+
No anatomy configured for this story. Add <AnatomyCode>parameters.anatomy</AnatomyCode> and
|
|
53
|
+
annotate elements with <AnatomyCode>data-part="name"</AnatomyCode>.
|
|
54
|
+
</AnatomyMessage>
|
|
111
55
|
);
|
|
112
56
|
}
|
|
113
57
|
|
|
114
58
|
if (parts.length === 0) {
|
|
115
59
|
return (
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
</p>
|
|
122
|
-
</div>
|
|
60
|
+
<AnatomyMessage>
|
|
61
|
+
No parts found. Annotate elements in your story with{' '}
|
|
62
|
+
<AnatomyCode>data-part="name"</AnatomyCode> or pass{' '}
|
|
63
|
+
<AnatomyCode>parameters.anatomy.parts</AnatomyCode>.
|
|
64
|
+
</AnatomyMessage>
|
|
123
65
|
);
|
|
124
66
|
}
|
|
125
67
|
|
|
126
68
|
return (
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
role="listitem"
|
|
135
|
-
tabIndex={0}
|
|
136
|
-
aria-label={part.name}
|
|
137
|
-
style={{
|
|
138
|
-
...styles.entry,
|
|
139
|
-
background: active ? `color-mix(in srgb, ${accent} 7%, transparent)` : undefined,
|
|
140
|
-
borderColor: active ? `color-mix(in srgb, ${accent} 20%, transparent)` : 'transparent',
|
|
141
|
-
}}
|
|
142
|
-
onMouseEnter={() => emit(EVENTS.HOVER_ITEM, { partId: part.id })}
|
|
143
|
-
onMouseLeave={() => emit(EVENTS.LEAVE_ITEM)}
|
|
144
|
-
onFocus={() => emit(EVENTS.HOVER_ITEM, { partId: part.id })}
|
|
145
|
-
onBlur={() => emit(EVENTS.LEAVE_ITEM)}
|
|
146
|
-
>
|
|
147
|
-
<div style={styles.header}>
|
|
148
|
-
<span
|
|
149
|
-
aria-hidden="true"
|
|
150
|
-
style={{
|
|
151
|
-
...styles.indicator,
|
|
152
|
-
background: active ? accent : undefined,
|
|
153
|
-
borderColor: active ? accent : '#d1d5db',
|
|
154
|
-
}}
|
|
155
|
-
/>
|
|
156
|
-
<span style={{ ...styles.name, color: active ? accent : undefined }}>
|
|
157
|
-
{part.name}
|
|
158
|
-
</span>
|
|
159
|
-
<code style={styles.id}>{part.id}</code>
|
|
160
|
-
</div>
|
|
161
|
-
{part.description && <p style={styles.description}>{part.description}</p>}
|
|
162
|
-
</li>
|
|
163
|
-
);
|
|
164
|
-
})}
|
|
165
|
-
</ul>
|
|
166
|
-
</div>
|
|
69
|
+
<AnatomyTable
|
|
70
|
+
parts={parts}
|
|
71
|
+
activeId={activeId}
|
|
72
|
+
accent={accent}
|
|
73
|
+
onItemEnter={(partId) => emit(EVENTS.HOVER_ITEM, { storyId, partId })}
|
|
74
|
+
onItemLeave={() => emit(EVENTS.LEAVE_ITEM, { storyId })}
|
|
75
|
+
/>
|
|
167
76
|
);
|
|
168
77
|
};
|
package/src/blocks.tsx
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs blocks entry — the `<Anatomy>` block for MDX pages.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the addon panel, this runs in the **preview iframe**, where
|
|
5
|
+
* `storybook/manager-api` does not exist. It reaches the story's controller
|
|
6
|
+
* over the addon channel instead: `Channel.emit` dispatches to local
|
|
7
|
+
* listeners as well as across transports, so a block and the decorator that
|
|
8
|
+
* mounted the story talk to each other directly, in-frame, with no extra
|
|
9
|
+
* plumbing.
|
|
10
|
+
*
|
|
11
|
+
* ```mdx
|
|
12
|
+
* import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
|
13
|
+
* import { Anatomy } from '@component-anatomy/storybook/blocks';
|
|
14
|
+
* import * as ButtonStories from './Button.stories';
|
|
15
|
+
*
|
|
16
|
+
* <Meta of={ButtonStories} />
|
|
17
|
+
*
|
|
18
|
+
* <Canvas of={ButtonStories.Anatomy} />
|
|
19
|
+
* <Anatomy of={ButtonStories.Anatomy} />
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import React, { useEffect, useState } from 'react';
|
|
23
|
+
import { addons } from 'storybook/preview-api';
|
|
24
|
+
import { Unstyled, useOf } from '@storybook/addon-docs/blocks';
|
|
25
|
+
import type { Of } from '@storybook/addon-docs/blocks';
|
|
26
|
+
import type { AnatomyPartDefinition } from '@component-anatomy/core';
|
|
27
|
+
|
|
28
|
+
import { EVENTS, PARAM_KEY } from './constants.js';
|
|
29
|
+
import { matchesStory } from './channel.js';
|
|
30
|
+
import type { PartEnterEvent, PartsEvent, StoryScopedEvent } from './channel.js';
|
|
31
|
+
import { ACCENT_FALLBACK, AnatomyCode, AnatomyMessage, AnatomyTable } from './AnatomyTable.js';
|
|
32
|
+
import type { AnatomyParameters } from './types.js';
|
|
33
|
+
|
|
34
|
+
export type AnatomyBlockProps = {
|
|
35
|
+
/**
|
|
36
|
+
* The CSF export to document — a story export, or the whole module export
|
|
37
|
+
* of a CSF file to read the meta's parameters.
|
|
38
|
+
*
|
|
39
|
+
* Omit it on an attached docs page (one with `<Meta of={...} />`, or an
|
|
40
|
+
* autodocs page) to fall back to the page's current story, mirroring how
|
|
41
|
+
* the other docs blocks resolve `of`.
|
|
42
|
+
*/
|
|
43
|
+
of?: Of;
|
|
44
|
+
/**
|
|
45
|
+
* Part list override. Skips both `parameters.anatomy.parts` and
|
|
46
|
+
* auto-discovery — useful for a hand-curated subset in prose.
|
|
47
|
+
*/
|
|
48
|
+
parts?: AnatomyPartDefinition[];
|
|
49
|
+
/**
|
|
50
|
+
* Two-way hover sync with the rendered story. Default: `true`. Set to
|
|
51
|
+
* `false` for a purely static table (also skips auto-discovery, since that
|
|
52
|
+
* arrives over the channel).
|
|
53
|
+
*/
|
|
54
|
+
sync?: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `addons.getChannel()` throws when no channel is installed. That should not
|
|
59
|
+
* happen inside a rendered docs page, but an MDX page is user-authored
|
|
60
|
+
* content and a throw here would blank the whole page — degrade to a static
|
|
61
|
+
* table instead.
|
|
62
|
+
*/
|
|
63
|
+
function getChannelSafely() {
|
|
64
|
+
try {
|
|
65
|
+
return addons.getChannel();
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const Anatomy: React.FC<AnatomyBlockProps> = ({ of, parts: partsProp, sync = true }) => {
|
|
72
|
+
const resolved = useOf(of ?? 'story', ['story', 'meta']);
|
|
73
|
+
|
|
74
|
+
const params = (
|
|
75
|
+
resolved.type === 'meta'
|
|
76
|
+
? resolved.preparedMeta.parameters?.[PARAM_KEY]
|
|
77
|
+
: resolved.story.parameters?.[PARAM_KEY]
|
|
78
|
+
) as AnatomyParameters | undefined;
|
|
79
|
+
|
|
80
|
+
// Only a story has a canvas to sync with. `of={SomeStories}` (a meta)
|
|
81
|
+
// documents the component as a whole and can render static parts only.
|
|
82
|
+
const storyId = resolved.type === 'story' ? resolved.story.id : undefined;
|
|
83
|
+
|
|
84
|
+
const [discovered, setDiscovered] = useState<AnatomyPartDefinition[]>([]);
|
|
85
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
86
|
+
|
|
87
|
+
const staticParts = partsProp ?? params?.parts;
|
|
88
|
+
// Hover sync is wired even when the parts are static — an explicit list
|
|
89
|
+
// still wants the canvas to light up. Only discovery depends on the channel.
|
|
90
|
+
const wired = sync && !!storyId;
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setDiscovered([]);
|
|
94
|
+
setActiveId(null);
|
|
95
|
+
if (!wired) return;
|
|
96
|
+
|
|
97
|
+
const channel = getChannelSafely();
|
|
98
|
+
if (!channel) return;
|
|
99
|
+
|
|
100
|
+
const onParts = (event: PartsEvent) => {
|
|
101
|
+
if (!matchesStory(event?.storyId, storyId)) return;
|
|
102
|
+
setDiscovered(event.parts);
|
|
103
|
+
};
|
|
104
|
+
const onEnter = (event: PartEnterEvent) => {
|
|
105
|
+
if (!matchesStory(event?.storyId, storyId)) return;
|
|
106
|
+
setActiveId(event.partId);
|
|
107
|
+
};
|
|
108
|
+
const onLeave = (event: StoryScopedEvent = {}) => {
|
|
109
|
+
if (!matchesStory(event?.storyId, storyId)) return;
|
|
110
|
+
setActiveId(null);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
channel.on(EVENTS.PARTS, onParts);
|
|
114
|
+
channel.on(EVENTS.PART_ENTER, onEnter);
|
|
115
|
+
channel.on(EVENTS.PART_LEAVE, onLeave);
|
|
116
|
+
|
|
117
|
+
// The block usually mounts before the story below it finishes rendering;
|
|
118
|
+
// the request covers the other order.
|
|
119
|
+
channel.emit(EVENTS.PARTS_REQUEST, { storyId } satisfies StoryScopedEvent);
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
channel.off(EVENTS.PARTS, onParts);
|
|
123
|
+
channel.off(EVENTS.PART_ENTER, onEnter);
|
|
124
|
+
channel.off(EVENTS.PART_LEAVE, onLeave);
|
|
125
|
+
};
|
|
126
|
+
}, [wired, storyId]);
|
|
127
|
+
|
|
128
|
+
const parts = staticParts ?? discovered;
|
|
129
|
+
const accent = params?.theme?.accent ?? ACCENT_FALLBACK;
|
|
130
|
+
|
|
131
|
+
const emitHover = (partId: string) => {
|
|
132
|
+
if (!wired) return;
|
|
133
|
+
getChannelSafely()?.emit(EVENTS.HOVER_ITEM, { storyId, partId });
|
|
134
|
+
};
|
|
135
|
+
const emitLeave = () => {
|
|
136
|
+
if (!wired) return;
|
|
137
|
+
getChannelSafely()?.emit(EVENTS.LEAVE_ITEM, { storyId } satisfies StoryScopedEvent);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// `Unstyled` keeps the docs page's prose CSS (`.sbdocs` restyles ul/li/p/
|
|
141
|
+
// code) from reaching the table.
|
|
142
|
+
if (!params && !partsProp) {
|
|
143
|
+
return (
|
|
144
|
+
<Unstyled>
|
|
145
|
+
<AnatomyMessage>
|
|
146
|
+
No anatomy configured. Add <AnatomyCode>parameters.anatomy</AnatomyCode> to the story you
|
|
147
|
+
pass to <AnatomyCode>of</AnatomyCode>, or pass a{' '}
|
|
148
|
+
<AnatomyCode>parts</AnatomyCode> list to this block.
|
|
149
|
+
</AnatomyMessage>
|
|
150
|
+
</Unstyled>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (params?.disable && !partsProp) {
|
|
155
|
+
return (
|
|
156
|
+
<Unstyled>
|
|
157
|
+
<AnatomyMessage>
|
|
158
|
+
Anatomy is disabled for this story (<AnatomyCode>anatomy.disable</AnatomyCode>).
|
|
159
|
+
</AnatomyMessage>
|
|
160
|
+
</Unstyled>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (parts.length === 0) {
|
|
165
|
+
return (
|
|
166
|
+
<Unstyled>
|
|
167
|
+
<AnatomyMessage>
|
|
168
|
+
{resolved.type === 'meta'
|
|
169
|
+
? 'No parts found. A meta has no canvas to discover parts from — pass a story to `of`, or list `parts` explicitly.'
|
|
170
|
+
: 'No parts found. Auto-discovery reads the rendered story, so make sure it is on this page (e.g. with a `<Canvas of={…} />` block above), or list `parts` explicitly.'}
|
|
171
|
+
</AnatomyMessage>
|
|
172
|
+
</Unstyled>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Unstyled>
|
|
178
|
+
<AnatomyTable
|
|
179
|
+
parts={parts}
|
|
180
|
+
activeId={activeId}
|
|
181
|
+
accent={accent}
|
|
182
|
+
onItemEnter={emitHover}
|
|
183
|
+
onItemLeave={emitLeave}
|
|
184
|
+
/>
|
|
185
|
+
</Unstyled>
|
|
186
|
+
);
|
|
187
|
+
};
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared channel payload contract between the preview decorator, the manager
|
|
3
|
+
* panel, and the MDX doc block.
|
|
4
|
+
*
|
|
5
|
+
* Every payload carries the `storyId` it refers to. In story view this is
|
|
6
|
+
* redundant — only one story is mounted — but a docs page mounts *many*
|
|
7
|
+
* stories at once, each with its own controller, and each `<Anatomy>` block
|
|
8
|
+
* must talk to exactly one of them. Without addressing, hovering a part in
|
|
9
|
+
* one block highlights the matching part in every other story on the page.
|
|
10
|
+
*/
|
|
11
|
+
import type { AnatomyPartDefinition } from '@component-anatomy/core';
|
|
12
|
+
|
|
13
|
+
/** preview → consumers: the resolved part list for one story. */
|
|
14
|
+
export type PartsEvent = { storyId?: string; parts: AnatomyPartDefinition[] };
|
|
15
|
+
|
|
16
|
+
/** preview → consumers: a part became active in that story's canvas. */
|
|
17
|
+
export type PartEnterEvent = { storyId?: string; partId: string };
|
|
18
|
+
|
|
19
|
+
/** consumer → preview: highlight this part in that story's canvas. */
|
|
20
|
+
export type HoverItemEvent = { storyId?: string; partId: string };
|
|
21
|
+
|
|
22
|
+
/** Payload for the events that only need to name a story. */
|
|
23
|
+
export type StoryScopedEvent = { storyId?: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether an event addressed to `eventStoryId` concerns `storyId`.
|
|
27
|
+
*
|
|
28
|
+
* A missing id on *either* side matches everything. That keeps the protocol
|
|
29
|
+
* backward compatible: a manager panel from a newer build still understands
|
|
30
|
+
* an older preview bundle that emits unaddressed events, and vice versa.
|
|
31
|
+
*/
|
|
32
|
+
export const matchesStory = (
|
|
33
|
+
eventStoryId: string | undefined,
|
|
34
|
+
storyId: string | undefined
|
|
35
|
+
): boolean => !eventStoryId || !storyId || eventStoryId === storyId;
|
package/src/constants.ts
CHANGED
|
@@ -4,18 +4,24 @@ export const PANEL_ID = `${ADDON_ID}/panel`;
|
|
|
4
4
|
/** Story parameter key: `parameters.anatomy = { ... }` */
|
|
5
5
|
export const PARAM_KEY = 'anatomy';
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Channel events used to sync the manager panel — and the `<Anatomy>` MDX doc
|
|
9
|
+
* block, which runs in the preview iframe — with the story canvas.
|
|
10
|
+
*
|
|
11
|
+
* Every payload carries the `storyId` it concerns; see `channel.ts` for the
|
|
12
|
+
* payload types and the `matchesStory` filter each listener applies.
|
|
13
|
+
*/
|
|
8
14
|
export const EVENTS = {
|
|
9
|
-
/** preview →
|
|
15
|
+
/** preview → consumers: a part became active in the canvas (hover/programmatic). */
|
|
10
16
|
PART_ENTER: `${ADDON_ID}/part-enter`,
|
|
11
|
-
/** preview →
|
|
17
|
+
/** preview → consumers: no part is active anymore. */
|
|
12
18
|
PART_LEAVE: `${ADDON_ID}/part-leave`,
|
|
13
|
-
/** preview →
|
|
19
|
+
/** preview → consumers: resolved part list for a story. */
|
|
14
20
|
PARTS: `${ADDON_ID}/parts`,
|
|
15
|
-
/**
|
|
21
|
+
/** consumers → preview: the user hovers/focuses a panel entry. */
|
|
16
22
|
HOVER_ITEM: `${ADDON_ID}/hover-item`,
|
|
17
|
-
/**
|
|
23
|
+
/** consumers → preview: the user left a panel entry. */
|
|
18
24
|
LEAVE_ITEM: `${ADDON_ID}/leave-item`,
|
|
19
|
-
/**
|
|
25
|
+
/** consumers → preview: a panel/block mounted and wants the current part list. */
|
|
20
26
|
PARTS_REQUEST: `${ADDON_ID}/parts-request`,
|
|
21
27
|
} as const;
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export { ADDON_ID, PANEL_ID, PARAM_KEY, EVENTS } from './constants.js';
|
|
2
2
|
export type { AnatomyParameters } from './types.js';
|
|
3
|
+
|
|
4
|
+
// The `<Anatomy>` doc block lives in the `./blocks` subpath, not here: this
|
|
5
|
+
// entry is loaded at config time by `.storybook/main.ts` (and built to CJS),
|
|
6
|
+
// while the block needs React and `@storybook/addon-docs`, both optional
|
|
7
|
+
// peers that must not become load-bearing for `addons: ['...']` to work.
|