From bddd57c10071fb3884dfc6454a2362bfb3fd3263 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 20 Feb 2026 15:08:27 +0100 Subject: [PATCH 01/44] UI: Fix code/copy buttons overlap with content --- .../src/blocks/components/Preview.stories.tsx | 13 +++++ .../docs/src/blocks/components/Preview.tsx | 15 ++++-- .../ActionBar/ActionBar.stories.tsx | 3 ++ .../components/ActionBar/ActionBar.tsx | 54 ++++++++++++------- .../syntaxhighlighter/syntaxhighlighter.tsx | 9 +++- 5 files changed, 69 insertions(+), 25 deletions(-) diff --git a/code/addons/docs/src/blocks/components/Preview.stories.tsx b/code/addons/docs/src/blocks/components/Preview.stories.tsx index 223efabeee51..a1c5108d234d 100644 --- a/code/addons/docs/src/blocks/components/Preview.stories.tsx +++ b/code/addons/docs/src/blocks/components/Preview.stories.tsx @@ -51,6 +51,19 @@ export const CodeError = () => ( ); +export const ActionBarWrapping = { + render: () => ( + + + + ), + globals: { + viewport: 'mobile1', + }, +}; + export const Single = () => ( + )} + {hasValidSource && ( + <> + setExpanded(!expanded)} + variant="ghost" + className={`docblock-code-toggle${expanded ? ' docblock-code-toggle--expanded' : ''}`} + > + {expanded ? 'Hide code' : 'Show code'} + + + + )} + {additionalActionItems.map(({ title, className, onClick, disabled }, index: number) => ( + + ))} + + )} + ); }; diff --git a/code/addons/docs/src/blocks/components/Source.tsx b/code/addons/docs/src/blocks/components/Source.tsx index 5b0e7b36037b..92f9a9c6360b 100644 --- a/code/addons/docs/src/blocks/components/Source.tsx +++ b/code/addons/docs/src/blocks/components/Source.tsx @@ -44,8 +44,8 @@ export interface SourceCodeProps { /** The formatter the syntax highlighter uses for your story’s code. */ format?: ComponentProps['format']; /** Display the source snippet in a dark mode. */ - dark?: boolean; -} + dark?: boolean; /** Whether to show the copy button. Defaults to true. */ + copyable?: boolean;} export interface SourceProps extends SourceCodeProps { isLoading?: boolean; @@ -91,6 +91,7 @@ const Source: FunctionComponent = ({ code, dark, format = true, + copyable = true, ...rest }) => { const { typography } = useTheme(); @@ -104,7 +105,7 @@ const Source: FunctionComponent = ({ const syntaxHighlighter = ( Date: Thu, 26 Feb 2026 17:06:15 +0100 Subject: [PATCH 03/44] UI: Remove toolbar padding for preview despite outline crop --- code/addons/docs/src/blocks/components/Preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index 331184b9f62d..303d1c81c7d4 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -224,7 +224,7 @@ export const Preview: FC = ({ )} {(withSource || additionalActionItems.length > 0) && ( - + {hasSourceError && ( )} diff --git a/code/addons/docs/src/blocks/components/Source.tsx b/code/addons/docs/src/blocks/components/Source.tsx index 92f9a9c6360b..164aadaf38cf 100644 --- a/code/addons/docs/src/blocks/components/Source.tsx +++ b/code/addons/docs/src/blocks/components/Source.tsx @@ -44,8 +44,10 @@ export interface SourceCodeProps { /** The formatter the syntax highlighter uses for your story’s code. */ format?: ComponentProps['format']; /** Display the source snippet in a dark mode. */ - dark?: boolean; /** Whether to show the copy button. Defaults to true. */ - copyable?: boolean;} + dark?: boolean; + /** Whether to show the copy button. Defaults to true. */ + copyable?: boolean; +} export interface SourceProps extends SourceCodeProps { isLoading?: boolean; From b0f94e71f164cfe7e0f03ab8841b2315475e6757 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 26 Feb 2026 17:25:15 +0100 Subject: [PATCH 05/44] Address PR review --- .../src/components/components/ActionBar/ActionBar.stories.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/code/core/src/components/components/ActionBar/ActionBar.stories.tsx b/code/core/src/components/components/ActionBar/ActionBar.stories.tsx index 5935b7bb7b81..8c3a04983b1d 100644 --- a/code/core/src/components/components/ActionBar/ActionBar.stories.tsx +++ b/code/core/src/components/components/ActionBar/ActionBar.stories.tsx @@ -27,9 +27,6 @@ export default { ), ], - args: { - sb11FlexLayout: true, - }, }; export const SingleItem = () => ; From 11ed0453ff77321da34432d6e00faca9c92e38bc Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 26 Feb 2026 17:31:33 +0100 Subject: [PATCH 06/44] Remove dead code --- code/addons/docs/src/blocks/components/Preview.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index c8341101df6a..609646f549e9 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -101,15 +101,11 @@ const StyledSource = styled(Source)(({ theme }) => ({ })); const PreviewContainer = styled.div( - ({ theme, withSource, isExpanded }) => ({ + ({ theme }) => ({ position: 'relative', overflow: 'hidden', margin: '25px 0 40px', ...getBlockBackgroundStyle(theme), - borderBottomLeftRadius: withSource && isExpanded ? 0 : undefined, - borderBottomRightRadius: withSource && isExpanded ? 0 : undefined, - borderBottomWidth: isExpanded ? 0 : undefined, - 'h3 + &': { marginTop: '16px', }, From f8dabbf2ea8d00665dcc49ebff89eb45b3f77c04 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 26 Feb 2026 17:44:17 +0100 Subject: [PATCH 07/44] Fix between-story spacing in Preview! --- code/addons/docs/src/blocks/components/Preview.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index 609646f549e9..bce5168efecf 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -81,6 +81,7 @@ const ChildrenContainer = styled.div( const ActionBar = styled(Bar)({ marginTop: -40, + marginBottom: 40, }); const StyledSource = styled(Source)(({ theme }) => ({ From 3bc11e10e8895169a34db3f49a79f7e9caa07412 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 26 Feb 2026 18:01:22 +0100 Subject: [PATCH 08/44] Try explicit value syntax for viewport global --- code/addons/docs/src/blocks/components/Preview.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/docs/src/blocks/components/Preview.stories.tsx b/code/addons/docs/src/blocks/components/Preview.stories.tsx index a1c5108d234d..d75bac0b374d 100644 --- a/code/addons/docs/src/blocks/components/Preview.stories.tsx +++ b/code/addons/docs/src/blocks/components/Preview.stories.tsx @@ -60,7 +60,7 @@ export const ActionBarWrapping = { ), globals: { - viewport: 'mobile1', + viewport: { value: 'mobile1' }, }, }; From 38b12c9575ffef6800e6426ceccf1decddc34f46 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 27 Feb 2026 09:44:58 +0100 Subject: [PATCH 09/44] UI: Repair docs-story class selector --- code/addons/docs/src/blocks/components/Preview.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index bce5168efecf..ceb904044056 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -208,6 +208,7 @@ export const Preview: FC = ({ columns={columns} layout={layout} inline={inline} + className="docs-story" > {Array.isArray(children) ? ( From 2f69d12d64e33b26083cd430c0c59bbad1c13d66 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 27 Feb 2026 12:47:36 +0100 Subject: [PATCH 10/44] UI: Repair docs-story decorators for primary stories --- .../src/blocks/examples/ButtonSomeAutodocs.stories.tsx | 5 +++++ code/core/src/backgrounds/decorator.ts | 10 ++++++++-- docs/_snippets/storybook-addon-use-global.md | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/code/addons/docs/src/blocks/examples/ButtonSomeAutodocs.stories.tsx b/code/addons/docs/src/blocks/examples/ButtonSomeAutodocs.stories.tsx index d691e2b8f91b..0a477e1d64c0 100644 --- a/code/addons/docs/src/blocks/examples/ButtonSomeAutodocs.stories.tsx +++ b/code/addons/docs/src/blocks/examples/ButtonSomeAutodocs.stories.tsx @@ -28,6 +28,11 @@ export const Secondary: Story = { args: { label: 'Button', }, + globals: { + backgrounds: { + grid: true, + }, + }, }; export const ForcedBgLight: Story = { diff --git a/code/core/src/backgrounds/decorator.ts b/code/core/src/backgrounds/decorator.ts index bb0d35de9cfc..a58e7e14cfb0 100644 --- a/code/core/src/backgrounds/decorator.ts +++ b/code/core/src/backgrounds/decorator.ts @@ -34,8 +34,14 @@ export const withBackgroundAndGrid: DecoratorFunction = (StoryFn, context) => { const showGrid = typeof data === 'string' ? false : data.grid || false; const shownBackground = !!item && !disable; - const backgroundSelector = viewMode === 'docs' ? `#anchor--${id} .docs-story` : '.sb-show-main'; - const gridSelector = viewMode === 'docs' ? `#anchor--${id} .docs-story` : '.sb-show-main'; + const backgroundSelector = + viewMode === 'docs' + ? `#anchor--${id} .docs-story, #anchor--primary--${id} .docs-story` + : '.sb-show-main'; + const gridSelector = + viewMode === 'docs' + ? `#anchor--${id} .docs-story, #anchor--primary--${id} .docs-story` + : '.sb-show-main'; const isLayoutPadded = parameters.layout === undefined || parameters.layout === 'padded'; const defaultOffset = viewMode === 'docs' ? 20 : isLayoutPadded ? 16 : 0; diff --git a/docs/_snippets/storybook-addon-use-global.md b/docs/_snippets/storybook-addon-use-global.md index dd12a1767c72..d1913cef1424 100644 --- a/docs/_snippets/storybook-addon-use-global.md +++ b/docs/_snippets/storybook-addon-use-global.md @@ -21,7 +21,7 @@ export const withGlobals = (StoryFn: StoryFunction, context: StoryCont const isInDocs = context.viewMode === 'docs'; const outlineStyles = useMemo(() => { - const selector = isInDocs ? `#anchor--${context.id} .docs-story` : '.sb-show-main'; + const selector = isInDocs ? `#anchor--${context.id} .docs-story, #anchor--primary--${context.id} .docs-story` : '.sb-show-main'; return outlineCSS(selector); }, [context.id]); From a4b57a6d06f26471e7b549c55a1ffd7a42fed3fd Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 16:53:45 +0100 Subject: [PATCH 11/44] UI: Handle kb nav edge cases when preview and panel are hidden --- .../src/manager/components/layout/Drag.tsx | 65 ++++++++ .../src/manager/components/layout/Layout.tsx | 140 +++--------------- .../components/layout/MainAreaContainer.tsx | 97 ++++++++++++ .../components/layout/PanelContainer.tsx | 62 ++++++++ .../manager/components/sidebar/Sidebar.tsx | 6 +- 5 files changed, 247 insertions(+), 123 deletions(-) create mode 100644 code/core/src/manager/components/layout/Drag.tsx create mode 100644 code/core/src/manager/components/layout/MainAreaContainer.tsx create mode 100644 code/core/src/manager/components/layout/PanelContainer.tsx diff --git a/code/core/src/manager/components/layout/Drag.tsx b/code/core/src/manager/components/layout/Drag.tsx new file mode 100644 index 000000000000..ba1fe4053d3d --- /dev/null +++ b/code/core/src/manager/components/layout/Drag.tsx @@ -0,0 +1,65 @@ +import { styled } from 'storybook/theming'; + +/** + * Drag handle for the sidebar and panel resizers. Can be horizontal (bottom panel) or vertical + * (sidebar or right panel). Can optionally be set to not overlap the content area (only render + * outside of it), which is necessary when the panel is collapsed to prevent a layout shift when + * scrollIntoView is used. + */ +export const Drag = styled.div<{ + orientation?: 'horizontal' | 'vertical'; + overlapping?: boolean; + position?: 'left' | 'right'; +}>( + ({ theme }) => ({ + position: 'absolute', + opacity: 0, + transition: 'opacity 0.2s ease-in-out', + zIndex: 100, + + '&:after': { + content: '""', + display: 'block', + backgroundColor: theme.color.secondary, + }, + + '&:hover': { + opacity: 1, + }, + }), + ({ orientation = 'vertical', overlapping = true, position = 'left' }) => + orientation === 'vertical' + ? { + width: overlapping ? (position === 'left' ? 10 : 13) : 7, + height: '100%', + top: 0, + right: position === 'left' ? -7 : undefined, + left: position === 'right' ? -7 : undefined, + + '&:after': { + width: 1, + height: '100%', + marginLeft: position === 'left' ? 3 : 6, + }, + + '&:hover': { + cursor: 'col-resize', + }, + } + : { + width: '100%', + height: overlapping ? 13 : 7, + top: -7, + left: 0, + + '&:after': { + width: '100%', + height: 1, + marginTop: 6, + }, + + '&:hover': { + cursor: 'row-resize', + }, + } +); diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 20c120169944..a5187c56b6ea 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -1,7 +1,6 @@ -import type { CSSProperties } from 'react'; +import type { CSSProperties, FC } from 'react'; import React, { useEffect, useLayoutEffect, useState } from 'react'; -import { Match } from 'storybook/internal/router'; import type { API_Layout, API_ViewMode } from 'storybook/internal/types'; import { type API, useStorybookApi } from 'storybook/manager-api'; @@ -10,7 +9,10 @@ import { styled } from 'storybook/theming'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; import { Notifications } from '../../container/Notifications'; import { MobileNavigation } from '../mobile/navigation/MobileNavigation'; +import { Drag } from './Drag'; import { useLayout } from './LayoutProvider'; +import { MainAreaContainer } from './MainAreaContainer'; +import { PanelContainer } from './PanelContainer'; import { useDragging } from './useDragging'; import { useLandmarkIndicator } from './useLandmarkIndicator'; @@ -104,7 +106,9 @@ const useLayoutSyncingState = ({ }, [internalDraggingSizeState, setManagerLayoutState]); const isPagesShown = - managerLayoutState.viewMode !== 'story' && managerLayoutState.viewMode !== 'docs'; + managerLayoutState.viewMode !== undefined && + managerLayoutState.viewMode !== 'story' && + managerLayoutState.viewMode !== 'docs'; const isPanelShown = managerLayoutState.viewMode === 'story' && !hasTab; const { panelResizerRef, sidebarResizerRef } = useDragging({ @@ -132,14 +136,6 @@ const useLayoutSyncingState = ({ }; }; -const MainContentMatcher = ({ children }: { children: React.ReactNode }) => { - return ( - - {({ match }) => {children}} - - ); -}; - const OrderedMobileNavigation = styled(MobileNavigation)({ order: 1, }); @@ -157,7 +153,6 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s sidebarResizerRef, showPages, showPanel, - isDragging, } = useLayoutSyncingState({ api, managerLayoutState, setManagerLayoutState, isDesktop, hasTab }); // Install landmark navigation listener in parent container of all landmarks. @@ -175,7 +170,6 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s } as CSSProperties } > - {showPages && {slots.slotPages}} <> {isDesktop && ( @@ -191,16 +185,19 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s /> )} - {slots.slotMain} + {isDesktop && showPanel && ( - - + {slots.slotPanel} )} @@ -249,104 +246,3 @@ const SidebarContainer = styled.div(({ theme }) => ({ position: 'relative', borderRight: `1px solid ${theme.appBorderColor}`, })); - -const ContentContainer = styled.div<{ shown: boolean }>(({ theme, shown }) => ({ - flex: 1, - position: 'relative', - backgroundColor: theme.appContentBg, - display: shown ? 'grid' : 'none', // This is needed to make the content container fill the available space - overflow: 'auto', - - [MEDIA_DESKTOP_BREAKPOINT]: { - flex: 'auto', - gridArea: 'content', - }, -})); - -const PagesContainer = styled.div(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - gridRowStart: 'sidebar-start', - gridRowEnd: '-1', - gridColumnStart: 'sidebar-end', - gridColumnEnd: '-1', - backgroundColor: theme.appContentBg, - zIndex: 1, -})); - -const PanelContainer = styled.div<{ position: LayoutState['panelPosition'] }>( - ({ theme, position }) => ({ - gridArea: 'panel', - position: 'relative', - backgroundColor: theme.appContentBg, - borderTop: position === 'bottom' ? `1px solid ${theme.appBorderColor}` : undefined, - borderLeft: position === 'right' ? `1px solid ${theme.appBorderColor}` : undefined, - '& > aside': { - overflow: 'hidden', - }, - }) -); - -/** - * Drag handle for the sidebar and panel resizers. Can be horizontal (bottom panel) or vertical - * (sidebar or right panel). Can optionally be set to not overlap the content area (only render - * outside of it), which is necessary when the panel is collapsed to prevent a layout shift when - * scrollIntoView is used. - */ -const Drag = styled.div<{ - orientation?: 'horizontal' | 'vertical'; - overlapping?: boolean; - position?: 'left' | 'right'; -}>( - ({ theme }) => ({ - position: 'absolute', - opacity: 0, - transition: 'opacity 0.2s ease-in-out', - zIndex: 100, - - '&:after': { - content: '""', - display: 'block', - backgroundColor: theme.color.secondary, - }, - - '&:hover': { - opacity: 1, - }, - }), - ({ orientation = 'vertical', overlapping = true, position = 'left' }) => - orientation === 'vertical' - ? { - width: overlapping ? (position === 'left' ? 10 : 13) : 7, - height: '100%', - top: 0, - right: position === 'left' ? -7 : undefined, - left: position === 'right' ? -7 : undefined, - - '&:after': { - width: 1, - height: '100%', - marginLeft: position === 'left' ? 3 : 6, - }, - - '&:hover': { - cursor: 'col-resize', - }, - } - : { - width: '100%', - height: overlapping ? 13 : 7, - top: -7, - left: 0, - - '&:after': { - width: '100%', - height: 1, - marginTop: 6, - }, - - '&:hover': { - cursor: 'row-resize', - }, - } -); diff --git a/code/core/src/manager/components/layout/MainAreaContainer.tsx b/code/core/src/manager/components/layout/MainAreaContainer.tsx new file mode 100644 index 000000000000..3cbc0f748796 --- /dev/null +++ b/code/core/src/manager/components/layout/MainAreaContainer.tsx @@ -0,0 +1,97 @@ +import React, { useRef } from 'react'; + +import { Match } from 'storybook/internal/router'; + +import { styled } from 'storybook/theming'; + +import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; +import { useLandmark } from '../../hooks/useLandmark'; + +interface PagesContainerProps { + children: React.ReactNode; +} + +const PagesInnerContainer = styled.main(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gridRowStart: 'sidebar-start', + gridRowEnd: '-1', + gridColumnStart: 'sidebar-end', + gridColumnEnd: '-1', + backgroundColor: theme.appContentBg, + zIndex: 1, +})); + +/** + * Shows Router-controlled pages (e.g. settings/about), inside a landmark for navigability. Assumes + * that the main preview area is not concurrently reachable by assistive technologies, since these + * components both define a `main` role. + */ +const PagesContainer = React.memo(function PagesContainer(props) { + const { children } = props; + + const mainRef = useRef(null); + const { landmarkProps } = useLandmark( + { 'aria-labelledby': 'main-content-heading', role: 'main' }, + mainRef + ); + + return ( + +

+ Main content +

+ {children} +
+ ); +}); + +const MainInnerContainer = styled.div<{ shown: boolean }>(({ theme, shown }) => ({ + flex: 1, + position: 'relative', + backgroundColor: theme.appContentBg, + display: shown ? 'grid' : 'none', // This is needed to make the content container fill the available space + overflow: 'auto', + + [MEDIA_DESKTOP_BREAKPOINT]: { + flex: 'auto', + gridArea: 'content', + }, +})); + +interface MainAreaContainerProps { + showPages: boolean; + slotMain: React.ReactNode; + slotPages: React.ReactNode; +} + +/** + * Shows Router-controlled pages (e.g. settings/about), inside a landmark for navigability. Assumes + * that the main preview area is not concurrently reachable by assistive technologies, since these + * components both define a `main` role. + */ +const MainAreaContainer = React.memo(function MainAreaContainer({ + showPages, + slotMain, + slotPages, +}) { + return ( + <> + {showPages && {slotPages}} + + {({ match }) => ( + + )} + + + ); +}); + +export { MainAreaContainer }; diff --git a/code/core/src/manager/components/layout/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx new file mode 100644 index 000000000000..ca08e7f50aef --- /dev/null +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { styled } from 'storybook/theming'; + +import type { API_Layout } from '../../../types'; +import { Drag } from './Drag'; + +interface PagesContainerProps { + children: React.ReactNode; + bottomPanelHeight: number; + rightPanelWidth: number; + panelResizerRef: React.Ref; + position: API_Layout['panelPosition']; +} + +const Container = styled.div<{ position: API_Layout['panelPosition'] }>(({ theme, position }) => ({ + gridArea: 'panel', + position: 'relative', + backgroundColor: theme.appContentBg, + borderTop: position === 'bottom' ? `1px solid ${theme.appBorderColor}` : undefined, + borderLeft: position === 'right' ? `1px solid ${theme.appBorderColor}` : undefined, + '& > aside': { + overflow: 'hidden', + }, +})); + +const PanelSlot = styled.div({ + height: '100%', +}); + +/** + * Shows the addon panel and its resize drag handle. The drag handle is always rendered so users can + * reopen the panel. The panel is always rendered (to preserve internal state), but it's excluded + * from the Accessibility Object Model when effectively collapsed. + */ +const PanelContainer = React.memo(function PagesContainer(props) { + const { children, bottomPanelHeight, rightPanelWidth, panelResizerRef, position } = props; + + const shouldHidePanelContent = + position === 'bottom' ? bottomPanelHeight === 0 : rightPanelWidth === 0; + + return ( + + + + + ); +}); + +export { PanelContainer }; diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index 4b5798198f63..6981f8701ec4 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -128,6 +128,7 @@ export const Sidebar = React.memo(function Sidebar({ const lastViewedProps = useLastViewed(selected); const { isMobile } = useLayout(); const api = useStorybookApi(); + const { viewMode } = api.getUrlState(); const tagPresets = useMemo( () => @@ -145,6 +146,9 @@ export const Sidebar = React.memo(function Sidebar({ headerRef ); + const isPagesShown = viewMode !== undefined && viewMode !== 'story' && viewMode !== 'docs'; + const skipLinkHref = isPagesShown ? '#main-content-wrapper' : '#storybook-preview-wrapper'; + return (

@@ -158,7 +162,7 @@ export const Sidebar = React.memo(function Sidebar({ className="sidebar-header" menuHighlighted={menuHighlighted} menu={menu} - skipLinkHref="#storybook-preview-wrapper" + skipLinkHref={skipLinkHref} isLoading={isLoading} onMenuClick={onMenuClick} /> From 3c1790b328cb094eac5220afca00ba7518c8f1bb Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 19 Jan 2026 17:23:25 +0100 Subject: [PATCH 12/44] UI: Ensure a single main landmark is rendered --- .../components/layout/Layout.stories.tsx | 68 +++++++++++++++++-- .../components/layout/MainAreaContainer.tsx | 25 +++---- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/code/core/src/manager/components/layout/Layout.stories.tsx b/code/core/src/manager/components/layout/Layout.stories.tsx index 2173924c035f..a56f6a548111 100644 --- a/code/core/src/manager/components/layout/Layout.stories.tsx +++ b/code/core/src/manager/components/layout/Layout.stories.tsx @@ -8,7 +8,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { startCase } from 'es-toolkit/string'; import { action } from 'storybook/actions'; import { ManagerContext } from 'storybook/manager-api'; -import { fn } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; import { styled } from 'storybook/theming'; import { isChromatic } from '../../../../../.storybook/isChromatic'; @@ -24,7 +24,7 @@ const PlaceholderBlock = styled.div({ overflow: 'hidden', }); -const PlaceholderClock: FC = ({ children }) => { +const PlaceholderClock: FC<{ id: string } & PropsWithChildren> = ({ children, id }) => { const [count, setCount] = React.useState(0); React.useEffect(() => { if (isChromatic()) { @@ -38,18 +38,21 @@ const PlaceholderClock: FC = ({ children }) => { return (

{count}

+ {children}
); }; -const MockSidebar: FC = () => ; +const MockSidebar: FC = () => ; -const MockPreview: FC = () => ; +const MockPreview: FC = () => ; -const MockPanel: FC = () => ; +const MockPanel: FC = () => ; -const MockPage: FC = () => ; +const MockPage: FC = () => ; const defaultState = { navSize: 150, @@ -145,18 +148,71 @@ export const DesktopHorizontal: Story = { args: { managerLayoutState: { ...defaultState, panelPosition: 'right' }, }, + play: async ({ canvas, step }) => { + await step('Verify preview can be focused', async () => { + const preview = canvas.getByTestId('preview'); + preview.focus(); + expect(preview).toHaveFocus(); + }); + await step('Verify panel can be focused', async () => { + const panel = canvas.getByTestId('panel'); + panel.focus(); + expect(panel).toHaveFocus(); + }); + }, +}; + +export const DesktopCollapsedPanel: Story = { + args: { + managerLayoutState: { ...defaultState, bottomPanelHeight: 0 }, + }, + play: async ({ canvas, step }) => { + await step('Verify panel is rendered but collapsed', async () => { + const panel = canvas.getByTestId('panel'); + expect(panel.clientHeight).toBe(0); + }); + + await step('Verify panel cannot be focused', async () => { + const panel = canvas.getByTestId('panel'); + panel.focus(); + expect(panel).not.toHaveFocus(); + }); + }, }; export const DesktopDocs: Story = { args: { managerLayoutState: { ...defaultState, viewMode: 'docs' }, }, + play: async ({ canvas, step }) => { + await step('Verify pages main landmark is not rendered', async () => { + const pagesMain = canvas.queryByRole('main', { name: 'Main content' }); + expect(pagesMain).not.toBeInTheDocument(); + }); + await step('Verify preview area is rendered', async () => { + const preview = canvas.getByTestId('preview'); + expect(preview).toBeInTheDocument(); + }); + }, }; export const DesktopPages: Story = { args: { managerLayoutState: { ...defaultState, viewMode: 'settings' }, }, + play: async ({ canvas, step }) => { + await step('Verify pages main landmark is rendered', async () => { + const pagesMain = canvas.queryByRole('main', { name: 'Main content' }); + expect(pagesMain).toBeInTheDocument(); + const page = canvas.getByTestId('page'); + page.focus(); + expect(page).toHaveFocus(); + }); + await step('Verify preview area is not rendered', async () => { + const preview = canvas.queryByTestId('preview'); + expect(preview).not.toBeInTheDocument(); + }); + }, }; export const Mobile = { diff --git a/code/core/src/manager/components/layout/MainAreaContainer.tsx b/code/core/src/manager/components/layout/MainAreaContainer.tsx index 3cbc0f748796..401283fd529a 100644 --- a/code/core/src/manager/components/layout/MainAreaContainer.tsx +++ b/code/core/src/manager/components/layout/MainAreaContainer.tsx @@ -66,9 +66,8 @@ interface MainAreaContainerProps { } /** - * Shows Router-controlled pages (e.g. settings/about), inside a landmark for navigability. Assumes - * that the main preview area is not concurrently reachable by assistive technologies, since these - * components both define a `main` role. + * Shows Router-controlled pages (e.g. settings/about), inside a landmark for navigability, OR shows + * the preview area. Ensures a single `main` landmark exists at a time. */ const MainAreaContainer = React.memo(function MainAreaContainer({ showPages, @@ -77,19 +76,13 @@ const MainAreaContainer = React.memo(function MainAreaCo }) { return ( <> - {showPages && {slotPages}} - - {({ match }) => ( - - )} - + {showPages ? ( + {slotPages} + ) : ( + + {({ match }) => {slotMain}} + + )} ); }); From a3d69cd35e692e6350aeabe69c89ec8e35347071 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Wed, 21 Jan 2026 12:25:59 +0100 Subject: [PATCH 13/44] refactor: Rename internal ifaces and functions --- code/core/src/manager/components/layout/PanelContainer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager/components/layout/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index ca08e7f50aef..033026afb6f8 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -5,7 +5,7 @@ import { styled } from 'storybook/theming'; import type { API_Layout } from '../../../types'; import { Drag } from './Drag'; -interface PagesContainerProps { +interface PanelContainerProps { children: React.ReactNode; bottomPanelHeight: number; rightPanelWidth: number; @@ -33,7 +33,7 @@ const PanelSlot = styled.div({ * reopen the panel. The panel is always rendered (to preserve internal state), but it's excluded * from the Accessibility Object Model when effectively collapsed. */ -const PanelContainer = React.memo(function PagesContainer(props) { +const PanelContainer = React.memo(function PanelContainer(props) { const { children, bottomPanelHeight, rightPanelWidth, panelResizerRef, position } = props; const shouldHidePanelContent = From 9b5d17ea89131eb637e5171f25464e13ae776705 Mon Sep 17 00:00:00 2001 From: Maks Pikov Date: Mon, 2 Mar 2026 22:10:09 +0000 Subject: [PATCH 14/44] Actions: add expandLevel parameter to configure tree depth Fixes #22390 The react-inspector's `Inspector` component supports an `expandLevel` prop that controls how deep the action tree is initially expanded. This makes it configurable via story parameters. Example usage: ```ts const meta = { parameters: { actions: { expandLevel: 2 }, }, }; ``` --- .../actions/components/ActionLogger/index.tsx | 5 ++++- .../actions/containers/ActionLogger/index.tsx | 17 ++++++++++++++--- code/core/src/actions/types.ts | 7 +++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/code/core/src/actions/components/ActionLogger/index.tsx b/code/core/src/actions/components/ActionLogger/index.tsx index 1da7339e09c8..4ab90a5b9130 100644 --- a/code/core/src/actions/components/ActionLogger/index.tsx +++ b/code/core/src/actions/components/ActionLogger/index.tsx @@ -30,6 +30,7 @@ interface InspectorProps { showNonenumerable: boolean; name: any; data: any; + expandLevel?: number; } const ThemedInspector = withTheme(({ theme, ...props }: InspectorProps) => ( @@ -38,10 +39,11 @@ const ThemedInspector = withTheme(({ theme, ...props }: InspectorProps) => ( interface ActionLoggerProps { actions: ActionDisplay[]; + expandLevel?: number; onClear: () => void; } -export const ActionLogger = ({ actions, onClear }: ActionLoggerProps) => { +export const ActionLogger = ({ actions, expandLevel, onClear }: ActionLoggerProps) => { const wrapperRef = useRef>(null); const wrapper = wrapperRef.current; const wasAtBottom = wrapper && wrapper.scrollHeight - wrapper.scrollTop === wrapper.clientHeight; @@ -67,6 +69,7 @@ export const ActionLogger = ({ actions, onClear }: ActionLoggerProps) => { showNonenumerable={false} name={action.data.name} data={action.data.args ?? action.data} + expandLevel={expandLevel} /> diff --git a/code/core/src/actions/containers/ActionLogger/index.tsx b/code/core/src/actions/containers/ActionLogger/index.tsx index e8ae2bab4566..3ba261cda41b 100644 --- a/code/core/src/actions/containers/ActionLogger/index.tsx +++ b/code/core/src/actions/containers/ActionLogger/index.tsx @@ -6,8 +6,9 @@ import { dequal as deepEqual } from 'dequal'; import type { API } from 'storybook/manager-api'; import { ActionLogger as ActionLoggerComponent } from '../../components/ActionLogger'; -import { CLEAR_ID, EVENT_ID } from '../../constants'; +import { CLEAR_ID, EVENT_ID, PARAM_KEY } from '../../constants'; import type { ActionDisplay } from '../../models'; +import type { ActionsParameters } from '../../types'; interface ActionLoggerProps { active: boolean; @@ -16,6 +17,7 @@ interface ActionLoggerProps { interface ActionLoggerState { actions: ActionDisplay[]; + expandLevel: number; } const safeDeepEqual = (a: any, b: any): boolean => { @@ -34,7 +36,7 @@ export default class ActionLogger extends Component(PARAM_KEY)?.expandLevel ?? 1; + this.setState({ expandLevel }); } override componentWillUnmount() { @@ -54,10 +60,14 @@ export default class ActionLogger extends Component { + const { api } = this.props; const { actions } = this.state; if (actions.length > 0 && actions[0].options.clearOnStoryChange) { this.clearActions(); } + const expandLevel = + api.getCurrentParameter(PARAM_KEY)?.expandLevel ?? 1; + this.setState({ expandLevel }); }; addAction = (action: ActionDisplay) => { @@ -83,10 +93,11 @@ export default class ActionLogger extends Component : null; diff --git a/code/core/src/actions/types.ts b/code/core/src/actions/types.ts index 72d6fe17f950..c45588f6ab20 100644 --- a/code/core/src/actions/types.ts +++ b/code/core/src/actions/types.ts @@ -38,6 +38,13 @@ export interface ActionsParameters { * @example `handles: ['mouseover', 'click .btn']` */ handles?: string[]; + + /** + * An integer specifying to which level the tree should be initially expanded. + * + * @default 1 + */ + expandLevel?: number; }; } From 658d712539144cf32072b0b0fee661dcf4e22a8d Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 3 Mar 2026 10:36:24 +0100 Subject: [PATCH 15/44] style: Clean up dead code and formatting --- code/core/src/manager/components/layout/Layout.stories.tsx | 2 +- code/core/src/manager/components/layout/PanelContainer.tsx | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/code/core/src/manager/components/layout/Layout.stories.tsx b/code/core/src/manager/components/layout/Layout.stories.tsx index a56f6a548111..01cb90d92066 100644 --- a/code/core/src/manager/components/layout/Layout.stories.tsx +++ b/code/core/src/manager/components/layout/Layout.stories.tsx @@ -189,7 +189,7 @@ export const DesktopDocs: Story = { const pagesMain = canvas.queryByRole('main', { name: 'Main content' }); expect(pagesMain).not.toBeInTheDocument(); }); - await step('Verify preview area is rendered', async () => { + await step('Verify preview area is rendered', async () => { const preview = canvas.getByTestId('preview'); expect(preview).toBeInTheDocument(); }); diff --git a/code/core/src/manager/components/layout/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index 033026afb6f8..9ddce06d569e 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -19,9 +19,6 @@ const Container = styled.div<{ position: API_Layout['panelPosition'] }>(({ theme backgroundColor: theme.appContentBg, borderTop: position === 'bottom' ? `1px solid ${theme.appBorderColor}` : undefined, borderLeft: position === 'right' ? `1px solid ${theme.appBorderColor}` : undefined, - '& > aside': { - overflow: 'hidden', - }, })); const PanelSlot = styled.div({ From 77df96b34864d657506be841b08804d8b5b9090d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:24:31 +0000 Subject: [PATCH 16/44] fix: move focus to 'Show addon panel' button after closing the addon panel When the addon panel is closed via the close button or keyboard shortcut (Alt+A), focus is now moved to the "Show addon panel" button in the toolbar. This uses the existing focusOnUIElement polling mechanism to handle the timing of React re-renders. Fixes broken tab navigation after closing the addon panel. Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/manager-api/modules/layout.ts | 9 +++++++++ code/core/src/manager-api/modules/shortcuts.ts | 4 ++++ .../core/src/manager/components/preview/tools/addons.tsx | 3 +++ code/core/src/manager/container/Panel.tsx | 8 +++++++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index 27ed403409da..9350bf395a2e 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -102,6 +102,14 @@ export interface SubAPI { * account customisations requested by the end user via a layoutCustomisations function. */ getNavSizeWithCustomisations: (navSize: number) => number; + /** + * Attempts to focus an element identified by its ID. Polls for the element for up to 500ms to + * handle cases where the element may not yet be rendered. + * + * @param elementId - The id of the element to focus. + * @param select - Whether to call select() on the element after focusing it. + */ + focusOnUIElement: (elementId?: string, select?: boolean) => void; } type PartialSubState = Partial; @@ -136,6 +144,7 @@ export const focusableUIElements = { storySearchField: 'storybook-explorer-searchfield', storyListMenu: 'storybook-explorer-menu', storyPanelRoot: 'storybook-panel-root', + showAddonPanel: 'storybook-show-addon-panel', }; const getIsNavShown = (state: State) => { diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index e0dc374bea16..1171be3b206e 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -358,7 +358,11 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } case 'togglePanel': { + const wasPanelShown = fullAPI.getIsPanelShown(); fullAPI.togglePanel(); + if (wasPanelShown) { + fullAPI.focusOnUIElement(focusableUIElements.showAddonPanel); + } break; } diff --git a/code/core/src/manager/components/preview/tools/addons.tsx b/code/core/src/manager/components/preview/tools/addons.tsx index 2a879f700f93..8ac3482746f6 100644 --- a/code/core/src/manager/components/preview/tools/addons.tsx +++ b/code/core/src/manager/components/preview/tools/addons.tsx @@ -8,6 +8,8 @@ import { BottomBarIcon, SidebarAltIcon } from '@storybook/icons'; import { Consumer, types } from 'storybook/manager-api'; import type { Combo } from 'storybook/manager-api'; +const SHOW_ADDON_PANEL_BUTTON_ID = 'storybook-show-addon-panel'; + const menuMapper = ({ api, state }: Combo) => ({ isVisible: api.getIsPanelShown(), singleStory: state.singleStory, @@ -30,6 +32,7 @@ export const addonsTool: Addon_BaseType = { padding="small" variant="ghost" ariaLabel="Show addon panel" + id={SHOW_ADDON_PANEL_BUTTON_ID} key="addons" onClick={toggle} > diff --git a/code/core/src/manager/container/Panel.tsx b/code/core/src/manager/container/Panel.tsx index d4ea77b813fc..837a16d2cb6b 100644 --- a/code/core/src/manager/container/Panel.tsx +++ b/code/core/src/manager/container/Panel.tsx @@ -27,7 +27,13 @@ const Panel: FC = (props) => { const panelActions = useMemo( () => ({ onSelect: (panel: string) => api.setSelectedPanel(panel), - toggleVisibility: () => api.togglePanel(), + toggleVisibility: () => { + const wasPanelShown = api.getIsPanelShown(); + api.togglePanel(); + if (wasPanelShown) { + api.focusOnUIElement('storybook-show-addon-panel'); + } + }, togglePosition: () => api.togglePanelPosition(), }), [api] From d06356ded15c464109c8473202a7e5ab933c1090 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:01:00 +0000 Subject: [PATCH 17/44] refactor: address review feedback for focusOnUIElement - Make focusOnUIElement polling optional via { poll: false } option - Return boolean (sync) or Promise (when polling) for success - Maintain backwards compat with boolean 2nd arg for select option - Only move focus when it was within the panel (shortcuts.ts) - Fall back to document.body.focus() when showAddonPanel button unavailable - Use focusableUIElements.showAddonPanel constant in container/Panel.tsx - Add 10 unit tests for focusOnUIElement covering all code paths Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/manager-api/modules/layout.ts | 74 +++++---- .../core/src/manager-api/modules/shortcuts.ts | 14 +- .../core/src/manager-api/tests/layout.test.ts | 140 +++++++++++++++++- code/core/src/manager/container/Panel.tsx | 6 +- 4 files changed, 204 insertions(+), 30 deletions(-) diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index 9350bf395a2e..a936a9ca91c2 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -103,13 +103,19 @@ export interface SubAPI { */ getNavSizeWithCustomisations: (navSize: number) => number; /** - * Attempts to focus an element identified by its ID. Polls for the element for up to 500ms to - * handle cases where the element may not yet be rendered. + * Attempts to focus an element identified by its ID. * * @param elementId - The id of the element to focus. - * @param select - Whether to call select() on the element after focusing it. + * @param options - Options for focusing the element. + * @param options.select - Whether to call select() on the element after focusing it. + * @param options.poll - Whether to poll for the element if it is not immediately available. + * Defaults to true. When true, polls every 50ms for up to 500ms. + * @returns Whether the element was successfully focused. Returns a Promise when polling. */ - focusOnUIElement: (elementId?: string, select?: boolean) => void; + focusOnUIElement: ( + elementId?: string, + options?: boolean | { select?: boolean; poll?: boolean } + ) => boolean | Promise; } type PartialSubState = Partial; @@ -339,25 +345,29 @@ export const init: ModuleFn = ({ store, provider, singleStory /** * Attempts to focus (and select) an element identified by its ID. It is the responsibility of * the callee to ensure that the element is present in the DOM and that no focus trap is - * available. This API polls and attempts to perform the focus for a set duration (max 500ms), - * so that race conditions can be avoided with the current API design. Because this API is - * historically synchronous, it cannot report errors or failure to focus. It fails silently. + * available. When polling is enabled, this API polls and attempts to perform the focus for a + * set duration (max 500ms), so that race conditions can be avoided with the current API + * design. * * @param elementId The id of the element to focus. - * @param select Whether to call select() on the element after focusing it. + * @param options When a boolean, treated as the `select` option for backwards compatibility. + * When an object, may contain `select` and `poll` options. + * @returns Whether the element was successfully focused. Returns a Promise when polling. */ - focusOnUIElement(elementId?: string, select?: boolean) { + focusOnUIElement( + elementId?: string, + options?: boolean | { select?: boolean; poll?: boolean } + ): boolean | Promise { // See RFC https://github.com/storybookjs/storybook/discussions/32983 for // ways to make this API more robust to focus-trap race conditions. + const { select = false, poll = true } = + typeof options === 'boolean' ? { select: options } : (options ?? {}); + if (!elementId) { - return; + return false; } - const startTime = Date.now(); - const maxDuration = 500; - const pollInterval = 50; - const attemptFocus = () => { const element = document.getElementById(elementId); if (!element) { @@ -376,22 +386,34 @@ export const init: ModuleFn = ({ store, provider, singleStory }; if (attemptFocus()) { - return; + return true; } - // Poll every 50ms for up to 500ms to account for race conditions. - const intervalId = setInterval(() => { - const elapsed = Date.now() - startTime; + if (!poll) { + return false; + } - if (elapsed >= maxDuration) { - clearInterval(intervalId); - return; - } + // Poll every 50ms for up to 500ms to account for race conditions. + return new Promise((resolve) => { + const startTime = Date.now(); + const maxDuration = 500; + const pollInterval = 50; + + const intervalId = setInterval(() => { + const elapsed = Date.now() - startTime; + + if (attemptFocus()) { + clearInterval(intervalId); + resolve(true); + return; + } - if (attemptFocus()) { - clearInterval(intervalId); - } - }, pollInterval); + if (elapsed >= maxDuration) { + clearInterval(intervalId); + resolve(false); + } + }, pollInterval); + }); }, getInitialOptions() { diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index 1171be3b206e..a1b80d227786 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -359,9 +359,19 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { case 'togglePanel': { const wasPanelShown = fullAPI.getIsPanelShown(); + const panelElement = document.getElementById(focusableUIElements.storyPanelRoot); + const wasFocusInPanel = + panelElement && document.activeElement && panelElement.contains(document.activeElement); + fullAPI.togglePanel(); - if (wasPanelShown) { - fullAPI.focusOnUIElement(focusableUIElements.showAddonPanel); + + if (wasPanelShown && wasFocusInPanel) { + const result = fullAPI.focusOnUIElement(focusableUIElements.showAddonPanel, { + poll: false, + }); + if (result === false) { + document.body.focus(); + } } break; } diff --git a/code/core/src/manager-api/tests/layout.test.ts b/code/core/src/manager-api/tests/layout.test.ts index f2c8f80c032b..0c382d586455 100644 --- a/code/core/src/manager-api/tests/layout.test.ts +++ b/code/core/src/manager-api/tests/layout.test.ts @@ -1,5 +1,5 @@ import type { Mock } from 'vitest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { API_Provider } from 'storybook/internal/types'; @@ -540,4 +540,142 @@ describe('layout API', () => { expect(layoutApi.getIsFullscreen()).toBe(false); }); }); + + describe('focusOnUIElement', () => { + let mockActiveElement: any; + let mockGetElementById: ReturnType; + let focusLayoutApi: SubAPI; + + beforeEach(async () => { + mockActiveElement = null; + mockGetElementById = vi.fn().mockReturnValue(null); + + // Set up mock document on globalThis before re-importing layout module. + // @storybook/global resolves to globalThis in Node, so the layout module's + // `const { document } = global;` will capture this mock. + (globalThis as any).document = { + getElementById: mockGetElementById, + get activeElement() { + return mockActiveElement; + }, + }; + + // Re-import the layout module so it captures our mock document + vi.resetModules(); + const { init: freshInit } = await import('../modules/layout'); + focusLayoutApi = freshInit({ + store, + provider, + singleStory: false, + } as unknown as ModuleArgs).api; + }); + + afterEach(() => { + delete (globalThis as any).document; + vi.restoreAllMocks(); + }); + + const createMockElement = (id: string) => { + const element = { + id, + focus: vi.fn(() => { + mockActiveElement = element; + }), + select: vi.fn(), + }; + mockGetElementById.mockImplementation((queryId: string) => (queryId === id ? element : null)); + return element; + }; + + it('should return false when elementId is not provided', () => { + const result = focusLayoutApi.focusOnUIElement(); + expect(result).toBe(false); + }); + + it('should return false when elementId is undefined', () => { + const result = focusLayoutApi.focusOnUIElement(undefined); + expect(result).toBe(false); + }); + + it('should return true and focus element when element exists', () => { + const element = createMockElement('test-element'); + const result = focusLayoutApi.focusOnUIElement('test-element'); + expect(result).toBe(true); + expect(element.focus).toHaveBeenCalled(); + }); + + it('should return true and call select when select option is true (boolean form)', () => { + const element = createMockElement('test-element'); + const result = focusLayoutApi.focusOnUIElement('test-element', true); + expect(result).toBe(true); + expect(element.focus).toHaveBeenCalled(); + expect(element.select).toHaveBeenCalled(); + }); + + it('should return true and call select when select option is true (object form)', () => { + const element = createMockElement('test-element'); + const result = focusLayoutApi.focusOnUIElement('test-element', { select: true }); + expect(result).toBe(true); + expect(element.focus).toHaveBeenCalled(); + expect(element.select).toHaveBeenCalled(); + }); + + it('should not call select when select option is false', () => { + const element = createMockElement('test-element'); + const result = focusLayoutApi.focusOnUIElement('test-element', { select: false }); + expect(result).toBe(true); + expect(element.focus).toHaveBeenCalled(); + expect(element.select).not.toHaveBeenCalled(); + }); + + it('should return false without polling when element does not exist and poll is false', () => { + const result = focusLayoutApi.focusOnUIElement('nonexistent-element', { poll: false }); + expect(result).toBe(false); + }); + + it('should return a Promise when element does not exist and poll is true (default)', () => { + const result = focusLayoutApi.focusOnUIElement('nonexistent-element'); + expect(result).toBeInstanceOf(Promise); + }); + + it('should resolve to true when element appears during polling', async () => { + vi.useFakeTimers(); + + const element = { + id: 'delayed-element', + focus: vi.fn(), + select: vi.fn(), + }; + + // Element not available initially + const result = focusLayoutApi.focusOnUIElement('delayed-element'); + expect(result).toBeInstanceOf(Promise); + + // Make element available and focusable + mockGetElementById.mockImplementation((id: string) => + id === 'delayed-element' ? element : null + ); + element.focus.mockImplementation(() => { + mockActiveElement = element; + }); + + await vi.advanceTimersByTimeAsync(150); + await expect(result).resolves.toBe(true); + expect(element.focus).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should resolve to false when element never appears during polling', async () => { + vi.useFakeTimers(); + + const result = focusLayoutApi.focusOnUIElement('never-appears'); + expect(result).toBeInstanceOf(Promise); + + await vi.advanceTimersByTimeAsync(600); + await expect(result).resolves.toBe(false); + + vi.useRealTimers(); + }); + }); }); diff --git a/code/core/src/manager/container/Panel.tsx b/code/core/src/manager/container/Panel.tsx index 837a16d2cb6b..f9a77a7ff6ac 100644 --- a/code/core/src/manager/container/Panel.tsx +++ b/code/core/src/manager/container/Panel.tsx @@ -6,6 +6,7 @@ import { Addon_TypesEnum } from 'storybook/internal/types'; import { useChannel, useStorybookApi, useStorybookState } from 'storybook/manager-api'; import { STORY_PREPARED } from '../../core-events'; +import { focusableUIElements } from '../../manager-api/modules/layout'; import { AddonPanel } from '../components/panel/Panel'; const Panel: FC = (props) => { @@ -31,7 +32,10 @@ const Panel: FC = (props) => { const wasPanelShown = api.getIsPanelShown(); api.togglePanel(); if (wasPanelShown) { - api.focusOnUIElement('storybook-show-addon-panel'); + const result = api.focusOnUIElement(focusableUIElements.showAddonPanel, { poll: false }); + if (result === false) { + document.body.focus(); + } } }, togglePosition: () => api.togglePanelPosition(), From 0b508dc93be97f4a5fe4955c2e2a1d2b85625f76 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 3 Mar 2026 16:37:42 +0100 Subject: [PATCH 18/44] Finish feature --- code/core/src/manager-api/modules/layout.ts | 24 +++++-- .../core/src/manager-api/modules/shortcuts.ts | 4 +- .../src/manager/components/layout/Layout.tsx | 4 +- .../components/layout/useLandmarkIndicator.ts | 64 +++++++++++------- .../src/manager/components/panel/Panel.tsx | 2 +- .../components/preview/tools/addons.tsx | 66 ++++++++++++------- code/core/src/manager/container/Panel.tsx | 2 +- 7 files changed, 109 insertions(+), 57 deletions(-) diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index a936a9ca91c2..a0df07067e5b 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -107,6 +107,7 @@ export interface SubAPI { * * @param elementId - The id of the element to focus. * @param options - Options for focusing the element. + * @param options.forceFocus - Whether to make the element focusable even though it wasn't. * @param options.select - Whether to call select() on the element after focusing it. * @param options.poll - Whether to poll for the element if it is not immediately available. * Defaults to true. When true, polls every 50ms for up to 500ms. @@ -114,7 +115,7 @@ export interface SubAPI { */ focusOnUIElement: ( elementId?: string, - options?: boolean | { select?: boolean; poll?: boolean } + options?: boolean | { forceFocus?: boolean; select?: boolean; poll?: boolean } ) => boolean | Promise; } @@ -147,6 +148,7 @@ export const defaultLayoutState: SubState = { }; export const focusableUIElements = { + addonPanel: 'storybook-panel-region', storySearchField: 'storybook-explorer-searchfield', storyListMenu: 'storybook-explorer-menu', storyPanelRoot: 'storybook-panel-root', @@ -356,13 +358,16 @@ export const init: ModuleFn = ({ store, provider, singleStory */ focusOnUIElement( elementId?: string, - options?: boolean | { select?: boolean; poll?: boolean } + options?: boolean | { forceFocus?: boolean; select?: boolean; poll?: boolean } ): boolean | Promise { // See RFC https://github.com/storybookjs/storybook/discussions/32983 for // ways to make this API more robust to focus-trap race conditions. - const { select = false, poll = true } = - typeof options === 'boolean' ? { select: options } : (options ?? {}); + const { + forceFocus = false, + select = false, + poll = true, + } = typeof options === 'boolean' ? { select: options } : (options ?? {}); if (!elementId) { return false; @@ -375,7 +380,16 @@ export const init: ModuleFn = ({ store, provider, singleStory } element.focus(); - if (element !== document.activeElement) { + if ( + element !== document.activeElement && + forceFocus && + element.getAttribute('tabindex') === null + ) { + element.setAttribute('tabindex', '-1'); + element.focus(); + } + + if (element !== document.activeElement && element.id !== document.activeElement?.id) { return false; } diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index a1b80d227786..8e1593b5c699 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -366,9 +366,7 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { fullAPI.togglePanel(); if (wasPanelShown && wasFocusInPanel) { - const result = fullAPI.focusOnUIElement(focusableUIElements.showAddonPanel, { - poll: false, - }); + const result = fullAPI.focusOnUIElement(focusableUIElements.showAddonPanel); if (result === false) { document.body.focus(); } diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 20c120169944..e52564bd75e9 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -127,7 +127,9 @@ const useLayoutSyncingState = ({ panelResizerRef, sidebarResizerRef, showPages: isPagesShown, - showPanel: customisedShowPanel, + showPanel: + customisedShowPanel && + (managerLayoutState.panelPosition === 'right' ? rightPanelWidth > 0 : bottomPanelHeight > 0), isDragging: internalDraggingSizeState.isDragging, }; }; diff --git a/code/core/src/manager/components/layout/useLandmarkIndicator.ts b/code/core/src/manager/components/layout/useLandmarkIndicator.ts index 7226ef483511..497a1f3b5130 100644 --- a/code/core/src/manager/components/layout/useLandmarkIndicator.ts +++ b/code/core/src/manager/components/layout/useLandmarkIndicator.ts @@ -19,14 +19,49 @@ function findActiveLandmarkElement() { return landmarkElement; } +export function useRegionFocusAnimation() { + const theme = useTheme(); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const currentAnimationRef = useRef(null); + + const animateLandmark = (elementToAnimate: HTMLElement | null) => { + if (!elementToAnimate) { + return; + } + + // Cancel previous landmark animation if user switches fast. + if (currentAnimationRef.current) { + currentAnimationRef.current.cancel(); + currentAnimationRef.current = null; + } + + if (!reducedMotion) { + const animation = elementToAnimate.animate( + [{ border: `2px solid ${theme.color.primary}` }, { border: `2px solid transparent` }], + { + duration: 1500, + pseudoElement: '::after', + } + ); + currentAnimationRef.current = animation; + + animation.onfinish = () => { + currentAnimationRef.current = null; + }; + } + }; + + return animateLandmark; +} + // Global keyboard handler for F6/Shift+F6 landmark navigation that // highlights the landmark containing the current element. This helps // users who navigate through landmark shortcuts more quickly visualise // which region of the UI they landed into. +// Call this once at the app root. export function useLandmarkIndicator() { - const theme = useTheme(); - const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); - const currentAnimationRef = useRef(null); + const animateLandmark = useRegionFocusAnimation(); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'F6') { @@ -38,31 +73,12 @@ export function useLandmarkIndicator() { return; } - // Cancel previous landmark animation if user switches fast. - if (currentAnimationRef.current) { - currentAnimationRef.current.cancel(); - currentAnimationRef.current = null; - } - - if (!reducedMotion) { - const animation = landmarkElement.animate( - [{ border: `2px solid ${theme.color.primary}` }, { border: `2px solid transparent` }], - { - duration: 1500, - pseudoElement: '::after', - } - ); - currentAnimationRef.current = animation; - - animation.onfinish = () => { - currentAnimationRef.current = null; - }; - } + animateLandmark(landmarkElement); }; document.addEventListener('keydown', handleKeyDown, { capture: true }); return () => { document.removeEventListener('keydown', handleKeyDown, { capture: true }); }; - }, [reducedMotion, theme.color.primary]); + }, [animateLandmark]); } diff --git a/code/core/src/manager/components/panel/Panel.tsx b/code/core/src/manager/components/panel/Panel.tsx index 41393b27634b..1ff1b5eb07ba 100644 --- a/code/core/src/manager/components/panel/Panel.tsx +++ b/code/core/src/manager/components/panel/Panel.tsx @@ -160,7 +160,7 @@ export const AddonPanel = React.memo<{ ); return ( -