Skip to content

Commit a67f7fb

Browse files
committed
add composeStories api for svelte
1 parent ad8a3a9 commit a67f7fb

File tree

5 files changed

+505
-0
lines changed

5 files changed

+505
-0
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { userEvent, within } from '@storybook/testing-library';
2+
import type { Meta, StoryFn as CSF2Story, StoryObj } from '../..';
3+
4+
import Button from './Button.svelte';
5+
6+
const meta = {
7+
title: 'Example/Button',
8+
component: Button,
9+
argTypes: {
10+
size: { control: 'select', options: ['small', 'medium', 'large'] },
11+
backgroundColor: { control: 'color' },
12+
onClick: { action: 'clicked' },
13+
},
14+
args: { primary: false },
15+
excludeStories: /.*ImNotAStory$/,
16+
} as Meta<typeof Button>;
17+
18+
export default meta;
19+
type CSF3Story = StoryObj<typeof meta>;
20+
21+
// For testing purposes. Should be ignored in ComposeStories
22+
export const ImNotAStory = 123;
23+
24+
const Template: CSF2Story = (args) => ({
25+
components: { Button },
26+
setup() {
27+
return { args };
28+
},
29+
template: '<Button v-bind="args" />',
30+
});
31+
32+
export const CSF2Secondary = Template.bind({});
33+
CSF2Secondary.args = {
34+
label: 'label coming from story args!',
35+
primary: false,
36+
};
37+
38+
const getCaptionForLocale = (locale: string) => {
39+
switch (locale) {
40+
case 'es':
41+
return 'Hola!';
42+
case 'fr':
43+
return 'Bonjour!';
44+
case 'kr':
45+
return '안녕하세요!';
46+
case 'pt':
47+
return 'Olá!';
48+
default:
49+
return 'Hello!';
50+
}
51+
};
52+
53+
export const CSF2StoryWithLocale: CSF2Story = (args, { globals }) => ({
54+
components: { Button },
55+
setup() {
56+
console.log({ globals });
57+
const label = getCaptionForLocale(globals.locale);
58+
return { args: { ...args, label } };
59+
},
60+
template: `<div>
61+
<p>locale: ${globals.locale}</p>
62+
<Button v-bind="args" />
63+
</div>`,
64+
});
65+
CSF2StoryWithLocale.storyName = 'WithLocale';
66+
67+
export const CSF2StoryWithParamsAndDecorator = Template.bind({});
68+
CSF2StoryWithParamsAndDecorator.args = {
69+
label: 'foo',
70+
};
71+
CSF2StoryWithParamsAndDecorator.parameters = {
72+
layout: 'centered',
73+
};
74+
CSF2StoryWithParamsAndDecorator.decorators = [
75+
() => ({ template: '<div style="margin: 3em;"><story/></div>' }),
76+
];
77+
78+
export const CSF3Primary: CSF3Story = {
79+
args: {
80+
label: 'foo',
81+
size: 'large',
82+
primary: true,
83+
},
84+
};
85+
86+
export const CSF3Button: CSF3Story = {
87+
args: { label: 'foo' },
88+
};
89+
90+
export const CSF3ButtonWithRender: CSF3Story = {
91+
...CSF3Button,
92+
render: (args) => ({
93+
components: { Button },
94+
setup() {
95+
return { args };
96+
},
97+
template: `
98+
<div>
99+
<p data-testid="custom-render">I am a custom render function</p>
100+
<Button v-bind="args" />
101+
</div>
102+
`,
103+
}),
104+
};
105+
106+
export const CSF3InputFieldFilled: CSF3Story = {
107+
...CSF3Button,
108+
render: (args) => ({
109+
components: { Button },
110+
setup() {
111+
return { args };
112+
},
113+
template: '<input data-testid="input" />',
114+
}),
115+
play: async ({ canvasElement, step }) => {
116+
const canvas = within(canvasElement);
117+
await step('Step label', async () => {
118+
await userEvent.type(canvas.getByTestId('input'), 'Hello world!');
119+
});
120+
},
121+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script lang="ts">
2+
/**
3+
* Is this the principal call to action on the page?
4+
*/
5+
export let primary = false;
6+
7+
/**
8+
* What background color to use
9+
*/
10+
export let backgroundColor: string | undefined = undefined;
11+
/**
12+
* How large should the button be?
13+
*/
14+
export let size: 'small' | 'medium' | 'large' = 'medium';
15+
/**
16+
* Button contents
17+
*/
18+
export let label: string = '';
19+
20+
$: mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
21+
22+
$: style = backgroundColor ? `background-color: ${backgroundColor}` : '';
23+
</script>
24+
25+
<button
26+
type="button"
27+
class={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
28+
{style}
29+
on:click
30+
>
31+
{label}
32+
</button>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/// <reference types="@testing-library/jest-dom" />;
2+
import { it, expect, vi, describe } from 'vitest';
3+
import { render, screen } from '@testing-library/svelte';
4+
import '@testing-library/svelte/vitest';
5+
import { expectTypeOf } from 'expect-type';
6+
import type { Meta } from '../../';
7+
import * as stories from './Button.stories';
8+
// import type Button from './Button.svelte';
9+
import Button from './Button.svelte';
10+
import { composeStories, composeStory, setProjectAnnotations } from '../../portable-stories';
11+
import { SvelteComponent } from 'svelte';
12+
13+
// example with composeStories, returns an object with all stories composed with args/decorators
14+
const { CSF3Primary } = composeStories(stories);
15+
16+
// example with composeStory, returns a single story composed with args/decorators
17+
const Secondary = composeStory(stories.CSF2Secondary, stories.default);
18+
19+
it('renders primary button', () => {
20+
render(CSF3Primary);
21+
// const buttonElement = screen.getByText(/Hello world/i);
22+
// expect(buttonElement).toBeInTheDocument();
23+
});
24+
25+
// it('reuses args from composed story', () => {
26+
// render(Secondary());
27+
// const buttonElement = screen.getByRole('button');
28+
// expect(buttonElement.textContent).toEqual(Secondary.args.label);
29+
// });
30+
31+
// it('myClickEvent handler is called', async () => {
32+
// const myClickEventSpy = vi.fn();
33+
// render(Secondary({ onMyClickEvent: myClickEventSpy }));
34+
// const buttonElement = screen.getByRole('button');
35+
// buttonElement.click();
36+
// expect(myClickEventSpy).toHaveBeenCalled();
37+
// });
38+
39+
// it('reuses args from composeStories', () => {
40+
// const { getByText } = render(CSF3Primary());
41+
// const buttonElement = getByText(/foo/i);
42+
// expect(buttonElement).toBeInTheDocument();
43+
// });
44+
45+
// describe('projectAnnotations', () => {
46+
// it('renders with default projectAnnotations', () => {
47+
// const WithEnglishText = composeStory(stories.CSF2StoryWithLocale, stories.default);
48+
// const { getByText } = render(WithEnglishText());
49+
// const buttonElement = getByText('Hello!');
50+
// expect(buttonElement).toBeInTheDocument();
51+
// });
52+
53+
// it('renders with custom projectAnnotations via composeStory params', () => {
54+
// const WithPortugueseText = composeStory(stories.CSF2StoryWithLocale, stories.default, {
55+
// globalTypes: { locale: { defaultValue: 'pt' } } as any,
56+
// });
57+
// const { getByText } = render(WithPortugueseText());
58+
// const buttonElement = getByText('Olá!');
59+
// expect(buttonElement).toBeInTheDocument();
60+
// });
61+
62+
// it('renders with custom projectAnnotations via setProjectAnnotations', () => {
63+
// setProjectAnnotations([{ parameters: { injected: true } }]);
64+
// const Story = composeStory(stories.CSF2StoryWithLocale, stories.default);
65+
// expect(Story.parameters?.injected).toBe(true);
66+
// });
67+
// });
68+
69+
// describe('CSF3', () => {
70+
// it('renders with inferred globalRender', () => {
71+
// const Primary = composeStory(stories.CSF3Button, stories.default);
72+
73+
// render(Primary({ label: 'Hello world' }));
74+
// const buttonElement = screen.getByText(/Hello world/i);
75+
// expect(buttonElement).toBeInTheDocument();
76+
// });
77+
78+
// it('renders with custom render function', () => {
79+
// const Primary = composeStory(stories.CSF3ButtonWithRender, stories.default);
80+
81+
// render(Primary());
82+
// expect(screen.getByTestId('custom-render')).toBeInTheDocument();
83+
// });
84+
85+
// it('renders with play function', async () => {
86+
// const CSF3InputFieldFilled = composeStory(stories.CSF3InputFieldFilled, stories.default);
87+
88+
// const { container } = render(CSF3InputFieldFilled());
89+
90+
// await CSF3InputFieldFilled.play({ canvasElement: container as HTMLElement });
91+
92+
// const input = screen.getByTestId('input') as HTMLInputElement;
93+
// expect(input.value).toEqual('Hello world!');
94+
// });
95+
// });
96+
97+
// describe('ComposeStories types', () => {
98+
// it('Should support typescript operators', () => {
99+
// type ComposeStoriesParam = Parameters<typeof composeStories>[0];
100+
101+
// expectTypeOf({
102+
// ...stories,
103+
// default: stories.default as Meta<typeof Button>,
104+
// }).toMatchTypeOf<ComposeStoriesParam>();
105+
106+
// expectTypeOf({
107+
// ...stories,
108+
// default: stories.default satisfies Meta<typeof Button>,
109+
// }).toMatchTypeOf<ComposeStoriesParam>();
110+
// });
111+
// });
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import React from 'react';
2+
// import { addons } from '@storybook/preview-api';
3+
// import { render, screen } from '@testing-library/svelte';
4+
import { describe, it, expect } from 'vitest';
5+
6+
import { composeStories, composeStory } from '../../portable-stories';
7+
8+
import * as stories from './Button.stories';
9+
10+
const { CSF2StoryWithParamsAndDecorator } = composeStories(stories);
11+
12+
it('returns composed args including default values from argtypes', () => {
13+
expect({
14+
...stories.default.args,
15+
...CSF2StoryWithParamsAndDecorator.args,
16+
}).toEqual(expect.objectContaining(CSF2StoryWithParamsAndDecorator.args));
17+
});
18+
19+
it('returns composed parameters from story', () => {
20+
expect(CSF2StoryWithParamsAndDecorator.parameters).toEqual(
21+
expect.objectContaining({
22+
...stories.CSF2StoryWithParamsAndDecorator.parameters,
23+
})
24+
);
25+
});
26+
27+
describe('Id of the story', () => {
28+
it('is exposed correctly when composeStories is used', () => {
29+
expect(CSF2StoryWithParamsAndDecorator.id).toBe(
30+
'example-button--csf-2-story-with-params-and-decorator'
31+
);
32+
});
33+
it('is exposed correctly when composeStory is used and exportsName is passed', () => {
34+
const exportName = Object.entries(stories).filter(
35+
([_, story]) => story === stories.CSF3Primary
36+
)[0][0];
37+
const Primary = composeStory(stories.CSF3Primary, stories.default, {}, exportName);
38+
expect(Primary.id).toBe('example-button--csf-3-primary');
39+
});
40+
it("is not unique when composeStory is used and exportsName isn't passed", () => {
41+
const Primary = composeStory(stories.CSF3Primary, stories.default);
42+
expect(Primary.id).toContain('unknown');
43+
});
44+
});
45+
46+
// TODO: Bring this back
47+
// common in addons that need to communicate between manager and preview
48+
// it('should pass with decorators that need addons channel', () => {
49+
// const PrimaryWithChannels = composeStory(stories.CSF3Primary, stories.default, {
50+
// decorators: [
51+
// (StoryFn: any) => {
52+
// addons.getChannel();
53+
// return StoryFn();
54+
// },
55+
// ],
56+
// });
57+
// render(PrimaryWithChannels({ label: 'Hello world' }));
58+
// const buttonElement = screen.getByText(/Hello world/i);
59+
// expect(buttonElement).not.toBeNull();
60+
// });
61+
62+
describe('Unsupported formats', () => {
63+
it('should throw error if story is undefined', () => {
64+
const UnsupportedStory = () => <div>hello world</div>;
65+
UnsupportedStory.story = { parameters: {} };
66+
67+
const UnsupportedStoryModule: any = {
68+
default: {},
69+
UnsupportedStory: undefined,
70+
};
71+
72+
expect(() => {
73+
composeStories(UnsupportedStoryModule);
74+
}).toThrow();
75+
});
76+
});
77+
78+
describe('non-story exports', () => {
79+
it('should filter non-story exports with excludeStories', () => {
80+
const StoryModuleWithNonStoryExports = {
81+
default: {
82+
title: 'Some/Component',
83+
excludeStories: /.*Data/,
84+
},
85+
LegitimateStory: () => <div>hello world</div>,
86+
mockData: {},
87+
};
88+
89+
const result = composeStories(StoryModuleWithNonStoryExports);
90+
expect(Object.keys(result)).not.toContain('mockData');
91+
});
92+
93+
it('should filter non-story exports with includeStories', () => {
94+
const StoryModuleWithNonStoryExports = {
95+
default: {
96+
title: 'Some/Component',
97+
includeStories: /.*Story/,
98+
},
99+
LegitimateStory: () => <div>hello world</div>,
100+
mockData: {},
101+
};
102+
103+
const result = composeStories(StoryModuleWithNonStoryExports);
104+
expect(Object.keys(result)).not.toContain('mockData');
105+
});
106+
});
107+
108+
// // Batch snapshot testing
109+
// const testCases = Object.values(composeStories(stories)).map((Story) => [
110+
// // The ! is necessary in Typescript only, as the property is part of a partial type
111+
// Story.storyName!,
112+
// Story,
113+
// ]);
114+
// it.each(testCases)('Renders %s story', async (_storyName, Story) => {
115+
// if (typeof Story === 'string' || _storyName === 'CSF2StoryWithParamsAndDecorator') {
116+
// return;
117+
// }
118+
119+
// await new Promise((resolve) => setTimeout(resolve, 0));
120+
121+
// const tree = await render(Story());
122+
// expect(tree.baseElement).toMatchSnapshot();
123+
// });

0 commit comments

Comments
 (0)