diff --git a/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts b/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts index 02799288b716..3542e0a1919c 100644 --- a/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts +++ b/code/lib/preview-api/src/modules/preview-web/docs-context/DocsContext.ts @@ -48,7 +48,7 @@ export class DocsContext implements DocsContextProps }); } - // This docs entry references this CSF file and can syncronously load the stories, as well + // This docs entry references this CSF file and can synchronously load the stories, as well // as reference them by module export. If the CSF is part of the "component" stories, they // can also be referenced by name and are in the componentStories list. referenceCSFFile(csfFile: CSFFile) { diff --git a/code/ui/blocks/src/blocks/Canvas.stories.tsx b/code/ui/blocks/src/blocks/Canvas.stories.tsx index 329690713f78..ccc50895e53c 100644 --- a/code/ui/blocks/src/blocks/Canvas.stories.tsx +++ b/code/ui/blocks/src/blocks/Canvas.stories.tsx @@ -1,66 +1,64 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { Canvas, SourceState } from './Canvas'; -import { Story as StoryComponent } from './Story'; +import dedent from 'ts-dedent'; +import { Canvas } from './Canvas'; +import SourceStoriesMeta from './Source.stories'; import * as ButtonStories from '../examples/Button.stories'; +import * as ParameterStories from '../examples/CanvasParameters.stories'; +import * as SourceParameterStories from '../examples/SourceParameters.stories'; const meta: Meta = { component: Canvas, parameters: { - relativeCsfPaths: ['../examples/Button.stories'], - }, - render: (args) => { - return ( - - - - ); + relativeCsfPaths: [ + '../examples/Button.stories', + '../examples/CanvasParameters.stories', + '../examples/SourceParameters.stories', + ], + snippets: { + 'storybook-blocks-example-button--primary': { + code: dedent` + ', // spaces should be removed by the prettier formatter + format: 'html', + }, + }, +}; + +export const PropInlineStory: Story = { + name: 'Prop story = { ..., inline: true }', + args: { + of: ButtonStories.Primary, + story: { inline: false, height: '200px' }, + }, +}; + +export const PropAutoplayingStory: Story = { + name: 'Prop story = { ..., autoplay: true}', + args: { + of: ButtonStories.Clicking, + story: { autoplay: true }, + }, +}; + +const ClassNameStoryDescription = () => (

This story sets the className prop on the Canvas to{' '} my-custom-classname, which will propagate to the preview element. To demonstrate this, it also adds a style tag that sets another background color for that class:

); -/** - * This is a comment on classname - */ -export const ClassName: Story = { - name: 'ClassName', +export const PropClassName: Story = { + name: 'Prop className = my-custom-classname', args: { + of: ButtonStories.Primary, className: 'my-custom-classname', }, render: (args) => ( <> - + + + + + ), +}; + +export const ParameterWithToolbar: Story = { + name: 'parameters.docs.canvas.withToolbar = true', + args: { + of: ParameterStories.WithToolbar, + }, +}; + +export const ParameterAdditionalActions: Story = { + name: 'parameters.docs.canvas.additionalActions = [ ... ]', + args: { + of: ParameterStories.AdditionalActions, + }, +}; + +export const ParameterClassName: Story = { + name: 'parameters.docs.canvas.className = my-custom-classname', + args: { + of: ParameterStories.ClassName, + }, + render: (args) => ( + <> + - - - + ), }; + +export const ParametersSourceStateShown: Story = { + name: 'parameters.docs.canvas.sourceState = shown', + args: { + of: ParameterStories.SourceStateShown, + }, +}; + +export const ParametersSourceStateHidden: Story = { + name: 'parameters.docs.canvas.sourceState = hidden', + args: { + of: ParameterStories.SourceStateHidden, + }, +}; + +export const ParametersSourceStateNone: Story = { + name: 'parameters.docs.canvas.sourceState = none', + args: { + of: ParameterStories.SourceStateNone, + }, +}; + +export const ParameterDocsCanvasLayoutFullscreen: Story = { + name: 'parameters.docs.canvas.layout = fullscreen', + args: { + of: ParameterStories.DocsCanvasLayoutFullscreen, + }, +}; + +export const ParameterDocsCanvasLayoutCentered: Story = { + name: 'parameters.docs.canvas.layout = centered', + args: { + of: ParameterStories.DocsCanvasLayoutCentered, + }, +}; + +export const ParameterDocsCanvasLayoutPadded: Story = { + name: 'parameters.docs.canvas.layout = padded', + args: { + of: ParameterStories.DocsCanvasLayoutPadded, + }, +}; + +export const ParameterLayoutFullscreen: Story = { + name: 'parameters.layout = fullscreen', + args: { + of: ParameterStories.LayoutFullscreen, + }, +}; + +export const ParameterLayoutCentered: Story = { + name: 'parameters.layout = centered', + args: { + of: ParameterStories.LayoutCentered, + }, +}; + +export const ParameterLayoutPadded: Story = { + name: 'parameters.layout = padded', + args: { + of: ParameterStories.LayoutPadded, + }, +}; + +export const ParameterSource: Story = { + name: 'parameters.docs.source', + args: { + of: SourceParameterStories.CodeLanguage, + }, +}; + +export const ParameterStory: Story = { + name: 'parameters.docs.story', + args: { + of: ParameterStories.StoryParameters, + }, +}; diff --git a/code/ui/blocks/src/blocks/Canvas.tsx b/code/ui/blocks/src/blocks/Canvas.tsx index 6ccd43f49650..c6bf9c1b7f77 100644 --- a/code/ui/blocks/src/blocks/Canvas.tsx +++ b/code/ui/blocks/src/blocks/Canvas.tsx @@ -1,26 +1,105 @@ +/* eslint-disable react/destructuring-assignment */ import React, { Children, useContext } from 'react'; import type { FC, ReactElement, ReactNode } from 'react'; -import type { Renderer } from '@storybook/types'; -import type { PreviewProps as PurePreviewProps } from '../components'; +import type { ModuleExport, ModuleExports, Renderer } from '@storybook/types'; +import { deprecate } from '@storybook/client-logger'; +import dedent from 'ts-dedent'; +import type { Layout, PreviewProps as PurePreviewProps } from '../components'; import { Preview as PurePreview, PreviewSkeleton } from '../components'; import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import type { SourceContextProps } from './SourceContainer'; import { SourceContext } from './SourceContainer'; -import { useSourceProps, SourceState } from './Source'; +import type { SourceProps } from './Source'; +import { useSourceProps, SourceState as DeprecatedSourceState, SourceState } from './Source'; import { useStories } from './useStory'; -import { getStoryId } from './Story'; +import type { StoryProps } from './Story'; +import { getStoryId, Story } from './Story'; +import { useOf } from './useOf'; -export { SourceState }; +export { DeprecatedSourceState as SourceState }; -type CanvasProps = Omit & { - withSource?: SourceState; +type DeprecatedCanvasProps = { + /** + * @deprecated multiple stories are not supported + */ + isColumn?: boolean; + /** + * @deprecated multiple stories are not supported + */ + columns?: number; + /** + * @deprecated use `sourceState` instead + */ + withSource?: DeprecatedSourceState; + /** + * @deprecated use `source.code` instead + */ mdxSource?: string; + /** + * @deprecated reference stories with the `of` prop instead + */ children?: ReactNode; }; -const usePreviewProps = ( - { withSource, mdxSource, children, ...props }: CanvasProps, +type CanvasProps = Pick & { + /** + * Pass the export defining a story to render that story + * + * ```jsx + * import { Meta, Canvas } from '@storybook/blocks'; + * import * as ButtonStories from './Button.stories'; + * + * + * + * ``` + */ + of?: ModuleExport; + /** + * Pass all exports of the CSF file if this MDX file is unattached + * + * ```jsx + * import { Canvas } from '@storybook/blocks'; + * import * as ButtonStories from './Button.stories'; + * + * + * ``` + */ + meta?: ModuleExports; + /** + * Specify the initial state of the source panel + * hidden: the source panel is hidden by default + * shown: the source panel is shown by default + * none: the source panel is not available and the button to show it is hidden + * @default 'hidden' + */ + sourceState?: 'hidden' | 'shown' | 'none'; + /** + * how to layout the story within the canvas + * padded: the story has padding within the canvas + * fullscreen: the story is rendered edge to edge within the canvas + * centered: the story is centered within the canvas + * @default 'padded' + */ + layout?: Layout; + /** + * @see {SourceProps} + */ + source?: Omit; + /** + * @see {StoryProps} + */ + story?: Pick; +}; + +const useDeprecatedPreviewProps = ( + { + withSource, + mdxSource, + children, + layout: layoutProp, + ...props + }: DeprecatedCanvasProps & { of?: ModuleExport; layout?: Layout }, docsContext: DocsContextProps, sourceContext: SourceContextProps ) => { @@ -36,7 +115,7 @@ const usePreviewProps = ( const stories = useStories(storyIds, docsContext); const isLoading = stories.some((s) => !s); const sourceProps = useSourceProps( - mdxSource ? { code: decodeURI(mdxSource) } : { ids: storyIds }, + mdxSource ? { code: decodeURI(mdxSource), of: props.of } : { ids: storyIds, of: props.of }, docsContext, sourceContext ); @@ -44,23 +123,116 @@ const usePreviewProps = ( return { isLoading, previewProps: props }; } + // if the user has specified a layout prop, use that... + let layout = layoutProp; + // ...otherwise, try to infer it from the children 'parameters' prop + Children.forEach(children, (child) => { + if (layout) { + return; + } + layout = (child as ReactElement)?.props?.parameters?.layout; + }); + // ...otherwise, try to infer it from the story parameters + stories.forEach((story) => { + if (layout || !story) { + return; + } + layout = story?.parameters.layout ?? story.parameters.docs?.canvas?.layout; + }); + return { isLoading, previewProps: { ...props, // pass through columns etc. + layout: layout ?? 'padded', withSource: sourceProps, isExpanded: (withSource || sourceProps.state) === SourceState.OPEN, }, }; }; -export const Canvas: FC = (props) => { +export const Canvas: FC = (props) => { const docsContext = useContext(DocsContext); const sourceContext = useContext(SourceContext); - const { isLoading, previewProps } = usePreviewProps(props, docsContext, sourceContext); - const { children } = props; + const { children, of, source } = props; + const { isLoading, previewProps } = useDeprecatedPreviewProps(props, docsContext, sourceContext); - if (isLoading) return ; + let story; + let sourceProps; + /** + * useOf and useSourceProps will throw if they can't find the story, in the scenario where + * the doc is unattached (no primary story) and 'of' is undefined. + * That scenario is valid in the deprecated API, where children is used as story refs rather than 'of'. + * So if children is passed we allow the error to be swallowed and we'll use them instead. + * We use two separate try blocks and throw the error afterwards to not break the rules of hooks. + */ + let hookError; + try { + ({ story } = useOf(of || 'story', ['story'])); + } catch (error) { + if (!children) { + hookError = error; + } + } + try { + sourceProps = useSourceProps({ ...source, of }, docsContext, sourceContext); + } catch (error) { + if (!children) { + hookError = error; + } + } + if (hookError) { + throw hookError; + } - return {children}; + if (props.withSource) { + deprecate(dedent`Setting source state with \`withSource\` is deprecated, please use \`sourceState\` with 'hidden', 'shown' or 'none' instead. + + Please refer to the migration guide: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#canvas-block' + `); + } + if (props.mdxSource) { + deprecate(dedent`Setting source code with \`mdxSource\` is deprecated, please use source={{code: '...'}} instead. + + Please refer to the migration guide: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#canvas-block' + `); + } + if (props.isColumn !== undefined || props.columns !== undefined) { + deprecate(dedent`\`isColumn\` and \`columns\` props are deprecated as the Canvas block now only supports showing a single story. + + Please refer to the migration guide: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#canvas-block' + `); + } + if (children) { + deprecate(dedent`Passing children to Canvas is deprecated, please use the \`of\` prop instead to reference a story. + + Please refer to the migration guide: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#canvas-block' + `); + return isLoading ? ( + + ) : ( + {children} + ); + } + + const layout = + props.layout ?? story.parameters.layout ?? story.parameters.docs?.canvas?.layout ?? 'padded'; + const withToolbar = props.withToolbar ?? story.parameters.docs?.canvas?.withToolbar ?? false; + const additionalActions = + props.additionalActions ?? story.parameters.docs?.canvas?.additionalActions; + const sourceState = props.sourceState ?? story.parameters.docs?.canvas?.sourceState ?? 'hidden'; + const className = props.className ?? story.parameters.docs?.canvas?.className; + + return ( + + + + ); }; diff --git a/code/ui/blocks/src/blocks/DocsStory.tsx b/code/ui/blocks/src/blocks/DocsStory.tsx index efdf5e51081b..1c9599667acc 100644 --- a/code/ui/blocks/src/blocks/DocsStory.tsx +++ b/code/ui/blocks/src/blocks/DocsStory.tsx @@ -4,38 +4,27 @@ import { Subheading } from './Subheading'; import type { DocsStoryProps } from './types'; import { Anchor } from './Anchor'; import { Description } from './Description'; -import { Story } from './Story'; import { Canvas } from './Canvas'; +import { useOf } from './useOf'; export const DocsStory: FC = ({ - id, - name, + of, expanded = true, withToolbar = false, - parameters = {}, __forceInitialArgs = false, __primary = false, }) => { - let description; - const { docs } = parameters; - if (expanded && docs) { - description = docs.description?.story; - } - - const subheading = expanded && name; + const { story } = useOf(of || 'story', ['story']); return ( - - {subheading && {subheading}} - {description && } - - - + + {expanded && ( + <> + {story.name} + + + )} + ); }; diff --git a/code/ui/blocks/src/blocks/Primary.tsx b/code/ui/blocks/src/blocks/Primary.tsx index 8095d079323e..1a4e36672453 100644 --- a/code/ui/blocks/src/blocks/Primary.tsx +++ b/code/ui/blocks/src/blocks/Primary.tsx @@ -1,16 +1,28 @@ import type { FC } from 'react'; import React, { useContext } from 'react'; +import dedent from 'ts-dedent'; +import { deprecate } from '@storybook/client-logger'; import { DocsContext } from './DocsContext'; import { DocsStory } from './DocsStory'; interface PrimaryProps { + /** + * @deprecated Primary block should only be used to render the primary story, which is automatically found. + */ name?: string; } export const Primary: FC = ({ name }) => { const docsContext = useContext(DocsContext); + if (name) { + deprecate(dedent`\`name\` prop is deprecated on the Primary block. + The Primary block should only be used to render the primary story, which is automatically found. + `); + } const storyId = name && docsContext.storyIdByName(name); const story = docsContext.storyById(storyId); - return story ? : null; + return story ? ( + + ) : null; }; diff --git a/code/ui/blocks/src/blocks/Source.tsx b/code/ui/blocks/src/blocks/Source.tsx index 564a511b7b2b..f63d497af048 100644 --- a/code/ui/blocks/src/blocks/Source.tsx +++ b/code/ui/blocks/src/blocks/Source.tsx @@ -33,7 +33,7 @@ type SourceParameters = SourceCodeProps & { originalSource?: string; }; -type SourceProps = Omit & { +export type SourceProps = Omit & { /** * Pass the export defining a story to render its source * diff --git a/code/ui/blocks/src/blocks/Stories.tsx b/code/ui/blocks/src/blocks/Stories.tsx index 76cb3a6a1e04..4accadb42eed 100644 --- a/code/ui/blocks/src/blocks/Stories.tsx +++ b/code/ui/blocks/src/blocks/Stories.tsx @@ -3,7 +3,6 @@ import React, { useContext } from 'react'; import { DocsContext } from './DocsContext'; import { DocsStory } from './DocsStory'; import { Heading } from './Heading'; -import type { DocsStoryProps } from './types'; interface StoriesProps { title?: JSX.Element | string; @@ -13,8 +12,8 @@ interface StoriesProps { export const Stories: FC = ({ title, includePrimary = true }) => { const { componentStories } = useContext(DocsContext); - let stories: DocsStoryProps[] = componentStories(); - stories = stories.filter((story) => !story.parameters?.docs?.disable); + let stories = componentStories().filter((story) => !story.parameters?.docs?.disable); + if (!includePrimary) stories = stories.slice(1); if (!stories || stories.length === 0) { @@ -24,7 +23,8 @@ export const Stories: FC = ({ title, includePrimary = true }) => { <> {title} {stories.map( - (story) => story && + (story) => + story && )} ); diff --git a/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx b/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx index 1f4b4ffddcd0..f16f8bb80329 100644 --- a/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx +++ b/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx @@ -4,16 +4,17 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; import { expect } from '@storybook/jest'; -import { Canvas } from '../Canvas'; +import { Canvas, SourceState } from '../Canvas'; import { Story as StoryComponent } from '../Story'; import * as ButtonStories from '../../examples/Button.stories'; +import * as CanvasParameterStories from '../../examples/CanvasParameters.stories'; const meta: Meta = { title: 'Blocks/Internal/Canvas', component: Canvas, parameters: { theme: 'light', - relativeCsfPaths: ['../examples/Button.stories'], + relativeCsfPaths: ['../examples/Button.stories', '../examples/CanvasParameters.stories'], }, render: (args) => { return ( @@ -40,10 +41,87 @@ const expectAmountOfStoriesInSource = await userEvent.click(showCodeButton); // Assert - check that the correct amount of stories' source is shown - const booleanControlNodes = await canvas.findAllByText(`label`); - await expect(booleanControlNodes).toHaveLength(amount); + const buttonNodes = await canvas.findAllByText(`label`); + await expect(buttonNodes).toHaveLength(amount); }; +export const BasicStoryChild: Story = {}; + +export const BasicStoryChildUnattached: Story = { + parameters: { attached: false }, +}; + +export const WithSourceOpen: Story = { + args: { + withSource: SourceState.OPEN, + }, +}; +export const WithSourceClosed: Story = { + args: { + withSource: SourceState.CLOSED, + }, +}; + +export const WithMdxSource: Story = { + name: 'With MDX Source', + args: { + withSource: SourceState.OPEN, + mdxSource: `