diff --git a/code/e2e-tests/framework-vue3.spec.ts b/code/e2e-tests/framework-vue3.spec.ts new file mode 100644 index 000000000000..fbb76c026ccc --- /dev/null +++ b/code/e2e-tests/framework-vue3.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import process from 'process'; + +import { SbPage } from './util'; + +const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:6006'; +const templateName = process.env.STORYBOOK_TEMPLATE_NAME; + +test.describe('Vue 3', () => { + test.beforeEach(async ({ page }) => { + await page.goto(storybookUrl); + await new SbPage(page, expect).waitUntilLoaded(); + }); + + test.skip(!templateName?.includes('vue3'), 'Only run these tests on Vue 3'); + + test('updateArgs works in decorators', async ({ page }) => { + const sbPage = new SbPage(page, expect); + + await sbPage.navigateToStory( + 'stories/renderers/vue3_vue3-vite-default-ts/decorators', + 'update-args' + ); + const previewRoot = sbPage.previewRoot(); + const button = previewRoot.getByRole('button', { name: 'Add 1' }); + + await expect(previewRoot).toContainText('0'); + await button.click(); + await expect(previewRoot).toContainText('1'); + await button.click(); + await expect(previewRoot).toContainText('2'); + }); + + test('Decorators can consume reactive globals', async ({ page }) => { + const sbPage = new SbPage(page, expect); + + await sbPage.navigateToStory( + 'stories/renderers/vue3_vue3-vite-default-ts/decorators', + 'reactive-global-decorator' + ); + + // Check the original language + await expect(sbPage.previewRoot()).toContainText('Hello'); + + // Select spanish in the locale toolbar and check that the text changes + await sbPage.selectToolbar('[aria-label^="Internationalization locale"]', 'text=/Español/'); + await expect(sbPage.previewRoot()).toContainText('Hola'); + }); +}); diff --git a/code/renderers/vue3/src/render.test.ts b/code/renderers/vue3/src/render.test.ts index 98e48b86fd57..f4fb1c38a082 100644 --- a/code/renderers/vue3/src/render.test.ts +++ b/code/renderers/vue3/src/render.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; +import type { Args, Globals } from 'storybook/internal/types'; + import { expectTypeOf } from 'expect-type'; -import { reactive } from 'vue'; +import { computed, reactive } from 'vue'; import { updateArgs } from './render'; @@ -23,7 +25,7 @@ describe('Render Story', () => { expectTypeOf(reactiveArgs).toEqualTypeOf<{ objectArg: { argFoo: string; argBar: string } }>(); const newArgs = { argFoo: 'foo2', argBar: 'bar2' }; - updateArgs(reactiveArgs, newArgs); + updateArgs(reactiveArgs, newArgs); expectTypeOf(reactiveArgs).toEqualTypeOf<{ objectArg: { argFoo: string; argBar: string } }>(); expect(reactiveArgs).toEqual({ argFoo: 'foo2', @@ -37,7 +39,7 @@ describe('Render Story', () => { expectTypeOf(reactiveArgs).toEqualTypeOf<{ objectArg: { argFoo: string } }>(); const newArgs = { argFoo: 'foo2', argBar: 'bar2' }; - updateArgs(reactiveArgs, newArgs); + updateArgs(reactiveArgs, newArgs); expect(reactiveArgs).toEqual({ argFoo: 'foo2', argBar: 'bar2' }); }); @@ -53,7 +55,7 @@ describe('Render Story', () => { }>(); const newArgs = { argFoo: 'foo2', argBar: 'bar2' }; - updateArgs(reactiveArgs, newArgs); + updateArgs(reactiveArgs, newArgs); expect(reactiveArgs).toEqual({ argFoo: 'foo2', @@ -88,4 +90,23 @@ describe('Render Story', () => { expect(reactiveArgs).toEqual({ objectArg: { argFoo: 'bar' } }); }); + + it('update reactive Globals', async () => { + const reactiveGlobals = reactive({ theme: 'light', locale: 'en' }); + + let observedTheme: string | undefined; + const watcher = computed(() => { + observedTheme = reactiveGlobals.theme as string; + return reactiveGlobals.theme; + }); + + expect(watcher.value).toBe('light'); + expect(observedTheme).toBe('light'); + + updateArgs(reactiveGlobals, { theme: 'dark', locale: 'en' }); + + expect(watcher.value).toBe('dark'); + expect(observedTheme).toBe('dark'); + expect(reactiveGlobals).toEqual({ theme: 'dark', locale: 'en' }); + }); }); diff --git a/code/renderers/vue3/src/render.ts b/code/renderers/vue3/src/render.ts index 1bc85770371e..b671e0a82861 100644 --- a/code/renderers/vue3/src/render.ts +++ b/code/renderers/vue3/src/render.ts @@ -1,5 +1,11 @@ /* eslint-disable local-rules/no-uncategorized-errors */ -import type { Args, ArgsStoryFn, RenderContext, StoryContext } from 'storybook/internal/types'; +import type { Globals } from 'storybook/internal/types'; +import { + type Args, + type ArgsStoryFn, + type RenderContext, + type StoryContext, +} from 'storybook/internal/types'; import type { PreviewWeb } from 'storybook/preview-api'; import type { App } from 'vue'; @@ -39,6 +45,7 @@ const map = new Map< { vueApp: ReturnType; reactiveArgs: Args; + reactiveGlobals: Globals; } >(); @@ -56,7 +63,8 @@ export async function renderToCanvas( const element = storyFn(); // call the story function to get the root element with all the decorators const args = getArgs(element, storyContext); // get args in case they are altered by decorators otherwise use the args from the context - updateArgs(existingApp.reactiveArgs, args); + updateArgs(existingApp.reactiveArgs, args); + updateArgs(existingApp.reactiveGlobals, storyContext.globals); return () => { teardown(existingApp.vueApp, canvasElement); }; @@ -66,20 +74,19 @@ export async function renderToCanvas( teardown(existingApp.vueApp, canvasElement); } - // create vue app for the story - // create vue app for the story const vueApp = createApp({ setup() { storyContext.args = reactive(storyContext.args); + storyContext.globals = reactive(storyContext.globals); const rootElement = storyFn(); // call the story function to get the root element with all the decorators const args = getArgs(rootElement, storyContext); // get args in case they are altered by decorators otherwise use the args from the context const appState = { vueApp, reactiveArgs: reactive(args), + reactiveGlobals: storyContext.globals, }; map.set(canvasElement, appState); - return () => { // not passing args here as props // treat the rootElement as a component without props @@ -141,7 +148,11 @@ function getArgs(element: StoryFnVueReturnType, storyContext: StoryContext(reactiveArgs: T, nextArgs: T) { if (Object.keys(nextArgs).length === 0) { return; } diff --git a/code/renderers/vue3/template/stories_vue3-vite-default-ts/decorators.stories.ts b/code/renderers/vue3/template/stories_vue3-vite-default-ts/decorators.stories.ts index 347ea2b6698e..a5fc3bec526b 100644 --- a/code/renderers/vue3/template/stories_vue3-vite-default-ts/decorators.stories.ts +++ b/code/renderers/vue3/template/stories_vue3-vite-default-ts/decorators.stories.ts @@ -3,7 +3,9 @@ import type { DecoratorFunction } from 'storybook/internal/types'; import { global as globalThis } from '@storybook/global'; import type { Meta, StoryObj, VueRenderer } from '@storybook/vue3'; +import { useArgs } from 'storybook/preview-api'; import { h } from 'vue'; +import { computed } from 'vue'; const { Button, Pre } = (globalThis as any).__TEMPLATE_COMPONENTS__; @@ -47,6 +49,62 @@ const DynamicWrapperWrapper: DecoratorFunction = (storyFn, { args } computed: { level: () => `${args.level}px` }, }); +const getCaptionForLocale = (locale: string) => { + switch (locale) { + case 'es': + return 'Hola!'; + case 'kr': + return '안녕하세요!'; + case 'zh': + return '你好!'; + case 'en': + return 'Hello!'; + default: + return undefined; + } +}; + +const updateArgsDecorator: DecoratorFunction = (story, { args }) => { + const [, updateArgs] = useArgs(); + return { + components: { story }, + setup() { + return { + args, + updateArgs, + }; + }, + template: ` +
+ +
+ +
+ `, + }; +}; + +const localeDecorator: DecoratorFunction = (story, { globals }) => { + return { + components: { story }, + setup() { + const ctxGreeting = computed(() => getCaptionForLocale(globals?.locale) || 'Hello!'); + + return { + ctxGreeting, + globals, + }; + }, + template: ` +
+

Greeting: {{ctxGreeting}}

+

Locale: {{globals?.locale}}

+ +
+ `, + }; +}; + export const ComponentTemplate: Story = { args: { label: 'With component' }, decorators: [ComponentTemplateWrapper], @@ -84,3 +142,13 @@ export const MultipleWrappers = { DynamicWrapperWrapper, ], }; + +export const UpdateArgs = { + args: { label: '0' }, + decorators: [updateArgsDecorator], +}; + +export const ReactiveGlobalDecorator = { + args: { label: 'With reactive global decorator' }, + decorators: [localeDecorator], +}; diff --git a/docs/_snippets/decorator-with-reactive-globals.md b/docs/_snippets/decorator-with-reactive-globals.md new file mode 100644 index 000000000000..12190d20695c --- /dev/null +++ b/docs/_snippets/decorator-with-reactive-globals.md @@ -0,0 +1,52 @@ +```js filename=".storybook/preview.js" renderer="vue" language="js" +import { computed } from 'vue'; + +export default { + decorators: [ + (story, { globals }) => { + return { + components: { story }, + setup() { + const greeting = computed(() => globals?.locale === 'en' ? 'Hello!' : '¡Hola!'); + + return { greeting, globals }; + }, + template: ` +
+

Greeting: {{greeting}}

+ +
+ `, + }; + }, + ], +}; +``` + +```ts filename=".storybook/preview.ts" renderer="vue" language="ts" +import { computed } from 'vue'; +import type { Preview } from '@storybook/vue3-vite'; + +const preview: Preview = { + decorators: [ + (story, { globals }) => { + return { + components: { story }, + setup() { + const greeting = computed(() => globals?.locale === 'en' ? 'Hello!' : '¡Hola!'); + + return { greeting, globals }; + }, + template: ` +
+

Greeting: {{greeting}}

+ +
+ `, + }; + }, + ], +}; + +export default preview; +``` diff --git a/docs/_snippets/decorator-with-updateArgs.md b/docs/_snippets/decorator-with-updateArgs.md new file mode 100644 index 000000000000..3212cae0fc1b --- /dev/null +++ b/docs/_snippets/decorator-with-updateArgs.md @@ -0,0 +1,58 @@ +```js filename=".storybook/preview.js" renderer="vue" language="js" +import { useArgs } from 'storybook/preview-api'; + +const WithIncrementDecorator = { + args: { + counter: 0, + }, + decorators: [ + (story, { args }) => { + const [, updateArgs] = useArgs(); + return { + components: { story }, + setup() { + return { args, updateArgs }; + }, + template: ` +
+ + +
+ `, + }; + }, + ], +}; +``` + +```ts filename=".storybook/preview.ts" renderer="vue" language="ts" +import { useArgs } from 'storybook/preview-api'; +import type { Meta, StoryObj } from '@storybook/vue3'; + +const WithIncrementDecorator: StoryObj> = { + args: { + counter: 0, + }, + decorators: [ + (story, { args }) => { + const [, updateArgs] = useArgs(); + return { + components: { story }, + setup() { + return { args, updateArgs }; + }, + template: ` +
+ + +
+ `, + }; + }, + ], +}; +``` diff --git a/docs/writing-stories/decorators.mdx b/docs/writing-stories/decorators.mdx index 7acaf6bdfa33..b60532bd9ac4 100644 --- a/docs/writing-stories/decorators.mdx +++ b/docs/writing-stories/decorators.mdx @@ -79,6 +79,30 @@ This context can be used to adjust the behavior of your decorator based on the s For another example, see the section on [configuring the mock provider](./mocking-data-and-modules/mocking-providers.mdx#configuring-the-mock-provider), which demonstrates how to use the same technique to change which theme is provided to the component. + + +### Reactive globals + +To ensure `globals` in Vue decorators are fully reactive, you must pass them through the `setup` function. You can also compute derived values with Vue's `computed` function. + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + +### Preview API hooks + +The same applies to Storybook hooks you want to call in your decorator. For instance, this decorator increments a counter used by its decorated story. + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + + + ### Using decorators to provide data If your components are “connected” and require side-loaded data to render, you can use decorators to provide that data in a mocked way without having to refactor your components to take that data as an arg. There are several techniques to achieve this. Depending on exactly how you are loading that data. Read more in the [building pages in Storybook](./build-pages-with-storybook.mdx) section.