diff --git a/MIGRATION.md b/MIGRATION.md index 400bbdf8fe7a..9feeb0c1b715 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -39,6 +39,7 @@ - [MDX2 upgrade](#mdx2-upgrade) - [Dropped source loader / storiesOf static snippets](#dropped-source-loader--storiesof-static-snippets) - [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration) + - [Autoplay in docs](#autoplay-in-docs) - [7.0 Deprecations](#70-deprecations) - [`Story` type deprecated](#story-type-deprecated) - [`ComponentStory`, `ComponentStoryObj`, `ComponentStoryFn` and `ComponentMeta` types are deprecated](#componentstory-componentstoryobj-componentstoryfn-and-componentmeta-types-are-deprecated) @@ -780,6 +781,12 @@ module.exports = { Storybook Docs 5.x shipped with instructions for how to manually configure webpack and storybook without the use of Storybook's "presets" feature. Over time, these docs went out of sync. Now in Storybook 7 we have removed support for manual configuration entirely. +#### Autoplay in docs + +Running play functions in docs is generally tricky, as they can steal focus and cause the window to scroll. Consequently, we've disabled play functions in docs by default. + +If your story depends on a play function to render correctly, _and_ you are confident the function autoplaying won't mess up your docs, you can set `parameters.docs.autoplay = true` to have it auto play. + ### 7.0 Deprecations #### `Story` type deprecated diff --git a/code/addons/docs/template/stories/docspage/autoplay.stories.ts b/code/addons/docs/template/stories/docspage/autoplay.stories.ts new file mode 100644 index 000000000000..974837b582c4 --- /dev/null +++ b/code/addons/docs/template/stories/docspage/autoplay.stories.ts @@ -0,0 +1,33 @@ +import globalThis from 'global'; +import { expect } from '@storybook/jest'; +import { within } from '@storybook/testing-library'; + +export default { + component: globalThis.Components.Pre, + tags: ['docsPage'], + args: { text: 'Play has not run' }, + parameters: { chromatic: { disable: true } }, +}; + +// Should not autoplay +export const NoAutoplay = { + play: async ({ viewMode, canvasElement }) => { + const pre = await within(canvasElement).findByText('Play has not run'); + if (viewMode === 'docs') { + pre.innerText = 'Play should not have run!'; + // Sort of pointless + expect(viewMode).not.toBe('docs'); + } else { + pre.innerText = 'Play has run'; + } + }, +}; + +// Should autoplay +export const Autoplay = { + parameters: { docs: { autoplay: true } }, + play: async ({ canvasElement }) => { + const pre = await within(canvasElement).findByText('Play has not run'); + pre.innerText = 'Play has run'; + }, +}; diff --git a/code/e2e-tests/addon-docs.spec.ts b/code/e2e-tests/addon-docs.spec.ts index 0ae090f8dc21..3363d4476f9e 100644 --- a/code/e2e-tests/addon-docs.spec.ts +++ b/code/e2e-tests/addon-docs.spec.ts @@ -40,4 +40,16 @@ test.describe('addon-docs', () => { await expect(text).not.toMatch(/^\(args\) => /); } }); + + test('should not run autoplay stories without parameter', async ({ page }) => { + const sbPage = new SbPage(page); + await sbPage.navigateToStory('addons/docs/docspage/autoplay', 'docs'); + + const root = sbPage.previewRoot(); + const autoplayPre = root.locator('#story--addons-docs-docspage-autoplay--autoplay pre'); + await expect(autoplayPre).toHaveText('Play has run'); + + const noAutoplayPre = root.locator('#story--addons-docs-docspage-autoplay--no-autoplay pre'); + await expect(noAutoplayPre).toHaveText('Play has not run'); + }); }); diff --git a/code/lib/preview-web/src/Preview.tsx b/code/lib/preview-web/src/Preview.tsx index fcf46c794a48..0e645757b402 100644 --- a/code/lib/preview-web/src/Preview.tsx +++ b/code/lib/preview-web/src/Preview.tsx @@ -31,6 +31,7 @@ import type { } from '@storybook/types'; import { StoryStore } from '@storybook/store'; +import type { StoryRenderOptions } from './render/StoryRender'; import { StoryRender } from './render/StoryRender'; import type { TemplateDocsRender } from './render/TemplateDocsRender'; import type { StandaloneDocsRender } from './render/StandaloneDocsRender'; @@ -304,7 +305,11 @@ export class Preview { // main to be consistent with the previous behaviour. In the future, // we will change it to go ahead and load the story, which will end up being // "instant", although async. - renderStoryToElement(story: Store_Story, element: HTMLElement) { + renderStoryToElement( + story: Store_Story, + element: HTMLElement, + options: StoryRenderOptions + ) { if (!this.renderToDOM) throw new Error(`Cannot call renderStoryToElement before initialization`); @@ -315,6 +320,7 @@ export class Preview { this.inlineStoryCallbacks(story.id), story.id, 'docs', + options, story ); render.renderToElement(element); diff --git a/code/lib/preview-web/src/docs-context/DocsContextProps.ts b/code/lib/preview-web/src/docs-context/DocsContextProps.ts index 49ffbe8a7557..7f23c0b833cd 100644 --- a/code/lib/preview-web/src/docs-context/DocsContextProps.ts +++ b/code/lib/preview-web/src/docs-context/DocsContextProps.ts @@ -8,6 +8,7 @@ import type { StoryName, } from '@storybook/types'; import type { Channel } from '@storybook/channels'; +import type { StoryRenderOptions } from '../render/StoryRender'; export interface DocsContextProps { /** @@ -54,7 +55,8 @@ export interface DocsContextProps, - element: HTMLElement + element: HTMLElement, + options: StoryRenderOptions ) => () => Promise; /** diff --git a/code/lib/preview-web/src/render/Render.ts b/code/lib/preview-web/src/render/Render.ts index 8e1b187c37b2..839727e48fab 100644 --- a/code/lib/preview-web/src/render/Render.ts +++ b/code/lib/preview-web/src/render/Render.ts @@ -1,4 +1,5 @@ import type { StoryId, AnyFramework } from '@storybook/types'; +import type { StoryRenderOptions } from './StoryRender'; export type RenderType = 'story' | 'docs'; @@ -17,7 +18,11 @@ export interface Render { disableKeyListeners: boolean; teardown?: (options: { viewModeChanged: boolean }) => Promise; torndown: boolean; - renderToElement: (canvasElement: HTMLElement, renderStoryToElement?: any) => Promise; + renderToElement: ( + canvasElement: HTMLElement, + renderStoryToElement?: any, + options?: StoryRenderOptions + ) => Promise; } export const PREPARE_ABORTED = new Error('prepareAborted'); diff --git a/code/lib/preview-web/src/render/StoryRender.test.ts b/code/lib/preview-web/src/render/StoryRender.test.ts index 4ca15195c07e..4f157cfdb5cd 100644 --- a/code/lib/preview-web/src/render/StoryRender.test.ts +++ b/code/lib/preview-web/src/render/StoryRender.test.ts @@ -50,4 +50,56 @@ describe('StoryRender', () => { await expect(preparePromise).rejects.toThrowError(PREPARE_ABORTED); }); + + it('does run play function if passed autoplay=true', async () => { + const story = { + id: 'id', + title: 'title', + name: 'name', + tags: [], + applyLoaders: jest.fn(), + unboundStoryFn: jest.fn(), + playFunction: jest.fn(), + }; + + const render = new StoryRender( + new Channel(), + { getStoryContext: () => ({}) } as any, + jest.fn() as any, + {} as any, + entry.id, + 'story', + { autoplay: true }, + story as any + ); + + await render.renderToElement({} as any); + expect(story.playFunction).toHaveBeenCalled(); + }); + + it('does not run play function if passed autoplay=false', async () => { + const story = { + id: 'id', + title: 'title', + name: 'name', + tags: [], + applyLoaders: jest.fn(), + unboundStoryFn: jest.fn(), + playFunction: jest.fn(), + }; + + const render = new StoryRender( + new Channel(), + { getStoryContext: () => ({}) } as any, + jest.fn() as any, + {} as any, + entry.id, + 'story', + { autoplay: false }, + story as any + ); + + await render.renderToElement({} as any); + expect(story.playFunction).not.toHaveBeenCalled(); + }); }); diff --git a/code/lib/preview-web/src/render/StoryRender.ts b/code/lib/preview-web/src/render/StoryRender.ts index e2b3d056ae93..0c8d8a827366 100644 --- a/code/lib/preview-web/src/render/StoryRender.ts +++ b/code/lib/preview-web/src/render/StoryRender.ts @@ -47,6 +47,10 @@ export type RenderContextCallbacks = Pick< 'showMain' | 'showError' | 'showException' >; +export type StoryRenderOptions = { + autoplay?: boolean; +}; + export class StoryRender implements Render { public type: RenderType = 'story'; @@ -73,6 +77,7 @@ export class StoryRender implements Render, public id: StoryId, public viewMode: ViewMode, + public renderOptions: StoryRenderOptions = { autoplay: true }, story?: Store_Story ) { this.abortController = new AbortController(); @@ -220,7 +225,7 @@ export class StoryRender implements Render { diff --git a/code/ui/blocks/src/blocks/Story.tsx b/code/ui/blocks/src/blocks/Story.tsx index a9cf9e41c7d6..2b343f2d4144 100644 --- a/code/ui/blocks/src/blocks/Story.tsx +++ b/code/ui/blocks/src/blocks/Story.tsx @@ -90,11 +90,12 @@ const Story: FC = (props) => { let cleanup: () => void; if (story && storyRef.current) { const element = storyRef.current as HTMLElement; - cleanup = context.renderStoryToElement(story, element); + const { autoplay } = story.parameters.docs || {}; + cleanup = context.renderStoryToElement(story, element, { autoplay }); setShowLoader(false); } return () => cleanup && cleanup(); - }, [story]); + }, [context, story]); if (!story) { return ;