diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 4a9c24e16c8b..398cf4a59e1f 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -55,10 +55,6 @@ const config = defineMain({ directory: '../core/src/highlight', titlePrefix: 'highlight', }, - { - directory: '../addons/docs/src/blocks', - titlePrefix: 'addons/docs/blocks', - }, { directory: '../addons/a11y/src', titlePrefix: 'addons/accessibility', @@ -71,6 +67,10 @@ const config = defineMain({ directory: '../addons/docs/template/stories', titlePrefix: 'addons/docs', }, + { + directory: '../addons/docs/src', + titlePrefix: 'addons/docs', + }, { directory: '../addons/links/template/stories', titlePrefix: 'addons/links', diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx index 4887d8193d66..e8868f91e82f 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx @@ -13,6 +13,7 @@ import type { SortType } from '../components'; import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; import { useOf } from './useOf'; import { getComponentName } from './utils'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type ArgTypesParameters = { include?: PropDescriptor; @@ -62,7 +63,7 @@ function getArgTypesFromResolved(resolved: ReturnType) { return { argTypes, parameters, component, subcomponents }; } -export const ArgTypes: FC = (props) => { +const ArgTypesImpl: FC = (props) => { const { of } = props; if ('of' in props && of === undefined) { throw new Error('Unexpected `of={undefined}`, did you mistype a CSF file reference?'); @@ -103,3 +104,5 @@ export const ArgTypes: FC = (props) => { }; return ; }; + +export const ArgTypes = withMdxComponentOverride('ArgTypes', ArgTypesImpl); diff --git a/code/addons/docs/src/blocks/blocks/Canvas.tsx b/code/addons/docs/src/blocks/blocks/Canvas.tsx index ff4d131d63a0..50f2c7b9d7d0 100644 --- a/code/addons/docs/src/blocks/blocks/Canvas.tsx +++ b/code/addons/docs/src/blocks/blocks/Canvas.tsx @@ -13,6 +13,7 @@ import { SourceContext } from './SourceContainer'; import type { StoryProps } from './Story'; import { Story } from './Story'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type CanvasProps = Pick & { /** @@ -60,7 +61,7 @@ type CanvasProps = Pick; }; -export const Canvas: FC = (props) => { +const CanvasImpl: FC = (props) => { const docsContext = useContext(DocsContext); const sourceContext = useContext(SourceContext); const { of, source } = props; @@ -95,3 +96,5 @@ export const Canvas: FC = (props) => { ); }; + +export const Canvas = withMdxComponentOverride('Canvas', CanvasImpl); diff --git a/code/addons/docs/src/blocks/blocks/Controls.tsx b/code/addons/docs/src/blocks/blocks/Controls.tsx index 5f7e9eee69fa..a1b784662f40 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.tsx @@ -16,6 +16,7 @@ import { useArgs } from './useArgs'; import { useGlobals } from './useGlobals'; import { usePrimaryStory } from './usePrimaryStory'; import { getComponentName } from './utils'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type ControlsParameters = { include?: PropDescriptor; @@ -38,7 +39,7 @@ function extractComponentArgTypes( return extractArgTypes(component) as StrictArgTypes; } -export const Controls: FC = (props) => { +const ControlsImpl: FC = (props) => { const { of } = props; const context = useContext(DocsContext); const primaryStory = usePrimaryStory(); @@ -104,3 +105,5 @@ export const Controls: FC = (props) => { /> ); }; + +export const Controls = withMdxComponentOverride('Controls', ControlsImpl); diff --git a/code/addons/docs/src/blocks/blocks/Description.tsx b/code/addons/docs/src/blocks/blocks/Description.tsx index fa257611a01a..cb0adc758d6e 100644 --- a/code/addons/docs/src/blocks/blocks/Description.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { Markdown } from './Markdown'; import type { Of } from './useOf'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; export enum DescriptionType { INFO = 'info', @@ -58,7 +59,7 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str } }; -const DescriptionContainer: FC = (props) => { +const DescriptionImpl: FC = (props) => { const { of } = props; if ('of' in props && of === undefined) { @@ -70,4 +71,4 @@ const DescriptionContainer: FC = (props) => { return markdown ? {markdown} : null; }; -export { DescriptionContainer as Description }; +export const Description = withMdxComponentOverride('Description', DescriptionImpl); diff --git a/code/addons/docs/src/blocks/blocks/DocsStory.tsx b/code/addons/docs/src/blocks/blocks/DocsStory.tsx index 64d12fd0efe7..730757ce97b1 100644 --- a/code/addons/docs/src/blocks/blocks/DocsStory.tsx +++ b/code/addons/docs/src/blocks/blocks/DocsStory.tsx @@ -7,8 +7,9 @@ import { Description } from './Description'; import { Subheading } from './Subheading'; import type { DocsStoryProps } from './types'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; -export const DocsStory: FC = ({ +const DocsStoryImpl: FC = ({ of, expanded = true, withToolbar: withToolbarProp = false, @@ -37,3 +38,5 @@ export const DocsStory: FC = ({ ); }; + +export const DocsStory = withMdxComponentOverride('DocsStory', DocsStoryImpl); diff --git a/code/addons/docs/src/blocks/blocks/Heading.tsx b/code/addons/docs/src/blocks/blocks/Heading.tsx index f274d835b9c4..d0b1c06894a4 100644 --- a/code/addons/docs/src/blocks/blocks/Heading.tsx +++ b/code/addons/docs/src/blocks/blocks/Heading.tsx @@ -6,6 +6,7 @@ import { H2 } from 'storybook/internal/components'; import GithubSlugger from 'github-slugger'; import { HeaderMdx } from './mdx'; +import { withMdxComponentOverride } from './with-mdx-component-override'; export interface HeadingProps { disableAnchor?: boolean; @@ -13,7 +14,7 @@ export interface HeadingProps { export const slugs = new GithubSlugger(); -export const Heading: FC> = ({ +const HeadingImpl: FC> = ({ children, disableAnchor, ...props @@ -28,3 +29,5 @@ export const Heading: FC> = ({ ); }; + +export const Heading = withMdxComponentOverride('Heading', HeadingImpl); diff --git a/code/addons/docs/src/blocks/blocks/Markdown.tsx b/code/addons/docs/src/blocks/blocks/Markdown.tsx index c683d92fef06..0770a4e0d065 100644 --- a/code/addons/docs/src/blocks/blocks/Markdown.tsx +++ b/code/addons/docs/src/blocks/blocks/Markdown.tsx @@ -5,11 +5,12 @@ import PureMarkdown from 'markdown-to-jsx'; import { dedent } from 'ts-dedent'; import { AnchorMdx, CodeOrSourceMdx, HeadersMdx } from './mdx'; +import { withMdxComponentOverride } from './with-mdx-component-override'; // mirror props from markdown-to-jsx. From https://react-typescript-cheatsheet.netlify.app/docs/advanced/patterns_by_usecase#wrappingmirroring-a-component type MarkdownProps = typeof PureMarkdown extends React.ComponentType ? Props : never; -export const Markdown = (props: MarkdownProps) => { +const MarkdownImpl = (props: MarkdownProps) => { if (!props.children) { return null; } @@ -50,3 +51,5 @@ export const Markdown = (props: MarkdownProps) => { /> ); }; + +export const Markdown = withMdxComponentOverride('Markdown', MarkdownImpl); diff --git a/code/addons/docs/src/blocks/blocks/Meta.tsx b/code/addons/docs/src/blocks/blocks/Meta.tsx index b53ee212519c..eebe07ca5931 100644 --- a/code/addons/docs/src/blocks/blocks/Meta.tsx +++ b/code/addons/docs/src/blocks/blocks/Meta.tsx @@ -21,8 +21,8 @@ export const Meta: FC = ({ of }) => { try { const primary = context.storyById(); return ; - } catch (err) { - // It is possible to use in a unnattached MDX file + } catch { + // It is possible to use in an unattached MDX file return null; } }; diff --git a/code/addons/docs/src/blocks/blocks/Primary.tsx b/code/addons/docs/src/blocks/blocks/Primary.tsx index 4c22ad1eccd1..98f1e39c9279 100644 --- a/code/addons/docs/src/blocks/blocks/Primary.tsx +++ b/code/addons/docs/src/blocks/blocks/Primary.tsx @@ -3,11 +3,14 @@ import React from 'react'; import { DocsStory } from './DocsStory'; import { usePrimaryStory } from './usePrimaryStory'; +import { withMdxComponentOverride } from './with-mdx-component-override'; -export const Primary: FC = () => { +const PrimaryImpl: FC = () => { const primaryStory = usePrimaryStory(); return primaryStory ? ( ) : null; }; + +export const Primary = withMdxComponentOverride('Primary', PrimaryImpl); diff --git a/code/addons/docs/src/blocks/blocks/Source.tsx b/code/addons/docs/src/blocks/blocks/Source.tsx index 135ec93cec76..328a5ac0110b 100644 --- a/code/addons/docs/src/blocks/blocks/Source.tsx +++ b/code/addons/docs/src/blocks/blocks/Source.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, FC } from 'react'; +import type { ComponentProps } from 'react'; import React, { useContext, useMemo } from 'react'; import { SourceType } from 'storybook/internal/docs-tools'; @@ -11,6 +11,7 @@ import { DocsContext } from './DocsContext'; import type { SourceContextProps, SourceItem } from './SourceContainer'; import { SourceContext, UNKNOWN_ARGS_HASH, argsHash } from './SourceContainer'; import { useTransformCode } from './useTransformCode'; +import { withMdxComponentOverride } from './with-mdx-component-override'; export type SourceParameters = SourceCodeProps & { /** Where to read the source code from, see `SourceType` */ @@ -112,7 +113,7 @@ export const useSourceProps = ( try { // Always fall back to the primary story for source parameters, even if code is set. return docsContext.storyById(); - } catch (err) { + } catch { // You are allowed to use and unattached. } } @@ -170,9 +171,11 @@ export const useSourceProps = ( * Story source doc block renders source code if provided, or the source for a story if `storyId` is * provided, or the source for the current story if nothing is provided. */ -export const Source = (props: SourceProps) => { +const SourceImpl = (props: SourceProps) => { const sourceContext = useContext(SourceContext); const docsContext = useContext(DocsContext); const sourceProps = useSourceProps(props, docsContext, sourceContext); return ; }; + +export const Source = withMdxComponentOverride('Source', SourceImpl); diff --git a/code/addons/docs/src/blocks/blocks/Stories.tsx b/code/addons/docs/src/blocks/blocks/Stories.tsx index e6760638d5f5..555163f539e6 100644 --- a/code/addons/docs/src/blocks/blocks/Stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Stories.tsx @@ -8,6 +8,7 @@ import { styled } from 'storybook/theming'; import { DocsContext } from './DocsContext'; import { DocsStory } from './DocsStory'; import { Heading } from './Heading'; +import { withMdxComponentOverride } from './with-mdx-component-override'; interface StoriesProps { title?: ReactElement | string; @@ -30,7 +31,7 @@ const StyledHeading: typeof Heading = styled(Heading)(({ theme }) => ({ }, })); -export const Stories: FC = ({ title = 'Stories', includePrimary = true }) => { +const StoriesImpl: FC = ({ title = 'Stories', includePrimary = true }) => { const { componentStories, projectAnnotations, getStoryContext } = useContext(DocsContext); let stories = componentStories(); @@ -69,3 +70,5 @@ export const Stories: FC = ({ title = 'Stories', includePrimary = ); }; + +export const Stories = withMdxComponentOverride('Stories', StoriesImpl); diff --git a/code/addons/docs/src/blocks/blocks/Story.tsx b/code/addons/docs/src/blocks/blocks/Story.tsx index f9feca6d1311..5ec2b7fd0363 100644 --- a/code/addons/docs/src/blocks/blocks/Story.tsx +++ b/code/addons/docs/src/blocks/blocks/Story.tsx @@ -13,6 +13,7 @@ import { Story as PureStory, StorySkeleton } from '../components'; import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import { useStory } from './useStory'; +import { withMdxComponentOverride } from './with-mdx-component-override'; type PureStoryProps = ComponentProps; @@ -113,7 +114,7 @@ export const getStoryProps = ( }; }; -const Story: FC = (props = { __forceInitialArgs: false, __primary: false }) => { +const StoryImpl: FC = (props = { __forceInitialArgs: false, __primary: false }) => { const context = useContext(DocsContext); const storyId = getStoryId(props, context); const story = useStory(storyId, context); @@ -130,4 +131,4 @@ const Story: FC = (props = { __forceInitialArgs: false, __primary: f return ; }; -export { Story }; +export const Story = withMdxComponentOverride('Story', StoryImpl); diff --git a/code/addons/docs/src/blocks/blocks/Subheading.tsx b/code/addons/docs/src/blocks/blocks/Subheading.tsx index 206fd6e3101f..177ead07d057 100644 --- a/code/addons/docs/src/blocks/blocks/Subheading.tsx +++ b/code/addons/docs/src/blocks/blocks/Subheading.tsx @@ -6,8 +6,9 @@ import { H3 } from 'storybook/internal/components'; import type { HeadingProps } from './Heading'; import { slugs } from './Heading'; import { HeaderMdx } from './mdx'; +import { withMdxComponentOverride } from './with-mdx-component-override'; -export const Subheading: FC> = ({ children, disableAnchor }) => { +const SubheadingImpl: FC> = ({ children, disableAnchor }) => { if (disableAnchor || typeof children !== 'string') { return

{children}

; } @@ -18,3 +19,5 @@ export const Subheading: FC> = ({ children, disa ); }; + +export const Subheading = withMdxComponentOverride('Subheading', SubheadingImpl); diff --git a/code/addons/docs/src/blocks/blocks/Subtitle.tsx b/code/addons/docs/src/blocks/blocks/Subtitle.tsx index c20df8a97a99..776bd0660fca 100644 --- a/code/addons/docs/src/blocks/blocks/Subtitle.tsx +++ b/code/addons/docs/src/blocks/blocks/Subtitle.tsx @@ -6,6 +6,7 @@ import { deprecate } from 'storybook/internal/client-logger'; import { Subtitle as PureSubtitle } from '../components'; import type { Of } from './useOf'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; interface SubtitleProps { children?: ReactNode; @@ -19,7 +20,7 @@ interface SubtitleProps { const DEPRECATION_MIGRATION_LINK = 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#subtitle-block-and-parameterscomponentsubtitle'; -export const Subtitle: FunctionComponent = (props) => { +const SubtitleImpl: FunctionComponent = (props) => { const { of, children } = props; if ('of' in props && of === undefined) { @@ -50,3 +51,5 @@ export const Subtitle: FunctionComponent = (props) => { {content} ) : null; }; + +export const Subtitle = withMdxComponentOverride('Subtitle', SubtitleImpl); diff --git a/code/addons/docs/src/blocks/blocks/Title.tsx b/code/addons/docs/src/blocks/blocks/Title.tsx index 9aa93abbb3be..29d9484f1c87 100644 --- a/code/addons/docs/src/blocks/blocks/Title.tsx +++ b/code/addons/docs/src/blocks/blocks/Title.tsx @@ -6,6 +6,7 @@ import type { ComponentTitle } from 'storybook/internal/types'; import { Title as PureTitle } from '../components'; import type { Of } from './useOf'; import { useOf } from './useOf'; +import { withMdxComponentOverride } from './with-mdx-component-override'; interface TitleProps { /** @@ -25,7 +26,7 @@ export const extractTitle = (title: ComponentTitle) => { return groups?.[groups?.length - 1] || title; }; -export const Title: FunctionComponent = (props) => { +const TitleImpl: FunctionComponent = (props) => { const { children, of } = props; if ('of' in props && of === undefined) { @@ -50,3 +51,5 @@ export const Title: FunctionComponent = (props) => { return content ? {content} : null; }; + +export const Title = withMdxComponentOverride('Title', TitleImpl); diff --git a/code/addons/docs/src/blocks/blocks/Unstyled.tsx b/code/addons/docs/src/blocks/blocks/Unstyled.tsx index d32b687e88fb..b55b9bd01bc4 100644 --- a/code/addons/docs/src/blocks/blocks/Unstyled.tsx +++ b/code/addons/docs/src/blocks/blocks/Unstyled.tsx @@ -1,5 +1,9 @@ import React from 'react'; -export const Unstyled: React.FC< +import { withMdxComponentOverride } from './with-mdx-component-override'; + +const UnstyledImpl: React.FC< React.DetailedHTMLProps, HTMLDivElement> > = (props) =>
; + +export const Unstyled = withMdxComponentOverride('Unstyled', UnstyledImpl); diff --git a/code/addons/docs/src/blocks/blocks/Wrapper.tsx b/code/addons/docs/src/blocks/blocks/Wrapper.tsx index c48ad0a02fc8..d675446aa5b8 100644 --- a/code/addons/docs/src/blocks/blocks/Wrapper.tsx +++ b/code/addons/docs/src/blocks/blocks/Wrapper.tsx @@ -1,6 +1,10 @@ import type { FC } from 'react'; import React from 'react'; -export const Wrapper: FC< +import { withMdxComponentOverride } from './with-mdx-component-override'; + +const WrapperImpl: FC< React.DetailedHTMLProps, HTMLDivElement> > = ({ children }) =>
{children}
; + +export const Wrapper = withMdxComponentOverride('Wrapper', WrapperImpl); diff --git a/code/addons/docs/src/blocks/blocks/with-mdx-component-override.tsx b/code/addons/docs/src/blocks/blocks/with-mdx-component-override.tsx new file mode 100644 index 000000000000..627311d0f4de --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/with-mdx-component-override.tsx @@ -0,0 +1,49 @@ +import type { ComponentType } from 'react'; +import React from 'react'; + +import { useMDXComponents } from '@mdx-js/react'; + +const MDX_WRAPPED_BLOCK = Symbol('mdxWrappedBlock'); +const MdxWrappedBlockContext = React.createContext | null>(null); + +type WrappedBlockComponent

= ComponentType

& { + [MDX_WRAPPED_BLOCK]?: true; +}; + +// Imported MDX doc blocks bypass MDXProvider in MDX2+, so this restores `docs.components` overrides. +export const withMdxComponentOverride =

( + blockName: string, + Block: ComponentType

+): ComponentType

=> { + const WrappedBlock = (props: P) => { + // Some overrides intentionally compose with the public block export, e.g. + // `components.Title = (props) => `. Track which wrapped blocks are already + // being resolved so those recursive re-entries render the underlying block instead of looping + // back through the MDX override lookup forever. + const wrappedBlocks = React.useContext(MdxWrappedBlockContext); + const components = useMDXComponents(); + const Override = components[blockName] as WrappedBlockComponent<P> | undefined; + + if (wrappedBlocks?.has(blockName) || Override === WrappedBlock) { + return <Block {...props} />; + } + + if (Override) { + const nextWrappedBlocks = new Set(wrappedBlocks ?? []); + nextWrappedBlocks.add(blockName); + + return ( + <MdxWrappedBlockContext.Provider value={nextWrappedBlocks}> + <Override {...props} /> + </MdxWrappedBlockContext.Provider> + ); + } + + return <Block {...props} />; + }; + + WrappedBlock.displayName = blockName; + (WrappedBlock as WrappedBlockComponent<P>)[MDX_WRAPPED_BLOCK] = true; + + return WrappedBlock; +}; diff --git a/code/addons/docs/src/blocks/component-overrides.mdx b/code/addons/docs/src/blocks/component-overrides.mdx new file mode 100644 index 000000000000..87b49591b8a4 --- /dev/null +++ b/code/addons/docs/src/blocks/component-overrides.mdx @@ -0,0 +1,61 @@ +import { + ArgTypes, + Canvas, + Controls, + Description, + DocsStory, + Heading, + Markdown, + Meta, + Primary, + Source, + Stories, + Story, + Subheading, + Subtitle, + Title, + Unstyled, + Wrapper, +} from '@storybook/addon-docs/blocks'; +import * as ComponentOverrideStories from './component-overrides.stories.tsx'; + +<Meta of={ComponentOverrideStories} name="MDX" /> + +# Component override verification + +Every block below should render its `override:*` marker rather than the default Storybook block UI. +The `Title` override intentionally renders `<Title />` again, so it also proves recursive composition does not loop infinitely. +`Meta` is intentionally excluded because it is not overridable. + +<Title /> +<Subtitle /> +<Description of="meta" /> + +<Primary /> +<Controls /> +<ArgTypes /> +<Stories /> +<Story of={ComponentOverrideStories.UsesDefaultImplementation} /> +<Canvas of={ComponentOverrideStories.UsesDefaultImplementation} /> +<Source of={ComponentOverrideStories.UsesDefaultImplementation} /> +<DocsStory of={ComponentOverrideStories.UsesDefaultImplementation} /> + +<Heading>Heading block</Heading> +<Subheading>Subheading block</Subheading> + +<Markdown> + {` +# Markdown block + +This should render as the Markdown override. +`} + +</Markdown> + +<Wrapper> + <div>Wrapper child content</div> +</Wrapper> + +<Unstyled> + <div>Unstyled child content</div> +</Unstyled> diff --git a/code/addons/docs/src/blocks/component-overrides.stories.tsx b/code/addons/docs/src/blocks/component-overrides.stories.tsx new file mode 100644 index 000000000000..35d151f9a4fc --- /dev/null +++ b/code/addons/docs/src/blocks/component-overrides.stories.tsx @@ -0,0 +1,151 @@ +/** + * These stories use JSX so they are not part of the template stories. Even though they _do_ work in + * non-React frameworks, we are keeping them out of the sandboxes and only have them in the main UI + * Storybook. + */ +import React from 'react'; +import type { FC, ReactNode } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Title as DocsTitle } from '@storybook/addon-docs/blocks'; + +import { MDXProvider } from '@mdx-js/react'; +import { expect } from 'storybook/test'; + +import { withMdxComponentOverride } from './blocks/with-mdx-component-override'; + +type OverrideProps = { + children?: ReactNode; +}; + +type TestBlockProps = { + label: string; +}; + +const OverrideShell = ({ name, children }: { name: string; children?: ReactNode }) => ( + <div + data-testid={`override-${name}`} + style={{ + border: '2px solid #ff4785', + borderRadius: 6, + color: '#ff4785', + fontFamily: 'monospace', + margin: '8px 0', + padding: '8px 12px', + }} + > + override:{name} + {children ? <div style={{ marginTop: 8 }}>{children}</div> : null} + </div> +); + +const createOverride = (name: string, renderChildren = false): FC<OverrideProps> => + function Override({ children }) { + return <OverrideShell name={name}>{renderChildren ? children : null}</OverrideShell>; + }; + +const RecursiveTitleOverride: FC<OverrideProps> = (props) => ( + <OverrideShell name="Title (via <Title /> composition)"> + <DocsTitle {...props} /> + </OverrideShell> +); + +const TestBlockImpl: FC<TestBlockProps> = ({ label }) => ( + <span data-testid="default">default:{label}</span> +); +const TestBlock = withMdxComponentOverride('TestBlock', TestBlockImpl); + +const SubtitleBlockImpl: FC<TestBlockProps> = ({ label }) => ( + <span data-testid="subtitle">subtitle:{label}</span> +); +const SubtitleBlock = withMdxComponentOverride('SubtitleBlock', SubtitleBlockImpl); + +const TestBlockOverride: FC<TestBlockProps> = ({ label }) => ( + <span data-testid="override">override:{label}</span> +); + +const RecursiveTestBlockOverride: FC<TestBlockProps> = (props) => <TestBlock {...props} />; + +type TestBlockComponents = { + TestBlock: React.ComponentType<TestBlockProps>; +}; + +const renderTestBlock = (components: TestBlockComponents | undefined) => ( + <MDXProvider components={components as React.ComponentProps<typeof MDXProvider>['components']}> + <TestBlock label="Hello" /> + </MDXProvider> +); + +const meta = { + tags: ['autodocs'], + args: { + label: 'Primary action', + }, + parameters: { + docs: { + name: 'ComponentOverrides', + subtitle: 'Subtitle supplied from docs parameters', + description: { + component: 'Component description used by the Description block.', + }, + components: { + ArgTypes: createOverride('ArgTypes'), + Canvas: createOverride('Canvas'), + Controls: createOverride('Controls'), + Description: createOverride('Description'), + DocsStory: createOverride('DocsStory'), + Heading: createOverride('Heading', true), + Markdown: createOverride('Markdown', true), + Primary: createOverride('Primary'), + Source: createOverride('Source'), + Stories: createOverride('Stories'), + Story: createOverride('Story'), + Subheading: createOverride('Subheading', true), + Subtitle: createOverride('Subtitle'), + Title: RecursiveTitleOverride, + Unstyled: createOverride('Unstyled', true), + Wrapper: createOverride('Wrapper', true), + }, + }, + }, +} satisfies Meta<typeof TestBlock>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const UsesDefaultImplementation: Story = { + render: () => renderTestBlock(undefined), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('default')).resolves.toHaveTextContent('default:Hello'); + }, +}; + +export const UsesMdxOverride: Story = { + render: () => renderTestBlock({ TestBlock: TestBlockOverride }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('override')).resolves.toHaveTextContent('override:Hello'); + }, +}; + +export const FallsBackWhenOverrideIsWrappedBlock: Story = { + render: () => renderTestBlock({ TestBlock }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('default')).resolves.toHaveTextContent('default:Hello'); + }, +}; + +export const FallsBackWhenOverrideComposesPublicBlock: Story = { + render: () => renderTestBlock({ TestBlock: RecursiveTestBlockOverride }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('default')).resolves.toHaveTextContent('default:Hello'); + }, +}; + +export const AllowsDifferentWrappedBlockOverride: Story = { + render: () => renderTestBlock({ TestBlock: SubtitleBlock }), + play: async ({ canvas }) => { + await expect(canvas.findByTestId('subtitle')).resolves.toHaveTextContent('subtitle:Hello'); + }, +}; diff --git a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.test.ts b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.test.ts index 6526af86ac08..146dd04cd569 100644 --- a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.test.ts +++ b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Channel } from 'storybook/internal/channels'; +import { Tag } from 'storybook/internal/core-server'; import type { DocsIndexEntry, RenderContextCallbacks, Renderer } from 'storybook/internal/types'; import type { StoryStore } from '../../store'; @@ -17,6 +18,14 @@ const entry = { storiesImports: [], } as DocsIndexEntry; +const attachedEntry = { + ...entry, + id: 'meta--docs', + title: 'Meta', + storiesImports: ['./Meta.stories.ts'], + tags: [Tag.ATTACHED_MDX], +} as DocsIndexEntry; + const createGate = (): [Promise<any | undefined>, (_?: any) => void] => { let openGate = (_?: any) => {}; const gate = new Promise<any | undefined>((resolve) => { @@ -90,4 +99,74 @@ describe('attaching', () => { expect(context.storyById()).toEqual(story); }); + + it('pre-attaches the indexed CSF file for attached MDX docs', async () => { + const render = new MdxDocsRender( + new Channel({}), + store, + attachedEntry, + {} as RenderContextCallbacks<Renderer> + ); + await render.prepare(); + + const context = render.docsContext(vi.fn()); + + expect(context.storyById()).toEqual(story); + }); +}); + +describe('docs parameters', () => { + it('uses the attached CSF story docs parameters for attached MDX docs', async () => { + const renderPage = vi.fn(); + const renderer = { render: renderPage }; + const docsRenderer = vi.fn(async () => renderer); + const { story, csfFile, moduleExports } = csfFileParts(); + const attachedStory = { + ...story, + parameters: { + docs: { + components: { Canvas: 'OverrideCanvas' }, + renderer: docsRenderer, + }, + }, + }; + const store = { + loadEntry: () => ({ + entryExports: { ...moduleExports, default: () => null }, + csfFiles: [csfFile], + }), + componentStoriesFromCSFFile: () => [attachedStory], + storyFromCSFFile: () => attachedStory, + projectAnnotations: { + parameters: { + docs: { + components: { Canvas: 'ProjectCanvas' }, + renderer: vi.fn(), + }, + }, + }, + } as unknown as StoryStore<Renderer>; + + const render = new MdxDocsRender( + new Channel({}), + store, + attachedEntry, + {} as RenderContextCallbacks<Renderer> + ); + await render.prepare(); + + await render.renderToElement({} as Renderer['canvasElement'], vi.fn()); + + expect(docsRenderer).toHaveBeenCalled(); + expect(renderPage).toHaveBeenCalledWith( + expect.objectContaining({ + storyById: expect.any(Function), + }), + expect.objectContaining({ + components: { Canvas: 'OverrideCanvas' }, + page: expect.any(Function), + }), + expect.anything() + ); + }); }); diff --git a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts index 809f8c19fe71..0839702cd4b2 100644 --- a/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts +++ b/code/core/src/preview-api/modules/preview-web/render/MdxDocsRender.ts @@ -1,10 +1,11 @@ import type { Channel } from 'storybook/internal/channels'; import { DOCS_RENDERED } from 'storybook/internal/core-events'; import type { Renderer, StoryId } from 'storybook/internal/types'; -import type { CSFFile, ModuleExports } from 'storybook/internal/types'; +import type { CSFFile, ModuleExports, PreparedStory } from 'storybook/internal/types'; import type { IndexEntry } from 'storybook/internal/types'; import type { RenderContextCallbacks } from 'storybook/internal/types'; +import { Tag } from '../../../../shared/constants/tags'; import type { StoryStore } from '../../store'; import { DocsContext } from '../docs-context/DocsContext'; import type { DocsContextProps } from '../docs-context/DocsContextProps'; @@ -46,6 +47,10 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender public csfFiles?: CSFFile<TRenderer>[]; + public attachedCsfFile?: CSFFile<TRenderer>; + + public attachedStory?: PreparedStory<TRenderer>; + constructor( protected channel: Channel, protected store: StoryStore<TRenderer>, @@ -70,6 +75,20 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender this.csfFiles = csfFiles; this.exports = entryExports; + this.attachedCsfFile = undefined; + this.attachedStory = undefined; + + if (this.entry.tags?.includes(Tag.ATTACHED_MDX)) { + this.attachedCsfFile = csfFiles[0]; + + const primaryStoryId = this.attachedCsfFile && Object.keys(this.attachedCsfFile.stories)[0]; + if (this.attachedCsfFile && primaryStoryId) { + this.attachedStory = this.store.storyFromCSFFile({ + storyId: primaryStoryId, + csfFile: this.attachedCsfFile, + }); + } + } this.preparing = false; } @@ -87,14 +106,18 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender throw new Error('Cannot render docs before preparing'); } - // NOTE we do *not* attach any CSF file yet. We wait for `referenceMeta(..., true)` - // ie the CSF file is attached via `<Meta of={} />` - return new DocsContext<TRenderer>( + const docsContext = new DocsContext<TRenderer>( this.channel, this.store, renderStoryToElement, this.csfFiles ); + + if (this.attachedCsfFile) { + docsContext.attachCSFFile(this.attachedCsfFile); + } + + return docsContext; } async renderToElement( @@ -108,15 +131,16 @@ export class MdxDocsRender<TRenderer extends Renderer> implements Render<TRender const docsContext = this.docsContext(renderStoryToElement); const { docs } = this.store.projectAnnotations.parameters ?? ({} as { docs: any }); + const baseDocsParameter = this.attachedStory?.parameters?.docs ?? docs; - if (!docs) { + if (!baseDocsParameter) { throw new Error( `Cannot render a story in viewMode=docs if \`@storybook/addon-docs\` is not installed` ); } - const docsParameter = { ...docs, page: this.exports.default }; - const renderer = await docs.renderer(); + const docsParameter = { ...baseDocsParameter, page: this.exports.default }; + const renderer = await baseDocsParameter.renderer(); const { render } = renderer as { render: DocsRenderFunction<TRenderer> }; const renderDocs = async () => { try {