Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion code/core/src/csf/csf-factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ function defineStory<

const play =
mountDestructured(this.play) || mountDestructured(testFunction)
? async ({ context }: StoryContext<TRenderer>) => {
? // mount needs to be explicitly destructured
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async ({ mount, context }: StoryContext<TRenderer>) => {
await this.play?.(context);
await testFunction(context);
}
Expand Down
10 changes: 5 additions & 5 deletions code/core/src/preview-api/modules/store/csf/processCSFFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,16 @@ export function processCSFFile<TRenderer extends Renderer>(
): CSFFile<TRenderer> {
const { default: defaultExport, __namedExportsOrder, ...namedExports } = moduleExports;

const firstStory = Object.values(namedExports)[0];
if (isStory<TRenderer>(firstStory)) {
const factoryStory = Object.values(namedExports).find((it) => isStory<TRenderer>(it));
if (factoryStory) {
const meta: NormalizedComponentAnnotations<TRenderer> =
normalizeComponentAnnotations<TRenderer>(firstStory.meta.input, title, importPath);
normalizeComponentAnnotations<TRenderer>(factoryStory.meta.input, title, importPath);
checkDisallowedParameters(meta.parameters);

const csfFile: CSFFile<TRenderer> = { meta, stories: {}, moduleExports };

Object.keys(namedExports).forEach((key) => {
if (isExportStory(key, meta)) {
if (isExportStory(key, meta) && isStory<TRenderer>(namedExports[key])) {
const story: Story<TRenderer> = namedExports[key];

const storyMeta = normalizeStory(key, story.input as any, meta);
Expand All @@ -82,7 +82,7 @@ export function processCSFFile<TRenderer extends Renderer>(
}
});

csfFile.projectAnnotations = firstStory.meta.preview.composed;
csfFile.projectAnnotations = factoryStory.meta.preview.composed;

return csfFile;
}
Expand Down
19 changes: 18 additions & 1 deletion code/renderers/react/src/csf-factories.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('Args can be provided in multiple ways', () => {
args: { label: 'good' },
});
// @ts-expect-error disabled not provided ❌
const Basic = meta.story({});
const Basic = meta.story();
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable Basic.

Copilot uses AI. Check for mistakes.
}
{
const meta = preview.meta({ component: Button });
Expand Down Expand Up @@ -384,3 +384,20 @@ describe('Composed getters', () => {
expect(renderSpy).toHaveBeenCalled();
});
});

it('meta.input also contains play', () => {
const meta = preview.meta({
/** Title, component, etc... */
play: async ({ canvas }) => {
/** Do some common interactions */
},
});

const ExtendedInteractionsStory = meta.story({
play: async ({ canvas, ...rest }) => {
await meta.input.play?.({ canvas, ...rest });

/** Do some extra interactions */
},
});
Comment on lines +396 to +402
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable ExtendedInteractionsStory.

Suggested change
const ExtendedInteractionsStory = meta.story({
play: async ({ canvas, ...rest }) => {
await meta.input.play?.({ canvas, ...rest });
/** Do some extra interactions */
},
});

Copilot uses AI. Check for mistakes.
});
28 changes: 25 additions & 3 deletions code/renderers/react/src/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function __definePreview<Addons extends PreviewAddon<never>[]>(
preview.meta = (_input) => {
const meta = defineMeta(_input);
const defineStory = meta.story.bind(meta);
// @ts-expect-error internal code that is hard to type
meta.story = (__input: any) => {
const story = defineStory(__input);
// TODO: [test-syntax] Are we sure we want this? the Component construct was for
Expand Down Expand Up @@ -76,7 +77,19 @@ export interface ReactPreview<T extends AddonTypes> extends Preview<ReactTypes &
TArgs & Simplify<RemoveIndexSignature<DecoratorsArgs<ReactTypes & T, Decorators>>>
>;
},
{ args: Partial<TArgs> extends TMetaArgs ? {} : TMetaArgs }
Omit<
ComponentAnnotations<
ReactTypes &
T & {
args: Simplify<
TArgs & Simplify<RemoveIndexSignature<DecoratorsArgs<ReactTypes & T, Decorators>>>
>;
}
>,
'args'
> & {
args: Partial<TArgs> extends TMetaArgs ? {} : TMetaArgs;
}
>;
}

Expand All @@ -95,7 +108,7 @@ export interface ReactMeta<T extends ReactTypes, MetaInput extends ComponentAnno
render: () => ReactTypes['storyResult'];
}),
>(
story?: TInput
story: TInput
): ReactStory<T, TInput extends () => ReactTypes['storyResult'] ? { render: TInput } : TInput>;

story<
Expand All @@ -108,9 +121,18 @@ export interface ReactMeta<T extends ReactTypes, MetaInput extends ComponentAnno
>
>,
>(
story?: TInput
story: TInput
/** @ts-expect-error hard */
): ReactStory<T, TInput>;

story(
..._args: Partial<T['args']> extends SetOptional<
T['args'],
keyof T['args'] & keyof MetaInput['args']
>
? []
: [never]
): ReactStory<T, {}>;
}

export interface ReactStory<T extends ReactTypes, TInput extends StoryAnnotations<T, T['args']>>
Expand Down
184 changes: 184 additions & 0 deletions code/renderers/vue3/src/csf-factories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// this file tests Typescript types that's why there are no assertions
import { describe, expect, expectTypeOf, it, test } from 'vitest';

import type { Canvas, ComponentAnnotations, StoryAnnotations } from 'storybook/internal/types';

import { h } from 'vue';

import BaseLayout from './__tests__/BaseLayout.vue';
import Button from './__tests__/Button.vue';
import Decorator2TsVue from './__tests__/Decorator2.vue';
import DecoratorTsVue from './__tests__/Decorator.vue';
import { __definePreview } from './preview';
import type { ComponentPropsAndSlots, Decorator, Meta, StoryObj } from './public-types';

type ButtonProps = ComponentPropsAndSlots<typeof Button>;

const preview = __definePreview({
addons: [],
});

test('csf factories', () => {
const config = __definePreview({
addons: [
{
decorators: [],
},
],
});

const meta = config.meta({ component: Button, args: { disabled: false } });

const MyStory = meta.story({
args: {
label: 'Hello world',
},
});

expect(MyStory.input.args?.label).toBe('Hello world');
});

describe('Meta', () => {
it('Generic parameter of Meta can be a component', () => {
const meta = preview.meta({
component: Button,
args: { label: 'good', disabled: false },
});
});

it('Events are inferred from component', () => {
const meta = preview.meta({
component: Button,
args: {
label: 'good',
disabled: false,
onMyChangeEvent: (value) => {
expectTypeOf(value).toMatchTypeOf<number>();
},
},
render: (args) => {
return h(Button, {
...args,
onMyChangeEvent: (value) => {
expectTypeOf(value).toMatchTypeOf<number>();
},
});
},
});
});
});

describe('StoryObj', () => {
it('✅ Required args may be provided partial in meta and the story', () => {
const meta = preview.meta({
component: Button,
args: { label: 'good' },
});

const Story = meta.story({
args: {
disabled: true,
},
});
});

it('❌ The combined shape of meta args and story args must match the required args.', () => {
{
const meta = preview.meta({
component: Button,
args: { label: 'good' },
});
// @ts-expect-error disabled not provided ❌
const Basic = meta.story();
}
{
const meta = preview.meta({ component: Button });
// @ts-expect-error disabled not provided ❌
const Basic = meta.story({
args: { label: 'good' },
});
}
});
});

type ThemeData = 'light' | 'dark';

describe('Story args can be inferred', () => {
it('Correct args are inferred when type is widened for render function', () => {
const meta = preview.meta({
render: (args: ButtonProps & { theme: ThemeData }) => {
return h('div', [h('div', `Use the theme ${args.theme}`), h(Button, args)]);
},
args: { disabled: false },
});

const Basic = meta.story({ args: { theme: 'light', label: 'good' } });
});

const withDecorator: Decorator<{ decoratorArg: string }> = (
storyFn,
{ args: { decoratorArg } }
) => h(DecoratorTsVue, { decoratorArg }, h(storyFn()));

it('Correct args are inferred when type is widened for decorators', () => {
type Props = ButtonProps & { decoratorArg: string };

const meta = preview.meta({
component: Button,
args: { disabled: false },
decorators: [withDecorator],
});

const Basic = meta.story({ args: { decoratorArg: 'title', label: 'good' } });
});

it('Correct args are inferred when type is widened for multiple decorators', () => {
type Props = ButtonProps & {
decoratorArg: string;
decoratorArg2: string;
};

const secondDecorator: Decorator<{ decoratorArg2: string }> = (
storyFn,
{ args: { decoratorArg2 } }
) => h(Decorator2TsVue, { decoratorArg2 }, h(storyFn()));

const meta = preview.meta({
component: Button,
args: { disabled: false },
decorators: [withDecorator, secondDecorator],
});

const Basic = meta.story({
args: { decoratorArg: '', decoratorArg2: '', label: 'good' },
});
});
});

it('Infer type of slots', () => {
const meta = preview.meta({
component: BaseLayout,
});

const Basic = meta.story({
args: {
otherProp: true,
header: ({ title }) =>
h({
components: { Button },
template: `<Button :primary='true' label='${title}'></Button>`,
}),
default: 'default slot',
footer: h(Button, { disabled: true, label: 'footer' }),
},
});
});

it('mount accepts a Component', () => {
const Basic: StoryObj<typeof Button> = {
async play({ mount }) {
const canvas = await mount(Button, { props: { label: 'label', disabled: true } });
expectTypeOf(canvas).toMatchTypeOf<Canvas>();
},
};
});
2 changes: 2 additions & 0 deletions code/renderers/vue3/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ import './globals';
export { setup } from './render';
export * from './public-types';
export * from './portable-stories';

export * from './preview';
Loading