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..7895ec219f8a 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -382,7 +382,25 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } case 'toggleNav': { + const wasNavShown = fullAPI.getIsNavShown(); + const sidebarElement = document.getElementById(focusableUIElements.sidebarRegion); + const wasFocusInSidebar = 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/Drag.stories.tsx b/code/core/src/manager/components/layout/Drag.stories.tsx new file mode 100644 index 000000000000..0b1eb0bf2c62 --- /dev/null +++ b/code/core/src/manager/components/layout/Drag.stories.tsx @@ -0,0 +1,274 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { expect, userEvent, within } from 'storybook/test'; + +import { Drag } from './Drag'; + +/** + * Presentational drag-handle component used for the sidebar and addon-panel resizers. + * + * Covers positioning, tooltips and hover/focus states, and ARIA attribute markup. The actual + * keyboard-resize logic lives in `useDragging` and can be tested in Layout stories. + */ +const meta = { + title: 'Layout/Drag', + component: Drag, + parameters: { + layout: 'centered', + }, + decorators: [ + (storyFn) => ( + // Drag uses `position: absolute` so it needs a positioned parent. +
+ {storyFn()} +
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const PositionLeft: Story = { + name: 'Position: left (sidebar)', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 200, + 'aria-valuemax': 500, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="vertical" for left position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'vertical'); + }); + + await step('Shows on the right of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.left).toBeGreaterThan(parentRect?.left ?? 0); + }); + }, +}; + +export const PositionRight: Story = { + name: 'Position: right (addon panel)', + args: { + position: 'right', + 'aria-label': 'Addon panel resize handle', + 'aria-valuenow': 300, + 'aria-valuemax': 600, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="vertical" for right position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'vertical'); + }); + + await step('Shows on the left of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.left).toBeLessThan(parentRect?.left ?? 0); + }); + }, +}; + +export const PositionBottom: Story = { + name: 'Position: bottom (bottom panel)', + args: { + position: 'bottom', + 'aria-label': 'Addon panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="horizontal" for bottom position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + await step('Shows on the top of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.top).toBeLessThan(parentRect?.top ?? 0); + }); + }, +}; + +export const PositionTop: Story = { + name: 'Position: top', + args: { + position: 'top', + 'aria-label': 'Top panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Has aria-orientation="horizontal" for top position', async () => { + expect(handle).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + await step('Shows on the bottom of the parent element', async () => { + handle.focus(); + const parentRect = handle.parentElement?.getBoundingClientRect(); + const handleRect = handle.getBoundingClientRect(); + expect(handleRect.top).toBeGreaterThan(parentRect?.top ?? 0); + }); + }, +}; + +export const AriaRole: Story = { + name: 'ARIA: role separator', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 240, + 'aria-valuemax': 480, + }, + play: async ({ canvas, step }) => { + await step('Has role="separator"', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toBeInTheDocument(); + }); + }, +}; + +export const AriaOrientationVertical: Story = { + name: 'ARIA: orientation vertical', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 240, + 'aria-valuemax': 480, + }, + play: async ({ canvas, step }) => { + await step('Has aria-orientation="vertical" for left position', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-orientation', 'vertical'); + }); + }, +}; + +export const AriaOrientationHorizontal: Story = { + name: 'ARIA: orientation horizontal', + args: { + position: 'bottom', + 'aria-label': 'Bottom panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + await step('Has aria-orientation="horizontal" for bottom position', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-orientation', 'horizontal'); + }); + }, +}; + +export const AriaLabel: Story = { + name: 'ARIA: aria-label', + args: { + position: 'bottom', + 'aria-label': 'Specific resize handle label', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + await step('Has correct aria-label', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-label', 'Specific resize handle label'); + }); + }, +}; + +export const AriaValue: Story = { + name: 'ARIA: aria-value* attributes', + args: { + position: 'bottom', + 'aria-label': 'Specific resize handle label', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + await step('Has correct aria-value* attributes', async () => { + const handle = canvas.getByRole('separator'); + expect(handle).toHaveAttribute('aria-valuemin', '0'); + expect(handle).toHaveAttribute('aria-valuenow', '150'); + expect(handle).toHaveAttribute('aria-valuemax', '400'); + }); + }, +}; + +export const FocusTooltipVertical: Story = { + name: 'Keyboard: vertical focus tooltip', + args: { + position: 'left', + 'aria-label': 'Sidebar resize handle', + 'aria-valuenow': 200, + 'aria-valuemax': 500, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Tab onto the handle', async () => { + await userEvent.tab(); + expect(handle).toHaveFocus(); + }); + + await step('Tooltip with ← → hint is visible', async () => { + // The tooltip is rendered in a portal outside canvas. + const tooltip = await within(document.body).findByText('← → to resize'); + expect(tooltip).toBeInTheDocument(); + }); + + await step('Tooltip disappears on blur', async () => { + handle.blur(); + // Give the tooltip time to un-mount / fade out. + await new Promise((r) => setTimeout(r, 250)); + const tooltip = within(document.body).queryByText('← → to resize'); + expect(tooltip).not.toBeInTheDocument(); + }); + }, +}; + +export const FocusTooltipHorizontal: Story = { + name: 'Keyboard: horizontal focus tooltip', + args: { + position: 'bottom', + 'aria-label': 'Bottom panel resize handle', + 'aria-valuenow': 150, + 'aria-valuemax': 400, + }, + play: async ({ canvas, step }) => { + const handle = canvas.getByRole('separator'); + + await step('Tab onto the handle', async () => { + await userEvent.tab(); + expect(handle).toHaveFocus(); + }); + + await step('Tooltip with ↑ ↓ hint is visible', async () => { + const tooltip = await within(document.body).findByText('↑ ↓ to resize'); + expect(tooltip).toBeInTheDocument(); + }); + }, +}; diff --git a/code/core/src/manager/components/layout/Drag.tsx b/code/core/src/manager/components/layout/Drag.tsx index ba1fe4053d3d..0ffe5a79c12d 100644 --- a/code/core/src/manager/components/layout/Drag.tsx +++ b/code/core/src/manager/components/layout/Drag.tsx @@ -1,15 +1,83 @@ +import React, { forwardRef } 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 { + /** 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; + + /** 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; +} + +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). 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<{ - orientation?: 'horizontal' | 'vertical'; - overlapping?: boolean; - position?: 'left' | 'right'; +export const Drag = forwardRef(function Drag(props, ref) { + const { + overlapping, + position, + 'aria-label': ariaLabel, + 'aria-valuenow': ariaValueNow, + 'aria-valuemax': ariaValueMax, + ...rest + } = props; + + const orientation = position === 'left' || position === 'right' ? 'vertical' : 'horizontal'; + const tooltipNote = orientation === 'vertical' ? '← → to resize' : '↑ ↓ to resize'; + + return ( + } + > + + + ); +}); + +const DragHandle = styled.div<{ + $orientation?: 'horizontal' | 'vertical'; + $overlapping?: boolean; + $position: 'left' | 'right' | 'top' | 'bottom'; }>( ({ theme }) => ({ position: 'absolute', @@ -27,19 +95,33 @@ export const Drag = styled.div<{ opacity: 1, }, }), - ({ orientation = 'vertical', overlapping = true, position = 'left' }) => - orientation === 'vertical' + ({ theme, $orientation = 'vertical' }) => ({ + '&:focus-visible': { + opacity: 1, + outline: '2px solid transparent', + ...($orientation === 'horizontal' ? { height: 7 } : { width: 7 }), + boxShadow: `inset 0 0 0 4px ${theme.color.secondary}`, + + '@media (forced-colors: active)': { + outline: '2px solid Highlight', + }, + }, + }), + ({ $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': { @@ -48,8 +130,9 @@ export const Drag = 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.stories.tsx b/code/core/src/manager/components/layout/Layout.stories.tsx index 1cabd5957091..3c7d33ba5ea6 100644 --- a/code/core/src/manager/components/layout/Layout.stories.tsx +++ b/code/core/src/manager/components/layout/Layout.stories.tsx @@ -8,10 +8,15 @@ 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 { expect, fn } from 'storybook/test'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import { styled } from 'storybook/theming'; import { isChromatic } from '../../../../../.storybook/isChromatic'; +import { + MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX, + MINIMUM_RIGHT_PANEL_WIDTH_PX, + MINIMUM_SIDEBAR_WIDTH_PX, +} from '../../constants'; import { Layout } from './Layout'; import { LayoutProvider } from './LayoutProvider'; @@ -167,9 +172,15 @@ export const DesktopCollapsedPanel: Story = { managerLayoutState: { ...defaultState, bottomPanelHeight: 0 }, }, play: async ({ canvas, step }) => { - await step('Verify panel is not rendered', async () => { + await step('Verify panel is aria-hidden and not interactive', async () => { const panel = canvas.queryByTestId('panel'); - expect(panel).not.toBeInTheDocument(); + + const ariaHiddenNode = panel?.closest('[aria-hidden="true"]'); + expect(ariaHiddenNode).toBeInTheDocument(); + expect(ariaHiddenNode).toHaveAttribute('aria-hidden', 'true'); + + panel?.focus(); + expect(panel).not.toHaveFocus(); }); }, }; @@ -209,6 +220,275 @@ export const DesktopPages: Story = { }, }; +export const KeyboardSidebarResize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Sidebar resize handle' }); + + await step('Focus the sidebar handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('ArrowRight widens the sidebar', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeGreaterThan(before) + ); + }); + + await step('Shift+ArrowRight widens by a larger step', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow')) - before).toBeGreaterThanOrEqual(50) + ); + }); + + await step('ArrowLeft narrows the sidebar', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + + await step('Home collapses the sidebar to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('End expands the sidebar to its maximum', async () => { + await userEvent.keyboard('{End}'); + await waitFor(() => { + const valuenow = Number(handle.getAttribute('aria-valuenow')); + const valuemax = Number(handle.getAttribute('aria-valuemax')); + expect(valuenow).toBe(valuemax); + }); + }); + + await step('ArrowLeft narrows the sidebar again', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + }, +}; + +export const KeyboardSidebarMinSize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Sidebar resize handle' }); + + await step('Focus the sidebar handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('Home collapses the sidebar to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('ArrowRight brings the sidebar to its min size', async () => { + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(handle).toHaveAttribute('aria-valuenow', `${MINIMUM_SIDEBAR_WIDTH_PX}`) + ); + }); + + await step('ArrowLeft collapses it again', async () => { + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + }, +}; + +export const KeyboardBottomPanelResize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('ArrowUp increases the panel height', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowUp}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeGreaterThan(before) + ); + }); + + await step('Shift+ArrowUp increases by a larger step', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{Shift>}{ArrowUp}{/Shift}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow')) - before).toBeGreaterThanOrEqual(50) + ); + }); + + await step('ArrowDown decreases the panel height', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + + await step('Home collapses the panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('End expands the panel to its maximum', async () => { + await userEvent.keyboard('{End}'); + await waitFor(() => { + const valuenow = Number(handle.getAttribute('aria-valuenow')); + const valuemax = Number(handle.getAttribute('aria-valuemax')); + expect(valuenow).toBe(valuemax); + }); + }); + + await step('ArrowDown decreases the panel height again', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + }, +}; + +export const KeyboardBottomPanelMinSize: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the addon panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('Home collapses the addon panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('ArrowUp brings the addon panel to its min size', async () => { + await userEvent.keyboard('{ArrowUp}'); + await waitFor(() => + expect(handle).toHaveAttribute('aria-valuenow', `${MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX}`) + ); + }); + + await step('ArrowDown collapses it again', async () => { + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + }, +}; + +export const KeyboardRightPanelResize: Story = { + args: { + managerLayoutState: { ...defaultState, panelPosition: 'right' }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('ArrowLeft widens the right panel', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeGreaterThan(before) + ); + }); + + await step('Shift+ArrowLeft widens by a larger step', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow')) - before).toBeGreaterThanOrEqual(50) + ); + }); + + await step('ArrowRight narrows the right panel', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + + await step('Home collapses the right panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('End expands the right panel to its maximum', async () => {}); + + await step('End expands the right panel to its maximum', async () => { + await userEvent.keyboard('{End}'); + await waitFor(() => { + const valuenow = Number(handle.getAttribute('aria-valuenow')); + const valuemax = Number(handle.getAttribute('aria-valuemax')); + expect(valuenow).toBe(valuemax); + }); + }); + + await step('ArrowRight narrows the right panel again', async () => { + const before = Number(handle.getAttribute('aria-valuenow')); + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => + expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThan(before) + ); + }); + }, +}; + +export const KeyboardRightPanelMinSize: Story = { + args: { + managerLayoutState: { ...defaultState, panelPosition: 'right' }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('separator', { name: 'Addon panel resize handle' }); + + await step('Focus the addon panel handle', async () => { + handle.focus(); + expect(handle).toHaveFocus(); + }); + + await step('Home collapses the addon panel to 0', async () => { + await userEvent.keyboard('{Home}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + + await step('ArrowLeft brings the addon panel to its min size', async () => { + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => + expect(handle).toHaveAttribute('aria-valuenow', `${MINIMUM_RIGHT_PANEL_WIDTH_PX}`) + ); + }); + + await step('ArrowRight collapses it again', async () => { + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => expect(handle).toHaveAttribute('aria-valuenow', '0')); + }); + }, +}; + export const Mobile = { parameters: { viewport: { diff --git a/code/core/src/manager/components/layout/Layout.tsx b/code/core/src/manager/components/layout/Layout.tsx index f1b019d5a3d3..2b1abc7d5e76 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'; @@ -6,13 +6,13 @@ import type { API_Layout, API_ViewMode } from 'storybook/internal/types'; import { type API, useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; -import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; +import { MEDIA_DESKTOP_BREAKPOINT, MINIMUM_CONTENT_WIDTH_PX } 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'; @@ -38,7 +38,6 @@ interface Props { slotPages?: React.ReactNode; hasTab: boolean; } -const MINIMUM_CONTENT_WIDTH_PX = 100; const layoutStateIsEqual = (state: ManagerLayoutState, other: ManagerLayoutState) => state.navSize === other.navSize && @@ -111,11 +110,6 @@ const useLayoutSyncingState = ({ managerLayoutState.viewMode !== 'docs'; const isPanelShown = managerLayoutState.viewMode === 'story' && !hasTab; - const { panelResizerRef, sidebarResizerRef } = useDragging({ - setState: setInternalDraggingSizeState, - isPanelShown, - isDesktop, - }); const { navSize, rightPanelWidth, bottomPanelHeight } = internalDraggingSizeState.isDragging ? internalDraggingSizeState : managerLayoutState; @@ -123,6 +117,15 @@ const useLayoutSyncingState = ({ const customisedNavSize = api.getNavSizeWithCustomisations?.(navSize) ?? navSize; const customisedShowPanel = api.getShowPanelWithCustomisations?.(isPanelShown) ?? isPanelShown; + const { panelResizerRef, sidebarResizerRef, sidebarMaxWidth, panelMaxSize } = useDragging({ + setState: setInternalDraggingSizeState, + isDesktop, + navSize: customisedNavSize, + showPanel: customisedShowPanel, + rightPanelWidth, + panelPosition: managerLayoutState.panelPosition, + }); + return { navSize: customisedNavSize, rightPanelWidth, @@ -130,10 +133,10 @@ const useLayoutSyncingState = ({ panelPosition: managerLayoutState.panelPosition, panelResizerRef, sidebarResizerRef, + sidebarMaxWidth, + panelMaxSize, showPages: isPagesShown, - showPanel: - customisedShowPanel && - (managerLayoutState.panelPosition === 'right' ? rightPanelWidth > 0 : bottomPanelHeight > 0), + showPanel: customisedShowPanel, isDragging: internalDraggingSizeState.isDragging, }; }; @@ -153,6 +156,8 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s panelPosition, panelResizerRef, sidebarResizerRef, + sidebarMaxWidth, + panelMaxSize, showPages, showPanel, } = useLayoutSyncingState({ api, managerLayoutState, setManagerLayoutState, isDesktop, hasTab }); @@ -174,8 +179,11 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s > <> {isDesktop && ( - - + {slots.slotSidebar} )} @@ -193,14 +201,15 @@ export const Layout = ({ managerLayoutState, setManagerLayoutState, hasTab, ...s slotPages={slots.slotPages} /> - {isDesktop && showPanel && ( + {isDesktop && ( - {slots.slotPanel} + {showPanel && slots.slotPanel} )} {isMobile && } @@ -241,10 +250,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/PanelContainer.tsx b/code/core/src/manager/components/layout/PanelContainer.tsx index 9ddce06d569e..98f39d028165 100644 --- a/code/core/src/manager/components/layout/PanelContainer.tsx +++ b/code/core/src/manager/components/layout/PanelContainer.tsx @@ -9,6 +9,7 @@ interface PanelContainerProps { children: React.ReactNode; bottomPanelHeight: number; rightPanelWidth: number; + panelMaxSize: number | undefined; panelResizerRef: React.Ref; position: API_Layout['panelPosition']; } @@ -31,7 +32,8 @@ const PanelSlot = styled.div({ * from the Accessibility Object Model when effectively collapsed. */ const PanelContainer = React.memo(function PanelContainer(props) { - const { children, bottomPanelHeight, rightPanelWidth, panelResizerRef, position } = props; + const { children, bottomPanelHeight, rightPanelWidth, panelMaxSize, panelResizerRef, position } = + props; const shouldHidePanelContent = position === 'bottom' ? bottomPanelHeight === 0 : rightPanelWidth === 0; @@ -39,10 +41,12 @@ const PanelContainer = React.memo(function PanelContainer(p return ( ; +} + +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, sidebarMaxWidth, sidebarResizerRef } = props; + + const shouldHideSidebarContent = navSize === 0; + + return ( + + + + + ); +}); + +export { SidebarContainer }; diff --git a/code/core/src/manager/components/layout/useDragging.ts b/code/core/src/manager/components/layout/useDragging.ts index 1028f45df9f5..581b513c0b81 100644 --- a/code/core/src/manager/components/layout/useDragging.ts +++ b/code/core/src/manager/components/layout/useDragging.ts @@ -1,13 +1,24 @@ import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useRef } from 'react'; +import type { API_Layout } from 'storybook/internal/types'; + +import { + MINIMUM_CONTENT_WIDTH_PX, + MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX, + MINIMUM_HORIZONTAL_PANEL_WIDTH_PX, + MINIMUM_RIGHT_PANEL_WIDTH_PX, + MINIMUM_SIDEBAR_WIDTH_PX, + TOOLBAR_HEIGHT_PX, +} from '../../constants'; import type { LayoutState } from './Layout'; // the distance from the edge of the screen at which the panel/sidebar will snap to the edge 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 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 { @@ -19,18 +30,105 @@ function interpolate(relativeValue: number, min: number, max: number): number { return min + (max - min) * relativeValue; } +/** + * Computes the maximum width for the sidebar, accounting for the content minimum and the panel's + * estimated horizontal footprint. When the panel is at the bottom its minimum enforced width is + * reserved so the browser never squashes it below `MINIMUM_HORIZONTAL_PANEL_WIDTH_PX`. + */ +function computeSidebarMaxWidth( + panelPosition: API_Layout['panelPosition'], + rightPanelWidth: number, + showPanel: boolean +): number { + if (typeof window === 'undefined') { + return 0; + } + + const panelWidth = !showPanel + ? 0 + : panelPosition === 'right' + ? rightPanelWidth + : MINIMUM_HORIZONTAL_PANEL_WIDTH_PX; + return Math.max(window.innerWidth - MINIMUM_CONTENT_WIDTH_PX - panelWidth, 0); +} + +/** + * Computes the maximum size for the panel: + * + * - Bottom panel: `innerHeight` minus the toolbar so it cannot push the toolbar off-screen. + * - Right panel: `innerWidth` minus the content minimum and the sidebar. + */ +function computePanelMaxSize(panelPosition: API_Layout['panelPosition'], navSize: number): number { + if (typeof window === 'undefined') { + return 0; + } + + if (panelPosition === 'bottom') { + return Math.max(window.innerHeight - TOOLBAR_HEIGHT_PX, 0); + } + return Math.max(window.innerWidth - MINIMUM_CONTENT_WIDTH_PX - navSize, 0); +} + +/** + * Given the current layout state, a size key, a max size, and the key pressed, returns the next + * layout state with the resized panel/sidebar. All sidebar/panel-specific logic lives in the + * callers; this function is fully parameterised. + * + * @param sizeKey - The layout state key to resize. + * @param maxSize - The effective maximum size for the region. + * @param increaseKey - The key that grows the region. + * @param decreaseKey - The key that shrinks the region. + */ +function applyResizeKeyboard( + state: LayoutState, + sizeKey: 'navSize' | 'bottomPanelHeight' | 'rightPanelWidth', + key: string, + step: number, + minSize: number, + maxSize: number, + increaseKey: string, + decreaseKey: string +): LayoutState { + const currentSize = state[sizeKey]; + + switch (key) { + case increaseKey: + return { ...state, [sizeKey]: clamp(currentSize + step, minSize, maxSize) }; + case decreaseKey: + const effectivelyComputed = clamp(currentSize - step, 0, maxSize); + return { ...state, [sizeKey]: effectivelyComputed < minSize ? 0 : effectivelyComputed }; + case 'Home': + return { ...state, [sizeKey]: 0 }; + case 'End': + return { ...state, [sizeKey]: maxSize }; + default: + return state; + } +} + export function useDragging({ setState, - isPanelShown, + showPanel, isDesktop, + navSize, + rightPanelWidth, + panelPosition, }: { setState: Dispatch>; - isPanelShown: boolean; + showPanel: boolean; isDesktop: boolean; + navSize: number; + rightPanelWidth: number; + panelPosition: API_Layout['panelPosition']; }) { const panelResizerRef = useRef(null); const sidebarResizerRef = useRef(null); + // Compute current max sizes so callers can use them for aria attributes without duplicating logic. + // Evaluated at render time (from the same values the containers receive), so they stay in sync. + const sidebarMaxWidth = computeSidebarMaxWidth(panelPosition, rightPanelWidth, showPanel); + const panelMaxSize = computePanelMaxSize(panelPosition, navSize); + useEffect(() => { const panelResizer = panelResizerRef.current; const sidebarResizer = sidebarResizerRef.current; @@ -59,29 +157,40 @@ export function useDragging({ } }; - const onDragEnd = (e: MouseEvent) => { + const onDragEnd = () => { setState((state) => { if (draggedElement === sidebarResizer) { - if (state.navSize < SIDEBAR_MIN_WIDTH_PX && state.navSize > 0) { + if (state.navSize < MINIMUM_SIDEBAR_WIDTH_PX && state.navSize > 0) { // snap the sidebar back to its minimum width if it's smaller than the threshold return { ...state, isDragging: false, - navSize: SIDEBAR_MIN_WIDTH_PX, + navSize: MINIMUM_SIDEBAR_WIDTH_PX, }; } } if (draggedElement === panelResizer) { if ( state.panelPosition === 'right' && - state.rightPanelWidth < RIGHT_PANEL_MIN_WIDTH_PX && + state.rightPanelWidth < MINIMUM_RIGHT_PANEL_WIDTH_PX && state.rightPanelWidth > 0 ) { // snap the right panel back to its minimum width if it's smaller than the threshold return { ...state, isDragging: false, - rightPanelWidth: RIGHT_PANEL_MIN_WIDTH_PX, + rightPanelWidth: MINIMUM_RIGHT_PANEL_WIDTH_PX, + }; + } else if ( + state.panelPosition === 'bottom' && + state.bottomPanelHeight < MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX && + state.bottomPanelHeight > 0 + ) { + // snap the bottom panel back to its minimum height if it's smaller than the threshold + return { + ...state, + isDragging: false, + bottomPanelHeight: MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX, }; } } @@ -99,7 +208,7 @@ export function useDragging({ const onDrag = (e: MouseEvent) => { if (e.buttons === 0) { - onDragEnd(e); + onDragEnd(); return; } @@ -116,17 +225,20 @@ export function useDragging({ navSize: 0, }; } - if (sidebarDragX <= SIDEBAR_MIN_WIDTH_PX) { + if (sidebarDragX <= MINIMUM_SIDEBAR_WIDTH_PX) { // set sidebar width to a value in between the actual drag position and the min width, determined by the stiffness return { ...state, - navSize: interpolate(MIN_WIDTH_STIFFNESS, sidebarDragX, SIDEBAR_MIN_WIDTH_PX), + navSize: interpolate(STIFFNESS, sidebarDragX, MINIMUM_SIDEBAR_WIDTH_PX), }; } return { ...state, - // @ts-expect-error (non strict) - navSize: clamp(sidebarDragX, 0, e.view.innerWidth), + navSize: clamp( + sidebarDragX, + 0, + computeSidebarMaxWidth(state.panelPosition, state.rightPanelWidth, showPanel) + ), }; } if (draggedElement === panelResizer) { @@ -138,6 +250,10 @@ export function useDragging({ e.view.innerHeight - e.clientY : // @ts-expect-error (non strict) e.view.innerWidth - e.clientX; + const minimumSize = + state.panelPosition === 'bottom' + ? MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX + : MINIMUM_RIGHT_PANEL_WIDTH_PX; if (panelDragSize === state[sizeAxisState]) { return state; @@ -148,45 +264,88 @@ export function useDragging({ [sizeAxisState]: 0, }; } - if (state.panelPosition === 'right' && panelDragSize <= RIGHT_PANEL_MIN_WIDTH_PX) { - // set right panel width to a value in between the actual drag position and the min width, determined by the stiffness + // set panel width/height to a value in between the actual drag position and the min size, determined by the stiffness + if (panelDragSize <= minimumSize) { return { ...state, - [sizeAxisState]: interpolate( - MIN_WIDTH_STIFFNESS, - panelDragSize, - RIGHT_PANEL_MIN_WIDTH_PX - ), + [sizeAxisState]: interpolate(STIFFNESS, panelDragSize, minimumSize), }; } - const sizeAxisMax = - // @ts-expect-error (non strict) - state.panelPosition === 'bottom' ? e.view.innerHeight : e.view.innerWidth; return { ...state, - [sizeAxisState]: clamp(panelDragSize, 0, sizeAxisMax), + [sizeAxisState]: clamp( + panelDragSize, + 0, + computePanelMaxSize(state.panelPosition, state.navSize) + ), }; } return state; }); }; + const onSidebarKeyDown = (e: KeyboardEvent) => { + if (!RESIZE_KEYS.includes(e.key)) { + return; + } + e.preventDefault(); + const step = e.shiftKey ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER : KEYBOARD_STEP_PX; + setState((state) => + applyResizeKeyboard( + state, + 'navSize', + e.key, + step, + MINIMUM_SIDEBAR_WIDTH_PX, + computeSidebarMaxWidth(state.panelPosition, state.rightPanelWidth, showPanel), + 'ArrowRight', + 'ArrowLeft' + ) + ); + }; + + const onPanelKeyDown = (e: KeyboardEvent) => { + if (!RESIZE_KEYS.includes(e.key)) { + return; + } + e.preventDefault(); + const step = e.shiftKey ? KEYBOARD_STEP_PX * KEYBOARD_SHIFT_MULTIPLIER : KEYBOARD_STEP_PX; + setState((state) => + applyResizeKeyboard( + state, + state.panelPosition === 'bottom' ? 'bottomPanelHeight' : 'rightPanelWidth', + e.key, + step, + state.panelPosition === 'bottom' + ? MINIMUM_HORIZONTAL_PANEL_HEIGHT_PX + : MINIMUM_RIGHT_PANEL_WIDTH_PX, + computePanelMaxSize(state.panelPosition, state.navSize), + state.panelPosition === 'bottom' ? 'ArrowUp' : 'ArrowLeft', + state.panelPosition === 'bottom' ? 'ArrowDown' : 'ArrowRight' + ) + ); + }; + 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'); }; }, [ // we need to rerun this effect when the panel is shown/hidden or when changing between mobile/desktop to re-attach the event listeners - isPanelShown, + showPanel, isDesktop, setState, ]); - return { panelResizerRef, sidebarResizerRef }; + return { panelResizerRef, sidebarResizerRef, sidebarMaxWidth, panelMaxSize }; } diff --git a/code/core/src/manager/components/panel/Panel.tsx b/code/core/src/manager/components/panel/Panel.tsx index 1ff1b5eb07ba..7682692dbacd 100644 --- a/code/core/src/manager/components/panel/Panel.tsx +++ b/code/core/src/manager/components/panel/Panel.tsx @@ -17,6 +17,7 @@ import { BottomBarIcon, CloseIcon, DocumentIcon, SidebarAltIcon } from '@storybo import type { State } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; +import { focusableUIElements } from '../../../manager-api/modules/layout'; import { useLandmark } from '../../hooks/useLandmark'; import { useLayout } from '../layout/LayoutProvider'; @@ -160,7 +161,7 @@ export const AddonPanel = React.memo<{ ); return ( -