From 03cf9ff35a828941f2610ae9e0ad408f3b058f46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:23:44 +0000 Subject: [PATCH 01/17] Initial plan From 9f380ec7dd7179c13f0f7545e60a98b21fbbb317 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:54:02 +0000 Subject: [PATCH 02/17] feat(a11y): make resize handles for addon panel and sidebar accessible - Add role="separator", tabIndex, aria-orientation, aria-label, aria-valuenow, aria-valuemin, aria-valuemax to sidebar and panel drag handles - Add keyboard interaction (arrow keys, Home/End) for resizing via keyboard - Add focus-visible styles so drag handles are visible when focused Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- .../src/manager/components/layout/Layout.tsx | 32 ++++++++++- .../manager/components/layout/useDragging.ts | 54 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index f1b019d5a3d3..47c365bc39a7 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, FC } from 'react'; +import type { CSSProperties } from 'react'; import React, { useEffect, useLayoutEffect, useState } from 'react'; import type { API_Layout, API_ViewMode } from 'storybook/internal/types'; @@ -175,7 +175,16 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s <> {isDesktop && ( - + {slots.slotSidebar} )} @@ -200,6 +209,25 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s panelResizerRef={panelResizerRef} position={panelPosition} > + {slots.slotPanel} )} diff --git a/code/core/src/manager/components/layout/useDragging.ts b/code/core/src/manager/components/layout/useDragging.ts index 1028f45df9f5..2de46c16067c 100644 --- a/code/core/src/manager/components/layout/useDragging.ts +++ b/code/core/src/manager/components/layout/useDragging.ts @@ -8,6 +8,7 @@ const SNAP_THRESHOLD_PX = 30; const SIDEBAR_MIN_WIDTH_PX = 240; const RIGHT_PANEL_MIN_WIDTH_PX = 270; const MIN_WIDTH_STIFFNESS = 0.9; +const KEYBOARD_STEP_PX = 10; /** Clamps a value between min and max. */ function clamp(value: number, min: number, max: number): number { @@ -172,12 +173,65 @@ export function useDragging({ }); }; + const onSidebarKeyDown = (e: KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { + return; + } + e.preventDefault(); + setState((state) => { + const maxSize = window.innerWidth; + switch (e.key) { + case 'ArrowRight': + return { ...state, navSize: clamp(state.navSize + KEYBOARD_STEP_PX, 0, maxSize) }; + case 'ArrowLeft': + return { ...state, navSize: clamp(state.navSize - KEYBOARD_STEP_PX, 0, maxSize) }; + case 'Home': + return { ...state, navSize: 0 }; + case 'End': + return { ...state, navSize: maxSize }; + default: + return state; + } + }); + }; + + const onPanelKeyDown = (e: KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { + return; + } + e.preventDefault(); + setState((state) => { + const isBottom = state.panelPosition === 'bottom'; + const sizeKey = isBottom ? 'bottomPanelHeight' : 'rightPanelWidth'; + const maxSize = isBottom ? window.innerHeight : window.innerWidth; + const increaseKey = isBottom ? 'ArrowUp' : 'ArrowLeft'; + const decreaseKey = isBottom ? 'ArrowDown' : 'ArrowRight'; + + switch (e.key) { + case increaseKey: + return { ...state, [sizeKey]: clamp(state[sizeKey] + KEYBOARD_STEP_PX, 0, maxSize) }; + case decreaseKey: + return { ...state, [sizeKey]: clamp(state[sizeKey] - KEYBOARD_STEP_PX, 0, maxSize) }; + case 'Home': + return { ...state, [sizeKey]: 0 }; + case 'End': + return { ...state, [sizeKey]: maxSize }; + default: + return state; + } + }); + }; + panelResizer?.addEventListener('mousedown', onDragStart); sidebarResizer?.addEventListener('mousedown', onDragStart); + panelResizer?.addEventListener('keydown', onPanelKeyDown); + sidebarResizer?.addEventListener('keydown', onSidebarKeyDown); return () => { panelResizer?.removeEventListener('mousedown', onDragStart); sidebarResizer?.removeEventListener('mousedown', onDragStart); + panelResizer?.removeEventListener('keydown', onPanelKeyDown); + sidebarResizer?.removeEventListener('keydown', onSidebarKeyDown); // make iframe capture pointer events again previewIframe?.removeAttribute('style'); }; From 8e0fbf5346d305d2b5f2acbed4f81918493418fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:42:58 +0000 Subject: [PATCH 03/17] refactor(a11y): address review feedback on resize handles - Refactor keyboard handlers into shared applyResizeKeyboard function with position parameter (left/right/bottom/top) - Add Shift key support for 5x step size - Move slotSidebar before Drag handle in sidebar container for better keyboard navigation order - Update focus-visible styles with outline:none, height:7, and boxShadow for the drag handle Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- .../src/manager/components/layout/Layout.tsx | 2 +- .../manager/components/layout/useDragging.ts | 108 ++++++++++++------ 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 47c365bc39a7..071077cdb9be 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -175,6 +175,7 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s <> {isDesktop && ( + {slots.slotSidebar} - {slots.slotSidebar} )} {isMobile && ( diff --git a/code/core/src/manager/components/layout/useDragging.ts b/code/core/src/manager/components/layout/useDragging.ts index 2de46c16067c..831aa8b034e2 100644 --- a/code/core/src/manager/components/layout/useDragging.ts +++ b/code/core/src/manager/components/layout/useDragging.ts @@ -9,6 +9,8 @@ const SIDEBAR_MIN_WIDTH_PX = 240; const RIGHT_PANEL_MIN_WIDTH_PX = 270; const MIN_WIDTH_STIFFNESS = 0.9; const KEYBOARD_STEP_PX = 10; +const KEYBOARD_SHIFT_MULTIPLIER = 5; +const RESIZE_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; /** Clamps a value between min and max. */ function clamp(value: number, min: number, max: number): number { @@ -20,6 +22,65 @@ function interpolate(relativeValue: number, min: number, max: number): number { return min + (max - min) * relativeValue; } +/** + * Given the current layout state, the position of the resize handle, and the key pressed, + * returns the next layout state with the resized panel/sidebar. + * + * @param position - The position of the resize handle relative to the content it resizes. + * 'left' for sidebar, 'bottom' or 'right' for the addon panel. + */ +function applyResizeKeyboard( + state: LayoutState, + position: 'left' | 'right' | 'bottom' | 'top', + key: string, + step: number +): LayoutState { + const sizeKey = + position === 'left' + ? 'navSize' + : position === 'bottom' || position === 'top' + ? 'bottomPanelHeight' + : 'rightPanelWidth'; + const maxSize = + position === 'bottom' || position === 'top' ? window.innerHeight : window.innerWidth; + + let increaseKey: string; + let decreaseKey: string; + switch (position) { + case 'left': + increaseKey = 'ArrowRight'; + decreaseKey = 'ArrowLeft'; + break; + case 'right': + increaseKey = 'ArrowLeft'; + decreaseKey = 'ArrowRight'; + break; + case 'bottom': + increaseKey = 'ArrowUp'; + decreaseKey = 'ArrowDown'; + break; + case 'top': + increaseKey = 'ArrowDown'; + decreaseKey = 'ArrowUp'; + break; + } + + const currentSize = state[sizeKey]; + + switch (key) { + case increaseKey: + return { ...state, [sizeKey]: clamp(currentSize + step, 0, maxSize) }; + case decreaseKey: + return { ...state, [sizeKey]: clamp(currentSize - step, 0, maxSize) }; + case 'Home': + return { ...state, [sizeKey]: 0 }; + case 'End': + return { ...state, [sizeKey]: maxSize }; + default: + return state; + } +} + export function useDragging({ setState, isPanelShown, @@ -174,52 +235,25 @@ export function useDragging({ }; const onSidebarKeyDown = (e: KeyboardEvent) => { - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { + if (!RESIZE_KEYS.includes(e.key)) { return; } e.preventDefault(); - setState((state) => { - const maxSize = window.innerWidth; - switch (e.key) { - case 'ArrowRight': - return { ...state, navSize: clamp(state.navSize + KEYBOARD_STEP_PX, 0, maxSize) }; - case 'ArrowLeft': - return { ...state, navSize: clamp(state.navSize - KEYBOARD_STEP_PX, 0, maxSize) }; - case 'Home': - return { ...state, navSize: 0 }; - case 'End': - return { ...state, navSize: maxSize }; - default: - return state; - } - }); + const step = e.shiftKey + ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER + : KEYBOARD_STEP_PX; + setState((state) => applyResizeKeyboard(state, 'left', e.key, step)); }; const onPanelKeyDown = (e: KeyboardEvent) => { - if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { + if (!RESIZE_KEYS.includes(e.key)) { return; } e.preventDefault(); - setState((state) => { - const isBottom = state.panelPosition === 'bottom'; - const sizeKey = isBottom ? 'bottomPanelHeight' : 'rightPanelWidth'; - const maxSize = isBottom ? window.innerHeight : window.innerWidth; - const increaseKey = isBottom ? 'ArrowUp' : 'ArrowLeft'; - const decreaseKey = isBottom ? 'ArrowDown' : 'ArrowRight'; - - switch (e.key) { - case increaseKey: - return { ...state, [sizeKey]: clamp(state[sizeKey] + KEYBOARD_STEP_PX, 0, maxSize) }; - case decreaseKey: - return { ...state, [sizeKey]: clamp(state[sizeKey] - KEYBOARD_STEP_PX, 0, maxSize) }; - case 'Home': - return { ...state, [sizeKey]: 0 }; - case 'End': - return { ...state, [sizeKey]: maxSize }; - default: - return state; - } - }); + const step = e.shiftKey + ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER + : KEYBOARD_STEP_PX; + setState((state) => applyResizeKeyboard(state, state.panelPosition, e.key, step)); }; panelResizer?.addEventListener('mousedown', onDragStart); From 2082668095fabc031b9a64ca3a841c98e2797c77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:53:28 +0000 Subject: [PATCH 04/17] refactor(a11y): rework resize handles based on next branch structure - Extract Drag.tsx component with built-in role=separator, tabIndex, aria-valuemin, and TooltipProvider (focus-only) showing arrow keys - Extract PanelContainer.tsx with ARIA attributes and hidden/aria-hidden for collapsed panel content - Extract MainAreaContainer.tsx matching next branch structure - Simplify Layout.tsx to use extracted components, reducing markup - Fix focus-visible: use height when horizontal, width when vertical - Sidebar slot stays before Drag handle for keyboard nav order Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- .../src/manager/components/layout/Drag.tsx | 88 ++++++++++++++++++- .../src/manager/components/layout/Layout.tsx | 22 ----- .../components/layout/PanelContainer.tsx | 10 +++ .../manager/components/layout/useDragging.ts | 16 ++-- 4 files changed, 100 insertions(+), 36 deletions(-) diff --git a/code/core/src/manager/components/layout/Drag.tsx b/code/core/src/manager/components/layout/Drag.tsx index ba1fe4053d3d..8ab18a22c3f7 100644 --- a/code/core/src/manager/components/layout/Drag.tsx +++ b/code/core/src/manager/components/layout/Drag.tsx @@ -1,12 +1,84 @@ +import React, { forwardRef, useMemo } from 'react'; + import { styled } from 'storybook/theming'; +import { TooltipNote } from '../../../components/components/tooltip/TooltipNote'; +import { TooltipProvider } from '../../../components/components/tooltip/TooltipProvider'; + +interface DragProps { + /** Visual orientation of the drag handle line. */ + orientation?: 'horizontal' | 'vertical'; + + /** Whether the drag handle overlaps the adjacent content area. */ + overlapping?: boolean; + + /** Which side the drag handle sits on, relative to the content it resizes. */ + position?: 'left' | 'right'; + + /** Accessible orientation for the separator role (determines arrow key mapping). */ + 'aria-orientation'?: 'horizontal' | 'vertical'; + + /** Accessible label describing what this separator resizes. */ + 'aria-label': string; + + /** Current size (in pixels) of the region controlled by this separator. */ + 'aria-valuenow': number; + + /** Maximum size (in pixels) for the region controlled by this separator. */ + 'aria-valuemax'?: number; +} + /** * 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. + * (sidebar or right panel). Implements the WAI-ARIA separator role with keyboard resize support. + * + * The component automatically sets `role="separator"`, `tabIndex={0}`, and `aria-valuemin={0}`. A + * tooltip is shown on focus advertising the arrow keys available for keyboard resizing. */ -export const Drag = styled.div<{ +export const Drag = forwardRef(function Drag(props, ref) { + const { + orientation, + overlapping, + position, + 'aria-orientation': ariaOrientation = 'horizontal', + 'aria-label': ariaLabel, + 'aria-valuenow': ariaValueNow, + 'aria-valuemax': ariaValueMax, + ...rest + } = props; + + const tooltipNote = useMemo(() => { + if (ariaOrientation === 'vertical') { + return '← → to resize'; + } + return '↑ ↓ to resize'; + }, [ariaOrientation]); + + return ( + } + > + + + ); +}); + +const DragHandle = styled.div<{ orientation?: 'horizontal' | 'vertical'; overlapping?: boolean; position?: 'left' | 'right'; @@ -27,6 +99,14 @@ export const Drag = styled.div<{ opacity: 1, }, }), + ({ theme, orientation = 'vertical' }) => ({ + '&:focus-visible': { + opacity: 1, + outline: 'none', + ...(orientation === 'horizontal' ? { height: 7 } : { width: 7 }), + boxShadow: `inset 0 0 0 4px ${theme.color.secondary}`, + }, + }), ({ orientation = 'vertical', overlapping = true, position = 'left' }) => orientation === 'vertical' ? { diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 071077cdb9be..d3d4a18743c2 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -178,12 +178,9 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s {slots.slotSidebar} @@ -209,25 +206,6 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s panelResizerRef={panelResizerRef} position={panelPosition} > - {slots.slotPanel} )} diff --git a/code/core/src/manager/components/layout/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index 9ddce06d569e..d529b9783e65 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -43,6 +43,16 @@ const PanelContainer = React.memo(function PanelContainer(p overlapping={position === 'bottom' ? !!bottomPanelHeight : !!rightPanelWidth} position={position === 'bottom' ? 'left' : 'right'} ref={panelResizerRef} + aria-orientation={position === 'bottom' ? 'horizontal' : 'vertical'} + aria-label="Addon panel resize handle" + aria-valuenow={position === 'bottom' ? bottomPanelHeight : rightPanelWidth} + aria-valuemax={ + typeof window !== 'undefined' + ? position === 'bottom' + ? window.innerHeight + : window.innerWidth + : undefined + } /> applyResizeKeyboard(state, 'left', e.key, step)); }; @@ -250,9 +248,7 @@ export function useDragging({ return; } e.preventDefault(); - const step = e.shiftKey - ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER - : KEYBOARD_STEP_PX; + const step = e.shiftKey ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER : KEYBOARD_STEP_PX; setState((state) => applyResizeKeyboard(state, state.panelPosition, e.key, step)); }; From b5b2969a971c8552330731c4aa3c62fa838b4e33 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 9 Mar 2026 10:59:50 +0100 Subject: [PATCH 05/17] Refactor smelly code and rebase leftovers --- .../src/manager/components/layout/Drag.tsx | 65 ++++++++++--------- .../src/manager/components/layout/Layout.tsx | 10 ++- .../components/layout/PanelContainer.tsx | 6 +- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/code/core/src/manager/components/layout/Drag.tsx b/code/core/src/manager/components/layout/Drag.tsx index 8ab18a22c3f7..1b14462bbf8a 100644 --- a/code/core/src/manager/components/layout/Drag.tsx +++ b/code/core/src/manager/components/layout/Drag.tsx @@ -2,22 +2,17 @@ import React, { forwardRef, useMemo } from 'react'; import { styled } from 'storybook/theming'; +import type { PopperPlacement } from '../../../components'; import { TooltipNote } from '../../../components/components/tooltip/TooltipNote'; import { TooltipProvider } from '../../../components/components/tooltip/TooltipProvider'; interface DragProps { - /** Visual orientation of the drag handle line. */ - orientation?: 'horizontal' | 'vertical'; + /** Which side the drag handle sits on, relative to the content it resizes. Determines orientation. */ + position: 'left' | 'right' | 'top' | 'bottom'; /** Whether the drag handle overlaps the adjacent content area. */ overlapping?: boolean; - /** Which side the drag handle sits on, relative to the content it resizes. */ - position?: 'left' | 'right'; - - /** Accessible orientation for the separator role (determines arrow key mapping). */ - 'aria-orientation'?: 'horizontal' | 'vertical'; - /** Accessible label describing what this separator resizes. */ 'aria-label': string; @@ -28,6 +23,13 @@ interface DragProps { 'aria-valuemax'?: number; } +const oppositePosition: Record = { + left: 'right', + right: 'left', + top: 'bottom', + bottom: 'top', +}; + /** * Drag handle for the sidebar and panel resizers. Can be horizontal (bottom panel) or vertical * (sidebar or right panel). Implements the WAI-ARIA separator role with keyboard resize support. @@ -37,37 +39,37 @@ interface DragProps { */ export const Drag = forwardRef(function Drag(props, ref) { const { - orientation, overlapping, position, - 'aria-orientation': ariaOrientation = 'horizontal', 'aria-label': ariaLabel, 'aria-valuenow': ariaValueNow, 'aria-valuemax': ariaValueMax, ...rest } = props; + const orientation = position === 'left' || position === 'right' ? 'vertical' : 'horizontal'; + const tooltipNote = useMemo(() => { - if (ariaOrientation === 'vertical') { + if (orientation === 'vertical') { return '← → to resize'; } return '↑ ↓ to resize'; - }, [ariaOrientation]); + }, [orientation]); return ( } > (function Drag(props, r }); const DragHandle = styled.div<{ - orientation?: 'horizontal' | 'vertical'; - overlapping?: boolean; - position?: 'left' | 'right'; + $orientation?: 'horizontal' | 'vertical'; + $overlapping?: boolean; + $position: 'left' | 'right' | 'top' | 'bottom'; }>( ({ theme }) => ({ position: 'absolute', @@ -99,27 +101,29 @@ const DragHandle = styled.div<{ opacity: 1, }, }), - ({ theme, orientation = 'vertical' }) => ({ + ({ theme, $orientation = 'vertical' }) => ({ '&:focus-visible': { opacity: 1, outline: 'none', - ...(orientation === 'horizontal' ? { height: 7 } : { width: 7 }), + ...($orientation === 'horizontal' ? { height: 7 } : { width: 7 }), boxShadow: `inset 0 0 0 4px ${theme.color.secondary}`, }, }), - ({ orientation = 'vertical', overlapping = true, position = 'left' }) => - orientation === 'vertical' + ({ $orientation = 'vertical', $overlapping = true, $position = 'left' }) => + $orientation === 'vertical' ? { - width: overlapping ? (position === 'left' ? 10 : 13) : 7, + // This is an old code smell, where 10px matches the sidebar and 13px matches the addon panel. + // It should be tidied up at some point. + width: $overlapping ? ($position === 'left' ? 10 : 13) : 7, height: '100%', top: 0, - right: position === 'left' ? -7 : undefined, - left: position === 'right' ? -7 : undefined, + right: $position === 'left' ? -7 : undefined, + left: $position === 'right' ? -7 : undefined, '&:after': { width: 1, height: '100%', - marginLeft: position === 'left' ? 3 : 6, + marginLeft: $position === 'left' ? 3 : 6, }, '&:hover': { @@ -128,8 +132,9 @@ const DragHandle = styled.div<{ } : { width: '100%', - height: overlapping ? 13 : 7, - top: -7, + height: $overlapping ? 13 : 7, + top: $position === 'bottom' ? -7 : undefined, + bottom: $position === 'top' ? -7 : undefined, left: 0, '&:after': { diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index d3d4a18743c2..310fa560ebf4 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -131,9 +131,7 @@ const useLayoutSyncingState = ({ panelResizerRef, sidebarResizerRef, showPages: isPagesShown, - showPanel: - customisedShowPanel && - (managerLayoutState.panelPosition === 'right' ? rightPanelWidth > 0 : bottomPanelHeight > 0), + showPanel: customisedShowPanel, isDragging: internalDraggingSizeState.isDragging, }; }; @@ -178,7 +176,7 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s {slots.slotSidebar} - {isDesktop && showPanel && ( + {isDesktop && ( - {slots.slotPanel} + {showPanel && slots.slotPanel} )} {isMobile && } diff --git a/code/core/src/manager/components/layout/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index d529b9783e65..c97931fcde57 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -39,11 +39,9 @@ const PanelContainer = React.memo(function PanelContainer(p return ( Date: Mon, 9 Mar 2026 10:13:30 +0000 Subject: [PATCH 06/17] feat(a11y): add SidebarContainer with focus management for sidebar toggle - Create SidebarContainer.tsx with hidden/aria-hidden when navSize === 0 - Add sidebarRegion and showSidebar IDs to focusableUIElements - Update menu.tsx tool with focus management and landmark animation - Update shortcuts.ts to move focus to Show sidebar button when closing - Simplify Layout.tsx to use SidebarContainer component Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/manager-api/modules/layout.ts | 2 + .../core/src/manager-api/modules/shortcuts.ts | 21 ++++++ .../src/manager/components/layout/Layout.tsx | 18 +---- .../components/layout/SidebarContainer.tsx | 56 +++++++++++++++ .../manager/components/preview/tools/menu.tsx | 68 +++++++++++++------ 5 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 code/core/src/manager/components/layout/SidebarContainer.tsx diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index a0df07067e5b..a5b47b2b432f 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -153,6 +153,8 @@ export const focusableUIElements = { storyListMenu: 'storybook-explorer-menu', storyPanelRoot: 'storybook-panel-root', showAddonPanel: 'storybook-show-addon-panel', + sidebarRegion: 'storybook-sidebar-region', + showSidebar: 'storybook-show-sidebar', }; 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 83520110c131..d52747d3c853 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -382,7 +382,28 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } case 'toggleNav': { + const wasNavShown = fullAPI.getIsNavShown(); + const sidebarElement = document.getElementById(focusableUIElements.sidebarRegion); + const wasFocusInSidebar = + sidebarElement && + document.activeElement && + sidebarElement.contains(document.activeElement); + fullAPI.toggleNav(); + + if (wasNavShown && wasFocusInSidebar) { + // poll: true always returns a Promise. + ( + fullAPI.focusOnUIElement(focusableUIElements.showSidebar, { + poll: true, + }) as Promise + ).then((success) => { + // Fallback to body for predictable behavior. + if (success === false) { + document.body.focus(); + } + }); + } break; } diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index 310fa560ebf4..69780fef5cc3 100644 --- a/code/core/src/manager/components/layout/Layout.tsx +++ b/code/core/src/manager/components/layout/Layout.tsx @@ -9,10 +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 { SidebarContainer } from './SidebarContainer'; import { useDragging } from './useDragging'; import { useLandmarkIndicator } from './useLandmarkIndicator'; @@ -172,15 +172,8 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s > <> {isDesktop && ( - + {slots.slotSidebar} - )} {isMobile && ( @@ -245,10 +238,3 @@ const LayoutContainer = styled.div<{ })(), }, })); - -const SidebarContainer = styled.div(({ theme }) => ({ - backgroundColor: theme.appBg, - gridArea: 'sidebar', - position: 'relative', - borderRight: `1px solid ${theme.appBorderColor}`, -})); diff --git a/code/core/src/manager/components/layout/SidebarContainer.tsx b/code/core/src/manager/components/layout/SidebarContainer.tsx new file mode 100644 index 000000000000..eff60f76aa0c --- /dev/null +++ b/code/core/src/manager/components/layout/SidebarContainer.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { styled } from 'storybook/theming'; + +import { focusableUIElements } from '../../../manager-api/modules/layout'; +import { Drag } from './Drag'; + +interface SidebarContainerProps { + children: React.ReactNode; + navSize: number; + sidebarResizerRef: React.Ref; +} + +const Container = styled.div(({ theme }) => ({ + backgroundColor: theme.appBg, + gridArea: 'sidebar', + position: 'relative', + borderRight: `1px solid ${theme.appBorderColor}`, +})); + +const SidebarSlot = styled.div({ + height: '100%', +}); + +/** + * Shows the sidebar and its resize drag handle. The drag handle is always rendered so users can + * reopen the sidebar. The sidebar is always rendered (to preserve internal state), but it's + * excluded from the Accessibility Object Model when effectively collapsed. + */ +const SidebarContainer = React.memo(function SidebarContainer(props) { + const { children, navSize, sidebarResizerRef } = props; + + const shouldHideSidebarContent = navSize === 0; + + return ( + + + + + ); +}); + +export { SidebarContainer }; diff --git a/code/core/src/manager/components/preview/tools/menu.tsx b/code/core/src/manager/components/preview/tools/menu.tsx index 0cb0531d5705..404177ba6f57 100644 --- a/code/core/src/manager/components/preview/tools/menu.tsx +++ b/code/core/src/manager/components/preview/tools/menu.tsx @@ -8,10 +8,23 @@ import { MenuIcon } from '@storybook/icons'; import { Consumer, types } from 'storybook/manager-api'; import type { Combo } from 'storybook/manager-api'; +import { focusableUIElements } from '../../../../manager-api/modules/layout'; +import { useRegionFocusAnimation } from '../../layout/useLandmarkIndicator'; + const menuMapper = ({ api, state }: Combo) => ({ isVisible: api.getIsNavShown(), singleStory: state.singleStory, - toggle: () => api.toggleNav(), + viewMode: state.viewMode, + showSidebar: async (animateLandmark?: (e: HTMLElement | null) => void) => { + api.toggleNav(true); + const success = await api.focusOnUIElement(focusableUIElements.sidebarRegion, { + forceFocus: true, + poll: true, + }); + if (success) { + animateLandmark?.(document.getElementById(focusableUIElements.sidebarRegion)); + } + }, }); export const menuTool: Addon_BaseType = { @@ -20,25 +33,36 @@ export const menuTool: Addon_BaseType = { type: types.TOOL, // @ts-expect-error (non strict) match: ({ viewMode }) => ['story', 'docs'].includes(viewMode), - render: () => ( - - {({ isVisible, toggle, singleStory }) => - !singleStory && - !isVisible && ( - <> - - - - ) - } - - ), + render: () => { + const animateLandmark = useRegionFocusAnimation(); + + return ( + + {({ isVisible, showSidebar, singleStory }) => + !singleStory && + !isVisible && ( + <> + + + + ) + } + + ); + }, }; From a1286097c495295d1f15dd3ca875bcdc072502ab Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 9 Mar 2026 14:03:04 +0100 Subject: [PATCH 07/17] Fix element IDs for landmark animations --- .../src/manager/components/layout/SidebarContainer.tsx | 2 +- code/core/src/manager/components/panel/Panel.tsx | 3 ++- code/core/src/manager/components/sidebar/Sidebar.tsx | 8 +++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/code/core/src/manager/components/layout/SidebarContainer.tsx b/code/core/src/manager/components/layout/SidebarContainer.tsx index eff60f76aa0c..d762fa53cb8c 100644 --- a/code/core/src/manager/components/layout/SidebarContainer.tsx +++ b/code/core/src/manager/components/layout/SidebarContainer.tsx @@ -33,7 +33,7 @@ const SidebarContainer = React.memo(function SidebarConta const shouldHideSidebarContent = navSize === 0; return ( - + +