@pyreon/storybook 0.0.1

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.
@@ -0,0 +1,413 @@
1
+ import { h, Fragment } from '@pyreon/core'
2
+ import type { ComponentFn, VNodeChild } from '@pyreon/core'
3
+ import { signal, effect } from '@pyreon/reactivity'
4
+ import { mount } from '@pyreon/runtime-dom'
5
+ import { renderToCanvas, defaultRender } from '../render'
6
+ import { render as previewRender } from '../preview'
7
+ import type {
8
+ Meta,
9
+ StoryObj,
10
+ DecoratorFn,
11
+ StoryFn,
12
+ StoryContext,
13
+ } from '../types'
14
+
15
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
16
+
17
+ function createCanvas(): HTMLElement {
18
+ const el = document.createElement('div')
19
+ document.body.appendChild(el)
20
+ return el
21
+ }
22
+
23
+ function makeRenderContext(overrides: {
24
+ storyFn?: () => VNodeChild
25
+ component?: ComponentFn<any>
26
+ args?: Record<string, unknown>
27
+ }) {
28
+ return {
29
+ storyFn: overrides.storyFn ?? (() => h('div', null, 'default')),
30
+ storyContext: {
31
+ component: overrides.component,
32
+ args: overrides.args ?? {},
33
+ },
34
+ showMain: () => {
35
+ /* noop */
36
+ },
37
+ showError: (_err: { title: string; description: string }) => {
38
+ /* noop */
39
+ },
40
+ forceRemount: false,
41
+ }
42
+ }
43
+
44
+ // ─── renderToCanvas ──────────────────────────────────────────────────────────
45
+
46
+ describe('renderToCanvas', () => {
47
+ it('mounts a simple VNode into the canvas', () => {
48
+ const canvas = createCanvas()
49
+ const ctx = makeRenderContext({
50
+ storyFn: () => h('button', null, 'Click me'),
51
+ })
52
+
53
+ renderToCanvas(ctx, canvas)
54
+
55
+ expect(canvas.innerHTML).toContain('Click me')
56
+ expect(canvas.querySelector('button')).toBeTruthy()
57
+ canvas.remove()
58
+ })
59
+
60
+ it('mounts a Pyreon component with props', () => {
61
+ function Button(props: { label: string; disabled?: boolean }) {
62
+ return h('button', { disabled: props.disabled ?? false }, props.label)
63
+ }
64
+
65
+ const canvas = createCanvas()
66
+ const ctx = makeRenderContext({
67
+ storyFn: () => h(Button, { label: 'Submit', disabled: true }),
68
+ })
69
+
70
+ renderToCanvas(ctx, canvas)
71
+
72
+ const btn = canvas.querySelector('button')!
73
+ expect(btn).toBeTruthy()
74
+ expect(btn.textContent).toBe('Submit')
75
+ expect(btn.getAttribute('disabled')).not.toBeNull()
76
+ canvas.remove()
77
+ })
78
+
79
+ it('cleans up previous mount on re-render', () => {
80
+ const canvas = createCanvas()
81
+
82
+ renderToCanvas(
83
+ makeRenderContext({ storyFn: () => h('div', null, 'First') }),
84
+ canvas,
85
+ )
86
+ expect(canvas.textContent).toBe('First')
87
+
88
+ renderToCanvas(
89
+ makeRenderContext({ storyFn: () => h('div', null, 'Second') }),
90
+ canvas,
91
+ )
92
+ expect(canvas.textContent).toBe('Second')
93
+ // Only one child — previous mount was cleaned up
94
+ expect(canvas.children.length).toBe(1)
95
+ canvas.remove()
96
+ })
97
+
98
+ it('disposes reactive effects on cleanup', () => {
99
+ const canvas = createCanvas()
100
+ let effectRunCount = 0
101
+
102
+ const count = signal(0)
103
+ function Counter() {
104
+ effect(() => {
105
+ count()
106
+ effectRunCount++
107
+ })
108
+ return h('span', null, () => `${count()}`)
109
+ }
110
+
111
+ renderToCanvas(
112
+ makeRenderContext({ storyFn: () => h(Counter, null) }),
113
+ canvas,
114
+ )
115
+
116
+ const initialCount = effectRunCount
117
+ count.set(1)
118
+ expect(effectRunCount).toBe(initialCount + 1)
119
+
120
+ // Re-render with a different story — should dispose previous effects
121
+ renderToCanvas(
122
+ makeRenderContext({ storyFn: () => h('div', null, 'New story') }),
123
+ canvas,
124
+ )
125
+
126
+ const countAfterCleanup = effectRunCount
127
+ count.set(2)
128
+ count.set(3)
129
+ // Effect should NOT have run again — it was disposed
130
+ expect(effectRunCount).toBe(countAfterCleanup)
131
+ canvas.remove()
132
+ })
133
+
134
+ it('shows error when storyFn throws an Error', () => {
135
+ const canvas = createCanvas()
136
+ let errorShown: { title: string; description: string } | null = null
137
+
138
+ const ctx = {
139
+ storyFn: () => {
140
+ throw new Error('Boom')
141
+ },
142
+ storyContext: { args: {} },
143
+ showMain: () => {
144
+ /* noop */
145
+ },
146
+ showError: (err: { title: string; description: string }) => {
147
+ errorShown = err
148
+ },
149
+ forceRemount: false,
150
+ }
151
+
152
+ renderToCanvas(ctx, canvas)
153
+
154
+ expect(errorShown).not.toBeNull()
155
+ expect(errorShown!.description).toBe('Boom')
156
+ canvas.remove()
157
+ })
158
+
159
+ it('shows error when storyFn throws a non-Error value', () => {
160
+ const canvas = createCanvas()
161
+ let errorShown: { title: string; description: string } | null = null
162
+
163
+ const ctx = {
164
+ storyFn: () => {
165
+ throw 'string error'
166
+ },
167
+ storyContext: { args: {} },
168
+ showMain: () => {
169
+ /* noop */
170
+ },
171
+ showError: (err: { title: string; description: string }) => {
172
+ errorShown = err
173
+ },
174
+ forceRemount: false,
175
+ }
176
+
177
+ renderToCanvas(ctx, canvas)
178
+
179
+ expect(errorShown).not.toBeNull()
180
+ expect(errorShown!.description).toBe('string error')
181
+ canvas.remove()
182
+ })
183
+
184
+ it('renders reactive components that update the DOM', () => {
185
+ const canvas = createCanvas()
186
+ const count = signal(0)
187
+
188
+ function Counter() {
189
+ return h('span', { 'data-testid': 'count' }, () => `Count: ${count()}`)
190
+ }
191
+
192
+ renderToCanvas(
193
+ makeRenderContext({ storyFn: () => h(Counter, null) }),
194
+ canvas,
195
+ )
196
+
197
+ expect(canvas.textContent).toBe('Count: 0')
198
+
199
+ count.set(5)
200
+ expect(canvas.textContent).toBe('Count: 5')
201
+ canvas.remove()
202
+ })
203
+ })
204
+
205
+ // ─── defaultRender ───────────────────────────────────────────────────────────
206
+
207
+ describe('defaultRender', () => {
208
+ it('creates a VNode from component + args', () => {
209
+ function Greeting(props: { name: string }) {
210
+ return h('p', null, `Hello, ${props.name}!`)
211
+ }
212
+
213
+ const canvas = createCanvas()
214
+ const vnode = defaultRender(Greeting, { name: 'World' })
215
+ const unmount = mount(vnode, canvas)
216
+
217
+ expect(canvas.textContent).toBe('Hello, World!')
218
+ unmount()
219
+ canvas.remove()
220
+ })
221
+ })
222
+
223
+ // ─── Type-level tests (Meta / StoryObj) ──────────────────────────────────────
224
+
225
+ describe('Meta and StoryObj types', () => {
226
+ it('Meta accepts a component and typed args', () => {
227
+ function Button(props: {
228
+ label: string
229
+ variant?: 'primary' | 'secondary'
230
+ }) {
231
+ return h('button', { class: props.variant }, props.label)
232
+ }
233
+
234
+ const meta = {
235
+ component: Button,
236
+ title: 'Button',
237
+ args: { label: 'Click', variant: 'primary' as const },
238
+ tags: ['autodocs'],
239
+ } satisfies Meta<typeof Button>
240
+
241
+ expect(meta.component).toBe(Button)
242
+ expect(meta.args!.label).toBe('Click')
243
+ })
244
+
245
+ it('StoryObj inherits args from Meta', () => {
246
+ function Input(props: { placeholder: string; disabled?: boolean }) {
247
+ return h('input', {
248
+ placeholder: props.placeholder,
249
+ disabled: props.disabled,
250
+ })
251
+ }
252
+
253
+ const _meta = {
254
+ component: Input,
255
+ args: { placeholder: 'Type here' },
256
+ } satisfies Meta<typeof Input>
257
+
258
+ type Story = StoryObj<typeof _meta>
259
+
260
+ const primary: Story = {
261
+ args: { disabled: true },
262
+ }
263
+
264
+ expect(primary.args!.disabled).toBe(true)
265
+ })
266
+
267
+ it('StoryObj supports custom render function', () => {
268
+ function Card(props: { title: string }) {
269
+ return h('div', { class: 'card' }, h('h2', null, props.title))
270
+ }
271
+
272
+ const _meta = {
273
+ component: Card,
274
+ args: { title: 'Default' },
275
+ } satisfies Meta<typeof Card>
276
+
277
+ type Story = StoryObj<typeof _meta>
278
+
279
+ const withWrapper: Story = {
280
+ render: (args) => h('div', { class: 'wrapper' }, h(Card, args)),
281
+ }
282
+
283
+ const canvas = createCanvas()
284
+ const vnode = withWrapper.render!({ title: 'Custom' }, {} as any)
285
+ const unmount = mount(vnode, canvas)
286
+
287
+ expect(canvas.querySelector('.wrapper')).toBeTruthy()
288
+ expect(canvas.querySelector('.card')).toBeTruthy()
289
+ expect(canvas.textContent).toBe('Custom')
290
+ unmount()
291
+ canvas.remove()
292
+ })
293
+ })
294
+
295
+ // ─── Decorators ──────────────────────────────────────────────────────────────
296
+
297
+ describe('Decorators', () => {
298
+ it('decorator wraps a story', () => {
299
+ function Button(props: { label: string }) {
300
+ return h('button', null, props.label)
301
+ }
302
+
303
+ const withPadding: DecoratorFn<{ label: string }> = (storyFn, context) => {
304
+ return h(
305
+ 'div',
306
+ { style: 'padding: 1rem' },
307
+ storyFn(context.args, context),
308
+ )
309
+ }
310
+
311
+ const canvas = createCanvas()
312
+ const storyResult = withPadding((args) => h(Button, args), {
313
+ args: { label: 'Wrapped' },
314
+ argTypes: {},
315
+ globals: {},
316
+ id: '1',
317
+ kind: 'Button',
318
+ name: 'Primary',
319
+ viewMode: 'story',
320
+ })
321
+
322
+ const unmount = mount(storyResult, canvas)
323
+ expect(canvas.querySelector('div[style]')).toBeTruthy()
324
+ expect(canvas.querySelector('button')!.textContent).toBe('Wrapped')
325
+ unmount()
326
+ canvas.remove()
327
+ })
328
+
329
+ it('multiple decorators compose correctly', () => {
330
+ function Text(props: { content: string }) {
331
+ return h('span', null, props.content)
332
+ }
333
+
334
+ const withBorder: DecoratorFn<{ content: string }> = (storyFn, ctx) =>
335
+ h('div', { class: 'border' }, storyFn(ctx.args, ctx))
336
+
337
+ const withTheme: DecoratorFn<{ content: string }> = (storyFn, ctx) =>
338
+ h('div', { class: 'theme-dark' }, storyFn(ctx.args, ctx))
339
+
340
+ const context: StoryContext<{ content: string }> = {
341
+ args: { content: 'Hello' },
342
+ argTypes: {},
343
+ globals: {},
344
+ id: '1',
345
+ kind: 'Text',
346
+ name: 'Default',
347
+ viewMode: 'story',
348
+ }
349
+
350
+ // Compose: withTheme(withBorder(story))
351
+ const story: StoryFn<{ content: string }> = (args) => h(Text, args)
352
+ const decorated = withTheme((_args, ctx) => withBorder(story, ctx), context)
353
+
354
+ const canvas = createCanvas()
355
+ const unmount = mount(decorated, canvas)
356
+ expect(canvas.querySelector('.theme-dark')).toBeTruthy()
357
+ expect(canvas.querySelector('.border')).toBeTruthy()
358
+ expect(canvas.querySelector('span')!.textContent).toBe('Hello')
359
+ unmount()
360
+ canvas.remove()
361
+ })
362
+ })
363
+
364
+ // ─── Fragment and multiple children ──────────────────────────────────────────
365
+
366
+ describe('Fragment stories', () => {
367
+ it('renders a story returning a Fragment', () => {
368
+ const canvas = createCanvas()
369
+ renderToCanvas(
370
+ makeRenderContext({
371
+ storyFn: () =>
372
+ h(Fragment, null, h('p', null, 'Line 1'), h('p', null, 'Line 2')),
373
+ }),
374
+ canvas,
375
+ )
376
+
377
+ const paragraphs = canvas.querySelectorAll('p')
378
+ expect(paragraphs.length).toBe(2)
379
+ expect(paragraphs[0]!.textContent).toBe('Line 1')
380
+ expect(paragraphs[1]!.textContent).toBe('Line 2')
381
+ canvas.remove()
382
+ })
383
+ })
384
+
385
+ // ─── Preview render function ─────────────────────────────────────────────────
386
+
387
+ describe('preview render', () => {
388
+ it('renders a component with args', () => {
389
+ function Badge(props: { text: string }) {
390
+ return h('span', { class: 'badge' }, props.text)
391
+ }
392
+
393
+ const canvas = createCanvas()
394
+ const vnode = previewRender({ text: 'New' }, { component: Badge })
395
+ const unmount = mount(vnode, canvas)
396
+
397
+ expect(canvas.querySelector('.badge')!.textContent).toBe('New')
398
+ unmount()
399
+ canvas.remove()
400
+ })
401
+
402
+ it('throws when no component is provided', () => {
403
+ expect(() => previewRender({ foo: 'bar' }, {})).toThrow(
404
+ '[@pyreon/storybook] No component provided',
405
+ )
406
+ })
407
+
408
+ it('throws when component is undefined', () => {
409
+ expect(() =>
410
+ previewRender({ foo: 'bar' }, { component: undefined }),
411
+ ).toThrow('[@pyreon/storybook] No component provided')
412
+ })
413
+ })
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
+
3
+ // ─── Storybook Renderer Interface ────────────────────────────────────────────
4
+
5
+ /**
6
+ * The Pyreon renderer descriptor used by Storybook internally.
7
+ * This tells Storybook what our "component" and "storyResult" types are.
8
+ */
9
+ export interface PyreonRenderer {
10
+ component: ComponentFn<any>
11
+ storyResult: VNodeChild
12
+ canvasElement: HTMLElement
13
+ }
14
+
15
+ // ─── Args & ArgTypes ─────────────────────────────────────────────────────────
16
+
17
+ /** Extract props type from a Pyreon component function. */
18
+ export type InferProps<T> = T extends ComponentFn<infer P> ? P : Props
19
+
20
+ // ─── Decorator ───────────────────────────────────────────────────────────────
21
+
22
+ export interface StoryContext<TArgs = Props> {
23
+ args: TArgs
24
+ argTypes: Record<string, unknown>
25
+ globals: Record<string, unknown>
26
+ id: string
27
+ kind: string
28
+ name: string
29
+ viewMode: 'story' | 'docs'
30
+ }
31
+
32
+ export type StoryFn<TArgs = Props> = (
33
+ args: TArgs,
34
+ context: StoryContext<TArgs>,
35
+ ) => VNodeChild
36
+
37
+ export type DecoratorFn<TArgs = Props> = (
38
+ storyFn: StoryFn<TArgs>,
39
+ context: StoryContext<TArgs>,
40
+ ) => VNodeChild
41
+
42
+ // ─── Meta ────────────────────────────────────────────────────────────────────
43
+
44
+ export interface Meta<TComponent extends ComponentFn<any> = ComponentFn> {
45
+ /** The component to document. */
46
+ component?: TComponent
47
+ /** Display title in the sidebar. */
48
+ title?: string
49
+ /** Decorators applied to every story in this file. */
50
+ decorators?: DecoratorFn<InferProps<TComponent>>[]
51
+ /** Default args for all stories. */
52
+ args?: Partial<InferProps<TComponent>>
53
+ /** Arg type definitions for Controls panel. */
54
+ argTypes?: Record<string, unknown>
55
+ /** Story parameters (backgrounds, viewport, etc.). */
56
+ parameters?: Record<string, unknown>
57
+ /** Tags for filtering (e.g. "autodocs"). */
58
+ tags?: string[]
59
+ /**
60
+ * Default render function. If omitted, the component is called
61
+ * with args as props: `h(component, args)`.
62
+ */
63
+ render?: (
64
+ args: InferProps<TComponent>,
65
+ context: StoryContext<InferProps<TComponent>>,
66
+ ) => VNodeChild
67
+ /** Exclude arg names from Controls. */
68
+ excludeStories?: string | string[] | RegExp
69
+ /** Include only these story names. */
70
+ includeStories?: string | string[] | RegExp
71
+ }
72
+
73
+ // ─── StoryObj ────────────────────────────────────────────────────────────────
74
+
75
+ export interface StoryObj<TMeta extends Meta<any> = Meta> {
76
+ /** Args for this specific story (merged with meta.args). */
77
+ args?: Partial<MetaArgs<TMeta>>
78
+ /** Arg type overrides. */
79
+ argTypes?: Record<string, unknown>
80
+ /** Decorators for this story only. */
81
+ decorators?: DecoratorFn<MetaArgs<TMeta>>[]
82
+ /** Parameters for this story. */
83
+ parameters?: Record<string, unknown>
84
+ /** Tags for this story. */
85
+ tags?: string[]
86
+ /** Override the render function for this story. */
87
+ render?: (
88
+ args: MetaArgs<TMeta>,
89
+ context: StoryContext<MetaArgs<TMeta>>,
90
+ ) => VNodeChild
91
+ /** Story name override. */
92
+ name?: string
93
+ /** Play function for interaction tests. */
94
+ play?: (context: {
95
+ canvasElement: HTMLElement
96
+ args: MetaArgs<TMeta>
97
+ step: (name: string, fn: () => Promise<void>) => Promise<void>
98
+ }) => Promise<void> | void
99
+ }
100
+
101
+ /** Extract the args type from a Meta definition. */
102
+ type MetaArgs<TMeta> = TMeta extends Meta<infer C> ? InferProps<C> : Props