From b930bbe9998c1c8bfe21e0aa4353b50b103538e0 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Thu, 12 Mar 2026 04:30:39 +0300 Subject: [PATCH 01/13] fix(core): improve interactions panel accessibility --- .../components/Interaction.tsx | 57 +++++++--- .../components/InteractionsPanel.test.tsx | 107 ++++++++++++++++++ .../components/InteractionsPanel.tsx | 82 +++++++++++--- 3 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 code/core/src/component-testing/components/InteractionsPanel.test.tsx diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 36b1bf91634b..8f0c978092a1 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Button } from 'storybook/internal/components'; -import { ChevronDownIcon, ChevronUpIcon } from '@storybook/icons'; +import { ChevronDownIcon, ChevronRightIcon } from '@storybook/icons'; import { transparentize } from 'polished'; import { styled, typography } from 'storybook/theming'; @@ -22,10 +22,11 @@ const MethodCallWrapper = styled.div({ inlineSize: 'calc( 100% - 40px )', }); -const RowContainer = styled('div', { +const RowContainer = styled('li', { shouldForwardProp: (prop) => !['call', 'pausedAt'].includes(prop.toString()), })<{ call: Call; pausedAt: Call['id'] | undefined }>( ({ theme, call }) => ({ + listStyle: 'none', position: 'relative', display: 'flex', flexDirection: 'column', @@ -62,10 +63,12 @@ const RowContainer = styled('div', { } ); -const RowHeader = styled.div<{ isInteractive: boolean }>(({ theme, isInteractive }) => ({ - display: 'flex', - '&:hover': isInteractive ? {} : { background: theme.background.hoverable }, -})); +const RowHeader = styled.div<{ isNavigationDisabled: boolean }>( + ({ theme, isNavigationDisabled }) => ({ + display: 'flex', + '&:hover': isNavigationDisabled ? {} : { background: theme.background.hoverable }, + }) +); const RowLabel = styled('button', { shouldForwardProp: (prop) => !['call'].includes(prop.toString()), @@ -129,6 +132,27 @@ const ErrorExplainer = styled.p(({ theme }) => ({ textWrap: 'balance', })); +const stepStatusTextMap: Record, string> = { + [CallStates.DONE]: 'passed', + [CallStates.ERROR]: 'failed', + [CallStates.ACTIVE]: 'running', + [CallStates.WAITING]: 'pending', +}; + +const getInteractionLabel = (call: Call) => { + if (call.method === 'step' && call.path?.length === 0 && typeof call.args?.[0] === 'string') { + const label = call.args[0].trim(); + if (label.length > 0) { + return label; + } + } + + return call.method; +}; + +const getInteractionStatusText = (call: Call) => + call.status ? stepStatusTextMap[call.status] : 'pending'; + const Exception = ({ exception }: { exception: Call['exception'] }) => { const filter = useAnsiToHtmlFilter(); if (!exception) { @@ -194,7 +218,10 @@ export const Interaction = ({ pausedAt?: Call['id']; }) => { const [isHovered, setIsHovered] = React.useState(false); - const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors?.length; + const isNavigationDisabled = + !controlStates.goto || !call.interceptable || !!call.ancestors?.length; + const interactionLabel = getInteractionLabel(call); + const interactionStatus = getInteractionStatusText(call); if (isHidden) { return null; @@ -206,12 +233,14 @@ export const Interaction = ({ return ( - + controls.goto(call.id)} - disabled={isInteractive} + disabled={isNavigationDisabled} onMouseEnter={() => controlStates.goto && setIsHovered(true)} onMouseLeave={() => controlStates.goto && setIsHovered(false)} > @@ -226,10 +255,12 @@ export const Interaction = ({ padding="small" variant="ghost" onClick={toggleCollapsed} - ariaLabel={`${isCollapsed ? 'Show' : 'Hide'} steps`} + ariaLabel={`${ + isCollapsed ? 'Expand' : 'Collapse' + } nested interaction steps for ${interactionLabel}`} + aria-expanded={!isCollapsed} > - {/* FIXME: accordion pattern */} - {isCollapsed ? : } + {isCollapsed ? : } )} diff --git a/code/core/src/component-testing/components/InteractionsPanel.test.tsx b/code/core/src/component-testing/components/InteractionsPanel.test.tsx new file mode 100644 index 000000000000..5c0bca644a49 --- /dev/null +++ b/code/core/src/component-testing/components/InteractionsPanel.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import React from 'react'; + +import type { API } from 'storybook/manager-api'; +import { ThemeProvider, convert, themes } from 'storybook/theming'; + +import { CallStates } from '../../instrumenter/types'; +import { getCalls, getInteractions } from '../mocks'; +import { InteractionsPanel } from './InteractionsPanel'; + +type InteractionsPanelProps = React.ComponentProps; + +const createProps = (overrides: Partial = {}): InteractionsPanelProps => ({ + storyUrl: 'http://localhost:6006/?path=/story/core-component-test-basics--step', + status: 'completed', + controls: { + start: vi.fn(), + back: vi.fn(), + goto: vi.fn(), + next: vi.fn(), + end: vi.fn(), + rerun: vi.fn(), + }, + controlStates: { + detached: false, + start: true, + back: true, + goto: true, + next: true, + end: true, + }, + interactions: getInteractions(CallStates.DONE), + calls: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), + api: { openInEditor: vi.fn() } as unknown as API, + ...overrides, +}); + +const renderPanel = (props: InteractionsPanelProps) => + render( + + + + ); + +describe('InteractionsPanel', () => { + afterEach(() => { + cleanup(); + }); + + it('renders interaction steps as semantic list items with actionable labels', () => { + renderPanel(createProps()); + + const list = screen.getByRole('list'); + expect(list.tagName).toBe('OL'); + expect(within(list).getAllByRole('listitem').length).toBeGreaterThan(0); + expect( + screen.getByRole('button', { + name: 'Go to interaction step: Click button. Status: passed.', + }) + ).toBeInTheDocument(); + }); + + it('labels nested-step toggle buttons with action and expanded state', () => { + const interactions = getInteractions(CallStates.DONE).map((interaction) => + interaction.method === 'step' + ? { ...interaction, childCallIds: ['child-call-id'], isCollapsed: false } + : interaction + ); + + renderPanel(createProps({ interactions })); + + const toggle = screen.getByRole('button', { + name: 'Collapse nested interaction steps for Click button', + }); + + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('announces run status and marks the list as busy while tests are running', () => { + const { rerender } = renderPanel( + createProps({ + status: 'playing', + interactions: getInteractions(CallStates.ACTIVE), + }) + ); + + expect(screen.getByRole('status')).toHaveTextContent('Component test is running.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'); + + rerender( + + + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Component test failed.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); + }); +}); diff --git a/code/core/src/component-testing/components/InteractionsPanel.tsx b/code/core/src/component-testing/components/InteractionsPanel.tsx index 6042aaeea567..51f5a0d25146 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.tsx @@ -55,6 +55,32 @@ const Container = styled.div(({ theme }) => ({ background: theme.background.content, })); +const InteractionsSection = styled.section({ + position: 'relative', +}); + +const srOnlyStyles = { + border: 0, + clip: 'rect(0, 0, 0, 0)', + clipPath: 'inset(50%)', + height: 1, + margin: -1, + overflow: 'hidden', + padding: 0, + position: 'absolute' as const, + whiteSpace: 'nowrap' as const, + width: 1, +}; + +const InteractionsHeading = styled.h2(srOnlyStyles); + +const InteractionsList = styled.ol({ + margin: 0, + padding: 0, +}); + +const LiveStatus = styled.div(srOnlyStyles); + const CaughtException = styled.div(({ theme }) => ({ borderBottom: `1px solid ${theme.appBorderColor}`, backgroundColor: @@ -114,6 +140,20 @@ export const InteractionsPanel: React.FC = React.memo( }) { const filter = useAnsiToHtmlFilter(); const hasRealInteractions = interactions.some((i) => i.id !== INTERNAL_RENDER_CALL_ID); + const headingId = React.useId(); + const isListBusy = status === 'rendering' || status === 'playing'; + const statusAnnouncement = + status === 'rendering' + ? 'Component test is rendering.' + : status === 'playing' + ? 'Component test is running.' + : status === 'errored' + ? 'Component test failed.' + : status === 'aborted' + ? 'Component test was aborted.' + : hasException + ? 'Component test completed with errors.' + : 'Component test completed successfully.'; return ( @@ -131,22 +171,32 @@ export const InteractionsPanel: React.FC = React.memo( canOpenInEditor={canOpenInEditor} api={api} /> -
- {interactions.map((call) => ( - - ))} -
+ + {statusAnnouncement} + + + Interaction steps + + {interactions.map((call) => ( + + ))} + + {caughtException && !isTestAssertionError(caughtException) && ( From 8cb7dfb0746e4ee4eba84332b19061055191a683 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Thu, 12 Mar 2026 05:34:18 +0300 Subject: [PATCH 02/13] fix(core): address interactions panel review --- .../components/InteractionsPanel.test.tsx | 16 ++++++++++ .../components/InteractionsPanel.tsx | 29 +++++++++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/code/core/src/component-testing/components/InteractionsPanel.test.tsx b/code/core/src/component-testing/components/InteractionsPanel.test.tsx index 5c0bca644a49..0f9c85c55e9f 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.test.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.test.tsx @@ -79,6 +79,22 @@ describe('InteractionsPanel', () => { expect(toggle).toHaveAttribute('aria-expanded', 'true'); }); + it('labels nested-step toggle buttons with action and collapsed state', () => { + const interactions = getInteractions(CallStates.DONE).map((interaction) => + interaction.method === 'step' + ? { ...interaction, childCallIds: ['child-call-id'], isCollapsed: true } + : interaction + ); + + renderPanel(createProps({ interactions })); + + const toggle = screen.getByRole('button', { + name: 'Expand nested interaction steps for Click button', + }); + + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); + it('announces run status and marks the list as busy while tests are running', () => { const { rerender } = renderPanel( createProps({ diff --git a/code/core/src/component-testing/components/InteractionsPanel.tsx b/code/core/src/component-testing/components/InteractionsPanel.tsx index 51f5a0d25146..4b769d438cfd 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.tsx @@ -24,6 +24,7 @@ export interface Controls { } interface InteractionsPanelProps { + id?: string; storyUrl: string; status: PlayStatus; controls: Controls; @@ -117,8 +118,19 @@ const CaughtExceptionStack = styled.pre(({ theme }) => ({ fontSize: theme.typography.size.s1 - 1, })); +const StatusAnnouncementMapping: Record = { + rendering: 'Component test is rendering.', + playing: 'Component test is running.', + completed: 'Component test completed successfully.', + errored: 'Component test failed.', + aborted: 'Component test was aborted.', +} as const; + +let generatedHeadingId = 0; + export const InteractionsPanel: React.FC = React.memo( function InteractionsPanel({ + id, storyUrl, status, calls, @@ -140,20 +152,13 @@ export const InteractionsPanel: React.FC = React.memo( }) { const filter = useAnsiToHtmlFilter(); const hasRealInteractions = interactions.some((i) => i.id !== INTERNAL_RENDER_CALL_ID); - const headingId = React.useId(); + const autoHeadingId = React.useRef(id || `interactions-panel-${generatedHeadingId++}`); + const headingId = id || autoHeadingId.current; const isListBusy = status === 'rendering' || status === 'playing'; const statusAnnouncement = - status === 'rendering' - ? 'Component test is rendering.' - : status === 'playing' - ? 'Component test is running.' - : status === 'errored' - ? 'Component test failed.' - : status === 'aborted' - ? 'Component test was aborted.' - : hasException - ? 'Component test completed with errors.' - : 'Component test completed successfully.'; + status === 'completed' && hasException + ? 'Component test completed with errors.' + : StatusAnnouncementMapping[status]; return ( From f89c0c8babacc5ceb5373902435fec80c7134a53 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Thu, 12 Mar 2026 05:58:03 +0300 Subject: [PATCH 03/13] test(core): cover remaining interactions panel statuses --- .../components/InteractionsPanel.test.tsx | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/code/core/src/component-testing/components/InteractionsPanel.test.tsx b/code/core/src/component-testing/components/InteractionsPanel.test.tsx index 0f9c85c55e9f..83c62cc81543 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.test.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.test.tsx @@ -95,14 +95,28 @@ describe('InteractionsPanel', () => { expect(toggle).toHaveAttribute('aria-expanded', 'false'); }); - it('announces run status and marks the list as busy while tests are running', () => { + it('announces run status and updates busy state across lifecycle statuses', () => { const { rerender } = renderPanel( createProps({ - status: 'playing', + status: 'rendering', interactions: getInteractions(CallStates.ACTIVE), }) ); + expect(screen.getByRole('status')).toHaveTextContent('Component test is rendering.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'); + + rerender( + + + + ); + expect(screen.getByRole('status')).toHaveTextContent('Component test is running.'); expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'); @@ -119,5 +133,33 @@ describe('InteractionsPanel', () => { expect(screen.getByRole('alert')).toHaveTextContent('Component test failed.'); expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); + + rerender( + + + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Component test completed successfully.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); + + rerender( + + + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Component test was aborted.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); }); }); From 48543408580592fda9a310f394f315bd87696f13 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Fri, 20 Mar 2026 01:30:05 +0300 Subject: [PATCH 04/13] test(core): update interactions e2e selector --- code/e2e-tests/component-tests.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/e2e-tests/component-tests.spec.ts b/code/e2e-tests/component-tests.spec.ts index 806d7ff43cd6..81e6421a0702 100644 --- a/code/e2e-tests/component-tests.spec.ts +++ b/code/e2e-tests/component-tests.spec.ts @@ -77,7 +77,9 @@ test.describe('interactions', () => { await expect(panel).toBeVisible(); // Test interactions debugger - Stepping through works, count is correct and values are as expected - const interactionsRow = panel.locator('[aria-label="Interaction step"]'); + const interactionsRow = panel.getByRole('button', { + name: /^(Go to )?interaction step:/i, + }); await expect(interactionsRow.first()).toBeVisible(); From e650455fb1666271745b92f2fb89c0d0e608aeb6 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Tue, 24 Mar 2026 06:44:42 +0300 Subject: [PATCH 05/13] fix(Interaction): update RowHeader prop to use $isNavigationDisabled for styled component --- .../core/src/component-testing/components/Interaction.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 8f0c978092a1..456e8396346f 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -63,10 +63,10 @@ const RowContainer = styled('li', { } ); -const RowHeader = styled.div<{ isNavigationDisabled: boolean }>( - ({ theme, isNavigationDisabled }) => ({ +const RowHeader = styled.div<{ $isNavigationDisabled: boolean }>( + ({ theme, $isNavigationDisabled }) => ({ display: 'flex', - '&:hover': isNavigationDisabled ? {} : { background: theme.background.hoverable }, + '&:hover': $isNavigationDisabled ? {} : { background: theme.background.hoverable }, }) ); @@ -233,7 +233,7 @@ export const Interaction = ({ return ( - + Date: Tue, 24 Mar 2026 06:51:24 +0300 Subject: [PATCH 06/13] refactor(Interaction): enhance getInteractionLabel function and reorder stepStatusTextMap for clarity --- .../components/Interaction.test.ts | 85 +++++++++++++++++++ .../components/Interaction.tsx | 25 ++++-- 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 code/core/src/component-testing/components/Interaction.test.ts diff --git a/code/core/src/component-testing/components/Interaction.test.ts b/code/core/src/component-testing/components/Interaction.test.ts new file mode 100644 index 000000000000..809f79fd94e3 --- /dev/null +++ b/code/core/src/component-testing/components/Interaction.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest'; + +import type { Call } from '../../instrumenter/types'; +import { getInteractionLabel } from './Interaction'; + +const minimalCall = (overrides: Partial & Pick): Call => ({ + id: 'call-id', + cursor: 0, + storyId: 'story--id', + ancestors: [], + path: [], + interceptable: true, + retain: false, + ...overrides, +}); + +describe('getInteractionLabel', () => { + it('uses the first string arg for a top-level step call', () => { + expect( + getInteractionLabel( + minimalCall({ + method: 'step', + args: ['Click button', { __function__: { name: '' } }], + }) + ) + ).toBe('Click button'); + }); + + it('trims whitespace from the step label', () => { + expect( + getInteractionLabel( + minimalCall({ + method: 'step', + args: [' My step '], + }) + ) + ).toBe('My step'); + }); + + it('falls back to method when the step label is empty after trim', () => { + expect( + getInteractionLabel( + minimalCall({ + method: 'step', + args: [' '], + }) + ) + ).toBe('step'); + }); + + it('falls back to method when the step is nested (non-empty path)', () => { + expect( + getInteractionLabel( + minimalCall({ + method: 'step', + path: [{ __callId__: 'parent' }], + args: ['Should be ignored'], + }) + ) + ).toBe('step'); + }); + + it('falls back to method when the first arg is not a string', () => { + expect( + getInteractionLabel( + minimalCall({ + method: 'step', + args: [{ __function__: { name: 'fn' } }], + }) + ) + ).toBe('step'); + }); + + it('uses method for non-step calls', () => { + expect( + getInteractionLabel( + minimalCall({ + method: 'click', + args: [], + }) + ) + ).toBe('click'); + }); +}); diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 456e8396346f..d1c9b4c45872 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -132,14 +132,16 @@ const ErrorExplainer = styled.p(({ theme }) => ({ textWrap: 'balance', })); -const stepStatusTextMap: Record, string> = { - [CallStates.DONE]: 'passed', - [CallStates.ERROR]: 'failed', - [CallStates.ACTIVE]: 'running', - [CallStates.WAITING]: 'pending', -}; - -const getInteractionLabel = (call: Call) => { +/** + * Short name for an interaction row and its accessible labels. + * + * Play-function `step('…')` calls are recorded with `method === 'step'` and the user-facing + * description in `args[0]`. For a **top-level** step (`path` is empty) with a non-empty string + * there, that string is used so the UI matches what the author wrote. + * + * Otherwise we use `call.method` (e.g. `click`, `expect`, nested steps without their own title). + */ +export const getInteractionLabel = (call: Call) => { if (call.method === 'step' && call.path?.length === 0 && typeof call.args?.[0] === 'string') { const label = call.args[0].trim(); if (label.length > 0) { @@ -150,6 +152,13 @@ const getInteractionLabel = (call: Call) => { return call.method; }; +const stepStatusTextMap: Record, string> = { + [CallStates.DONE]: 'passed', + [CallStates.ERROR]: 'failed', + [CallStates.ACTIVE]: 'running', + [CallStates.WAITING]: 'pending', +}; + const getInteractionStatusText = (call: Call) => call.status ? stepStatusTextMap[call.status] : 'pending'; From 9049477c55a3c771f076883f3ab316583a7e2b11 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Tue, 24 Mar 2026 06:54:09 +0300 Subject: [PATCH 07/13] fix(Interaction): update getInteractionStatusText to return 'not run' for undefined status --- code/core/src/component-testing/components/Interaction.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index d1c9b4c45872..3f639e5649ac 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -160,7 +160,7 @@ const stepStatusTextMap: Record, string> = { }; const getInteractionStatusText = (call: Call) => - call.status ? stepStatusTextMap[call.status] : 'pending'; + call.status ? stepStatusTextMap[call.status] : 'not run'; const Exception = ({ exception }: { exception: Call['exception'] }) => { const filter = useAnsiToHtmlFilter(); From ab249a4f9ed663b2ef87e15a99607b9be1a90a85 Mon Sep 17 00:00:00 2001 From: Andrei Chmelev Date: Thu, 26 Mar 2026 03:46:50 +0300 Subject: [PATCH 08/13] fix(core): address interaction accessibility review feedback --- .../components/Interaction.stories.tsx | 146 +++++++++++++++- .../components/Interaction.test.ts | 85 --------- .../components/Interaction.tsx | 41 ++++- .../components/InteractionsPanel.stories.tsx | 164 ++++++++++++++++- .../components/InteractionsPanel.test.tsx | 165 ------------------ .../components/InteractionsPanel.tsx | 42 ++--- code/e2e-tests/component-tests.spec.ts | 2 +- 7 files changed, 353 insertions(+), 292 deletions(-) delete mode 100644 code/core/src/component-testing/components/Interaction.test.ts delete mode 100644 code/core/src/component-testing/components/InteractionsPanel.test.tsx diff --git a/code/core/src/component-testing/components/Interaction.stories.tsx b/code/core/src/component-testing/components/Interaction.stories.tsx index 32894427c162..7ff311902550 100644 --- a/code/core/src/component-testing/components/Interaction.stories.tsx +++ b/code/core/src/component-testing/components/Interaction.stories.tsx @@ -2,13 +2,27 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, within } from 'storybook/test'; -import { CallStates } from '../../instrumenter/types.ts'; +import { type Call, CallStates } from '../../instrumenter/types.ts'; import { getCalls } from '../mocks/index.ts'; import { Interaction } from './Interaction.tsx'; import ToolbarStories from './Toolbar.stories.tsx'; type Story = StoryObj; +const createCall = (overrides: Partial = {}): Call => ({ + id: 'story--id [interaction]', + storyId: 'story--id', + cursor: 1, + ancestors: [], + path: [], + method: 'step', + args: ['Click button', { __function__: { name: '' } }], + interceptable: true, + retain: false, + status: CallStates.DONE, + ...overrides, +}); + export default { title: 'Interaction', component: Interaction, @@ -16,6 +30,10 @@ export default { callsById: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), controls: ToolbarStories.args.controls, controlStates: ToolbarStories.args.controlStates, + isHidden: false, + isCollapsed: false, + childCallIds: undefined, + toggleCollapsed: () => {}, }, } as Meta; @@ -51,7 +69,15 @@ export const Failed: Story = { export const Done: Story = { args: { - call: getCalls(CallStates.DONE, -1)[0], + call: getCalls(CallStates.DONE)[1], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Go to interaction row: Click button. Status: passed.', + }) + ).toBeInTheDocument(); }, }; @@ -63,6 +89,122 @@ export const WithParent: Story = { export const Disabled: Story = { args: { ...Done.args, controlStates: { ...ToolbarStories.args.controlStates, goto: false } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Interaction row: Click button. Status: passed.', + }) + ).toBeInTheDocument(); + }, +}; + +export const TrimmedStepLabelAria: Story = { + args: { + call: createCall({ args: [' My step '] }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Go to interaction row: My step. Status: passed.', + }) + ).toBeInTheDocument(); + }, +}; + +export const EmptyStepLabelFallbackAria: Story = { + args: { + call: createCall({ + args: [' '], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Go to interaction row: step. Status: passed.', + }) + ).toBeInTheDocument(); + }, +}; + +/** + * When `step` has no user label, `extractStepName` is the method name `step` — row ARIA must stay + * readable. + */ +export const StepMethodFallbackAria: Story = { + args: { + call: { + id: 'story--id [step-fallback]', + storyId: 'story--id', + cursor: 1, + ancestors: [], + path: [], + method: 'step', + args: [], + interceptable: true, + retain: false, + status: CallStates.WAITING, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Go to interaction row: step. Status: pending.', + }) + ).toBeInTheDocument(); + }, +}; + +export const NestedStepMethodFallbackAria: Story = { + args: { + call: createCall({ + path: ['nested'], + args: ['Should be ignored'], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Go to interaction row: step. Status: passed.', + }) + ).toBeInTheDocument(); + }, +}; + +export const ExpandedNestedStepAria: Story = { + args: { + call: createCall(), + childCallIds: ['child-call-id'], + isCollapsed: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Collapse nested interaction steps for Click button', + }) + ).toHaveAttribute('aria-expanded', 'true'); + }, +}; + +export const CollapsedNestedStepAria: Story = { + args: { + call: createCall(), + childCallIds: ['child-call-id'], + isCollapsed: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('button', { + name: 'Expand nested interaction steps for Click button', + }) + ).toHaveAttribute('aria-expanded', 'false'); + }, }; export const Hovered: Story = { diff --git a/code/core/src/component-testing/components/Interaction.test.ts b/code/core/src/component-testing/components/Interaction.test.ts deleted file mode 100644 index 809f79fd94e3..000000000000 --- a/code/core/src/component-testing/components/Interaction.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it } from 'vitest'; - -import type { Call } from '../../instrumenter/types'; -import { getInteractionLabel } from './Interaction'; - -const minimalCall = (overrides: Partial & Pick): Call => ({ - id: 'call-id', - cursor: 0, - storyId: 'story--id', - ancestors: [], - path: [], - interceptable: true, - retain: false, - ...overrides, -}); - -describe('getInteractionLabel', () => { - it('uses the first string arg for a top-level step call', () => { - expect( - getInteractionLabel( - minimalCall({ - method: 'step', - args: ['Click button', { __function__: { name: '' } }], - }) - ) - ).toBe('Click button'); - }); - - it('trims whitespace from the step label', () => { - expect( - getInteractionLabel( - minimalCall({ - method: 'step', - args: [' My step '], - }) - ) - ).toBe('My step'); - }); - - it('falls back to method when the step label is empty after trim', () => { - expect( - getInteractionLabel( - minimalCall({ - method: 'step', - args: [' '], - }) - ) - ).toBe('step'); - }); - - it('falls back to method when the step is nested (non-empty path)', () => { - expect( - getInteractionLabel( - minimalCall({ - method: 'step', - path: [{ __callId__: 'parent' }], - args: ['Should be ignored'], - }) - ) - ).toBe('step'); - }); - - it('falls back to method when the first arg is not a string', () => { - expect( - getInteractionLabel( - minimalCall({ - method: 'step', - args: [{ __function__: { name: 'fn' } }], - }) - ) - ).toBe('step'); - }); - - it('uses method for non-step calls', () => { - expect( - getInteractionLabel( - minimalCall({ - method: 'click', - args: [], - }) - ) - ).toBe('click'); - }); -}); diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 3f639e5649ac..0a996803ea57 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -133,7 +133,7 @@ const ErrorExplainer = styled.p(({ theme }) => ({ })); /** - * Short name for an interaction row and its accessible labels. + * Human-readable name for an interaction row (visible text and ARIA), matching instrumented data. * * Play-function `step('…')` calls are recorded with `method === 'step'` and the user-facing * description in `args[0]`. For a **top-level** step (`path` is empty) with a non-empty string @@ -141,7 +141,7 @@ const ErrorExplainer = styled.p(({ theme }) => ({ * * Otherwise we use `call.method` (e.g. `click`, `expect`, nested steps without their own title). */ -export const getInteractionLabel = (call: Call) => { +export const extractStepName = (call: Call) => { if (call.method === 'step' && call.path?.length === 0 && typeof call.args?.[0] === 'string') { const label = call.args[0].trim(); if (label.length > 0) { @@ -152,6 +152,29 @@ export const getInteractionLabel = (call: Call) => { return call.method; }; +/** + * Accessible name for the main row control. Uses "interaction row" wording so we never combine + * "Interaction step" with a `stepName` of `step` (awkward "step … step" for screen readers). + */ +export const getRowAriaLabel = ({ + isNavigationDisabled, + stepName, + statusText, +}: { + isNavigationDisabled: boolean; + stepName: string; + statusText: string; +}) => + `${isNavigationDisabled ? 'Interaction row' : 'Go to interaction row'}: ${stepName}. Status: ${statusText}.`; + +export const getExpandButtonAriaLabel = ({ + isCollapsed, + stepName, +}: { + isCollapsed: boolean; + stepName: string; +}) => `${isCollapsed ? 'Expand' : 'Collapse'} nested interaction steps for ${stepName}`; + const stepStatusTextMap: Record, string> = { [CallStates.DONE]: 'passed', [CallStates.ERROR]: 'failed', @@ -229,7 +252,7 @@ export const Interaction = ({ const [isHovered, setIsHovered] = React.useState(false); const isNavigationDisabled = !controlStates.goto || !call.interceptable || !!call.ancestors?.length; - const interactionLabel = getInteractionLabel(call); + const stepName = extractStepName(call); const interactionStatus = getInteractionStatusText(call); if (isHidden) { @@ -244,9 +267,11 @@ export const Interaction = ({ controls.goto(call.id)} disabled={isNavigationDisabled} @@ -264,9 +289,7 @@ export const Interaction = ({ padding="small" variant="ghost" onClick={toggleCollapsed} - ariaLabel={`${ - isCollapsed ? 'Expand' : 'Collapse' - } nested interaction steps for ${interactionLabel}`} + ariaLabel={getExpandButtonAriaLabel({ isCollapsed, stepName })} aria-expanded={!isCollapsed} > {isCollapsed ? : } diff --git a/code/core/src/component-testing/components/InteractionsPanel.stories.tsx b/code/core/src/component-testing/components/InteractionsPanel.stories.tsx index c91899488e18..e9e446f6c2f2 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.stories.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { ManagerContext } from 'storybook/manager-api'; -import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { styled } from 'storybook/theming'; import { isChromatic } from '../../../../.storybook/isChromatic.ts'; @@ -70,16 +70,58 @@ export default meta; type Story = StoryObj; +const withNestedStepToggle = (isCollapsed: boolean) => { + return getInteractions(CallStates.DONE).map((interaction) => + interaction.method === 'step' + ? { ...interaction, childCallIds: ['child-call-id'], isCollapsed } + : interaction + ); +}; + +const expectLiveAnnouncement = async ({ + canvas, + role, + ariaLive, + text, + isListBusy, +}: { + canvas: ReturnType; + role: 'status' | 'alert'; + ariaLive: 'polite' | 'assertive'; + text: string; + isListBusy: boolean; +}) => { + const announcement = canvas.getByRole(role); + + await expect(announcement).toHaveAttribute('aria-live', ariaLive); + await expect(announcement).toHaveTextContent(text); + await expect(canvas.getByRole('list')).toHaveAttribute( + 'aria-busy', + isListBusy ? 'true' : 'false' + ); +}; + export const Passing: Story = { args: { browserTestStatus: CallStates.DONE, interactions: getInteractions(CallStates.DONE), }, play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Expose the completed run status for assistive tech', async () => { + await expectLiveAnnouncement({ + canvas, + role: 'status', + ariaLive: 'polite', + text: 'Component test completed successfully.', + isListBusy: false, + }); + }); + if (isChromatic()) { return; } - const canvas = within(canvasElement); await step('Go to start', async () => { const btn = await canvas.findByLabelText('Go to start'); @@ -113,6 +155,44 @@ export const Passing: Story = { }, }; +export const AccessibilityLabels: Story = { + args: { + interactions: withNestedStepToggle(false), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const list = canvas.getByRole('list'); + + expect(list.tagName).toBe('OL'); + expect(within(list).getAllByRole('listitem').length).toBeGreaterThan(0); + await expect( + canvas.getByRole('button', { + name: 'Go to interaction row: Click button. Status: passed.', + }) + ).toBeInTheDocument(); + await expect( + canvas.getByRole('button', { + name: 'Collapse nested interaction steps for Click button', + }) + ).toHaveAttribute('aria-expanded', 'true'); + }, +}; + +export const CollapsedNestedStep: Story = { + args: { + interactions: withNestedStepToggle(true), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole('button', { + name: 'Expand nested interaction steps for Click button', + }) + ).toHaveAttribute('aria-expanded', 'false'); + }, +}; + export const Paused: Story = { args: { status: 'playing', @@ -136,6 +216,17 @@ export const Playing: Story = { browserTestStatus: CallStates.ACTIVE, interactions: getInteractions(CallStates.ACTIVE), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectLiveAnnouncement({ + canvas, + role: 'status', + ariaLive: 'polite', + text: 'Component test is running.', + isListBusy: true, + }); + }, }; export const Failed: Story = { @@ -145,6 +236,17 @@ export const Failed: Story = { hasException: true, interactions: getInteractions(CallStates.ERROR), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectLiveAnnouncement({ + canvas, + role: 'alert', + ariaLive: 'assertive', + text: 'Component test failed.', + isListBusy: false, + }); + }, }; export const CaughtException: Story = { @@ -186,6 +288,64 @@ export const RenderOnly: Story = { }, }; +export const Rendering: Story = { + args: { + status: 'rendering', + browserTestStatus: CallStates.ACTIVE, + interactions: getInteractions(CallStates.ACTIVE), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectLiveAnnouncement({ + canvas, + role: 'status', + ariaLive: 'polite', + text: 'Component test is rendering.', + isListBusy: true, + }); + }, +}; + +export const CompletedWithException: Story = { + args: { + status: 'completed', + browserTestStatus: CallStates.ERROR, + hasException: true, + interactions: getInteractions(CallStates.DONE), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectLiveAnnouncement({ + canvas, + role: 'alert', + ariaLive: 'assertive', + text: 'Component test failed.', + isListBusy: false, + }); + }, +}; + +export const Aborted: Story = { + args: { + status: 'aborted', + browserTestStatus: CallStates.DONE, + interactions: getInteractions(CallStates.DONE), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectLiveAnnouncement({ + canvas, + role: 'status', + ariaLive: 'polite', + text: 'Component test was aborted.', + isListBusy: false, + }); + }, +}; + export const Empty: Story = { args: { interactions: [], diff --git a/code/core/src/component-testing/components/InteractionsPanel.test.tsx b/code/core/src/component-testing/components/InteractionsPanel.test.tsx deleted file mode 100644 index 83c62cc81543..000000000000 --- a/code/core/src/component-testing/components/InteractionsPanel.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, render, screen, within } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import React from 'react'; - -import type { API } from 'storybook/manager-api'; -import { ThemeProvider, convert, themes } from 'storybook/theming'; - -import { CallStates } from '../../instrumenter/types'; -import { getCalls, getInteractions } from '../mocks'; -import { InteractionsPanel } from './InteractionsPanel'; - -type InteractionsPanelProps = React.ComponentProps; - -const createProps = (overrides: Partial = {}): InteractionsPanelProps => ({ - storyUrl: 'http://localhost:6006/?path=/story/core-component-test-basics--step', - status: 'completed', - controls: { - start: vi.fn(), - back: vi.fn(), - goto: vi.fn(), - next: vi.fn(), - end: vi.fn(), - rerun: vi.fn(), - }, - controlStates: { - detached: false, - start: true, - back: true, - goto: true, - next: true, - end: true, - }, - interactions: getInteractions(CallStates.DONE), - calls: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), - api: { openInEditor: vi.fn() } as unknown as API, - ...overrides, -}); - -const renderPanel = (props: InteractionsPanelProps) => - render( - - - - ); - -describe('InteractionsPanel', () => { - afterEach(() => { - cleanup(); - }); - - it('renders interaction steps as semantic list items with actionable labels', () => { - renderPanel(createProps()); - - const list = screen.getByRole('list'); - expect(list.tagName).toBe('OL'); - expect(within(list).getAllByRole('listitem').length).toBeGreaterThan(0); - expect( - screen.getByRole('button', { - name: 'Go to interaction step: Click button. Status: passed.', - }) - ).toBeInTheDocument(); - }); - - it('labels nested-step toggle buttons with action and expanded state', () => { - const interactions = getInteractions(CallStates.DONE).map((interaction) => - interaction.method === 'step' - ? { ...interaction, childCallIds: ['child-call-id'], isCollapsed: false } - : interaction - ); - - renderPanel(createProps({ interactions })); - - const toggle = screen.getByRole('button', { - name: 'Collapse nested interaction steps for Click button', - }); - - expect(toggle).toHaveAttribute('aria-expanded', 'true'); - }); - - it('labels nested-step toggle buttons with action and collapsed state', () => { - const interactions = getInteractions(CallStates.DONE).map((interaction) => - interaction.method === 'step' - ? { ...interaction, childCallIds: ['child-call-id'], isCollapsed: true } - : interaction - ); - - renderPanel(createProps({ interactions })); - - const toggle = screen.getByRole('button', { - name: 'Expand nested interaction steps for Click button', - }); - - expect(toggle).toHaveAttribute('aria-expanded', 'false'); - }); - - it('announces run status and updates busy state across lifecycle statuses', () => { - const { rerender } = renderPanel( - createProps({ - status: 'rendering', - interactions: getInteractions(CallStates.ACTIVE), - }) - ); - - expect(screen.getByRole('status')).toHaveTextContent('Component test is rendering.'); - expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'); - - rerender( - - - - ); - - expect(screen.getByRole('status')).toHaveTextContent('Component test is running.'); - expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'); - - rerender( - - - - ); - - expect(screen.getByRole('alert')).toHaveTextContent('Component test failed.'); - expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); - - rerender( - - - - ); - - expect(screen.getByRole('status')).toHaveTextContent('Component test completed successfully.'); - expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); - - rerender( - - - - ); - - expect(screen.getByRole('status')).toHaveTextContent('Component test was aborted.'); - expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); - }); -}); diff --git a/code/core/src/component-testing/components/InteractionsPanel.tsx b/code/core/src/component-testing/components/InteractionsPanel.tsx index 4b769d438cfd..2d8b5a3e542c 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { transparentize } from 'polished'; import type { API } from 'storybook/manager-api'; -import { styled } from 'storybook/theming'; +import { srOnlyStyles, styled } from 'storybook/theming'; import { type Call, type CallStates, type ControlStates } from '../../instrumenter/types.ts'; import { INTERNAL_RENDER_CALL_ID } from '../constants.ts'; @@ -24,7 +24,6 @@ export interface Controls { } interface InteractionsPanelProps { - id?: string; storyUrl: string; status: PlayStatus; controls: Controls; @@ -60,20 +59,7 @@ const InteractionsSection = styled.section({ position: 'relative', }); -const srOnlyStyles = { - border: 0, - clip: 'rect(0, 0, 0, 0)', - clipPath: 'inset(50%)', - height: 1, - margin: -1, - overflow: 'hidden', - padding: 0, - position: 'absolute' as const, - whiteSpace: 'nowrap' as const, - width: 1, -}; - -const InteractionsHeading = styled.h2(srOnlyStyles); +const InteractionsHeading = styled.h3(srOnlyStyles); const InteractionsList = styled.ol({ margin: 0, @@ -126,11 +112,15 @@ const StatusAnnouncementMapping: Record = { aborted: 'Component test was aborted.', } as const; -let generatedHeadingId = 0; +const getStatusAnnouncement = (status: PlayStatus, hasException?: boolean) => { + if (status === 'completed' && hasException) { + return StatusAnnouncementMapping.errored; + } + return StatusAnnouncementMapping[status]; +}; export const InteractionsPanel: React.FC = React.memo( function InteractionsPanel({ - id, storyUrl, status, calls, @@ -152,13 +142,9 @@ export const InteractionsPanel: React.FC = React.memo( }) { const filter = useAnsiToHtmlFilter(); const hasRealInteractions = interactions.some((i) => i.id !== INTERNAL_RENDER_CALL_ID); - const autoHeadingId = React.useRef(id || `interactions-panel-${generatedHeadingId++}`); - const headingId = id || autoHeadingId.current; const isListBusy = status === 'rendering' || status === 'playing'; - const statusAnnouncement = - status === 'completed' && hasException - ? 'Component test completed with errors.' - : StatusAnnouncementMapping[status]; + const statusAnnouncement = getStatusAnnouncement(status, hasException); + const isStatusAlert = status === 'errored' || (status === 'completed' && hasException); return ( @@ -177,14 +163,14 @@ export const InteractionsPanel: React.FC = React.memo( api={api} /> {statusAnnouncement} - - Interaction steps + + Interaction steps {interactions.map((call) => ( { // Test interactions debugger - Stepping through works, count is correct and values are as expected const interactionsRow = panel.getByRole('button', { - name: /^(Go to )?interaction step:/i, + name: /^(Go to )?interaction row:/i, }); await expect(interactionsRow.first()).toBeVisible(); From f555699ad87baa7a1e7a308a8cb6ed6b5757a7d9 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 2 Apr 2026 18:21:59 +0200 Subject: [PATCH 09/13] Fix up small details --- .../component-testing/components/Interaction.stories.tsx | 7 +++++++ code/core/src/component-testing/components/Interaction.tsx | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/code/core/src/component-testing/components/Interaction.stories.tsx b/code/core/src/component-testing/components/Interaction.stories.tsx index 7ff311902550..449a8f5ed936 100644 --- a/code/core/src/component-testing/components/Interaction.stories.tsx +++ b/code/core/src/component-testing/components/Interaction.stories.tsx @@ -26,6 +26,13 @@ const createCall = (overrides: Partial = {}): Call => ({ export default { title: 'Interaction', component: Interaction, + decorators: [ + (Story) => ( +
    + +
+ ), + ], args: { callsById: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), controls: ToolbarStories.args.controls, diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 0a996803ea57..85021b652dd1 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Button } from 'storybook/internal/components'; -import { ChevronDownIcon, ChevronRightIcon } from '@storybook/icons'; +import { ChevronDownIcon, ChevronUpIcon } from '@storybook/icons'; import { transparentize } from 'polished'; import { styled, typography } from 'storybook/theming'; @@ -292,7 +292,7 @@ export const Interaction = ({ ariaLabel={getExpandButtonAriaLabel({ isCollapsed, stepName })} aria-expanded={!isCollapsed} > - {isCollapsed ? : } + {isCollapsed ? : } )} From e1b9dab16f01793e7b386cfdfe6fb1c91c211bc6 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 2 Apr 2026 19:06:06 +0200 Subject: [PATCH 10/13] Write live area tests with new RAC utils --- .../components/InteractionsPanel.stories.tsx | 17 +++++------------ .../components/InteractionsPanel.tsx | 14 +++++--------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/code/core/src/component-testing/components/InteractionsPanel.stories.tsx b/code/core/src/component-testing/components/InteractionsPanel.stories.tsx index e9e446f6c2f2..00e99a3d4277 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.stories.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.stories.tsx @@ -11,6 +11,7 @@ import { CallStates } from '../../instrumenter/types.ts'; import { getCalls, getInteractions } from '../mocks/index.ts'; import { InteractionsPanel } from './InteractionsPanel.tsx'; import ToolbarStories from './Toolbar.stories.tsx'; +import { destroyAnnouncer } from '@react-aria/live-announcer'; const StyledWrapper = styled.div(({ theme }) => ({ backgroundColor: theme.background.content, @@ -64,6 +65,9 @@ const meta = { // prop for the AddonPanel used as wrapper of Panel active: true, }, + beforeEach: () => { + destroyAnnouncer(); + }, } as Meta; export default meta; @@ -80,21 +84,16 @@ const withNestedStepToggle = (isCollapsed: boolean) => { const expectLiveAnnouncement = async ({ canvas, - role, ariaLive, text, isListBusy, }: { canvas: ReturnType; - role: 'status' | 'alert'; ariaLive: 'polite' | 'assertive'; text: string; isListBusy: boolean; }) => { - const announcement = canvas.getByRole(role); - - await expect(announcement).toHaveAttribute('aria-live', ariaLive); - await expect(announcement).toHaveTextContent(text); + await expect(document.body).toHaveLiveRegion({ text, level: ariaLive }); await expect(canvas.getByRole('list')).toHaveAttribute( 'aria-busy', isListBusy ? 'true' : 'false' @@ -112,7 +111,6 @@ export const Passing: Story = { await step('Expose the completed run status for assistive tech', async () => { await expectLiveAnnouncement({ canvas, - role: 'status', ariaLive: 'polite', text: 'Component test completed successfully.', isListBusy: false, @@ -221,7 +219,6 @@ export const Playing: Story = { await expectLiveAnnouncement({ canvas, - role: 'status', ariaLive: 'polite', text: 'Component test is running.', isListBusy: true, @@ -241,7 +238,6 @@ export const Failed: Story = { await expectLiveAnnouncement({ canvas, - role: 'alert', ariaLive: 'assertive', text: 'Component test failed.', isListBusy: false, @@ -299,7 +295,6 @@ export const Rendering: Story = { await expectLiveAnnouncement({ canvas, - role: 'status', ariaLive: 'polite', text: 'Component test is rendering.', isListBusy: true, @@ -319,7 +314,6 @@ export const CompletedWithException: Story = { await expectLiveAnnouncement({ canvas, - role: 'alert', ariaLive: 'assertive', text: 'Component test failed.', isListBusy: false, @@ -338,7 +332,6 @@ export const Aborted: Story = { await expectLiveAnnouncement({ canvas, - role: 'status', ariaLive: 'polite', text: 'Component test was aborted.', isListBusy: false, diff --git a/code/core/src/component-testing/components/InteractionsPanel.tsx b/code/core/src/component-testing/components/InteractionsPanel.tsx index 2d8b5a3e542c..433fb691150f 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { transparentize } from 'polished'; +import { announce } from '@react-aria/live-announcer'; import type { API } from 'storybook/manager-api'; import { srOnlyStyles, styled } from 'storybook/theming'; @@ -66,8 +67,6 @@ const InteractionsList = styled.ol({ padding: 0, }); -const LiveStatus = styled.div(srOnlyStyles); - const CaughtException = styled.div(({ theme }) => ({ borderBottom: `1px solid ${theme.appBorderColor}`, backgroundColor: @@ -146,6 +145,10 @@ export const InteractionsPanel: React.FC = React.memo( const statusAnnouncement = getStatusAnnouncement(status, hasException); const isStatusAlert = status === 'errored' || (status === 'completed' && hasException); + React.useEffect(() => { + announce(statusAnnouncement, isStatusAlert ? 'assertive' : 'polite'); + }, [statusAnnouncement, isStatusAlert]); + return ( {hasResultMismatch && } @@ -162,13 +165,6 @@ export const InteractionsPanel: React.FC = React.memo( canOpenInEditor={canOpenInEditor} api={api} /> - - {statusAnnouncement} - Interaction steps From 93ab78409f861d7c4fcc0d4df27ea916399380fc Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 2 Apr 2026 19:27:56 +0200 Subject: [PATCH 11/13] Add IDE support for toHaveLiveRegion in core --- code/core/custom-matchers.d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 code/core/custom-matchers.d.ts diff --git a/code/core/custom-matchers.d.ts b/code/core/custom-matchers.d.ts new file mode 100644 index 000000000000..a4c21bf91071 --- /dev/null +++ b/code/core/custom-matchers.d.ts @@ -0,0 +1,9 @@ +import 'storybook/test'; + +import type { LiveRegionMatcherOptions } from '../../../core/src/shared/utils/toHaveLiveRegion'; + +declare module 'storybook/test' { + interface Assertion { + toHaveLiveRegion(options: LiveRegionMatcherOptions): Promise; + } +} From bc82297a74fb4a4a19288a22733367fdc1bb062c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 3 Apr 2026 12:14:51 +0200 Subject: [PATCH 12/13] Fix VRT issue due to semantic decorator --- .../src/component-testing/components/Interaction.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/component-testing/components/Interaction.stories.tsx b/code/core/src/component-testing/components/Interaction.stories.tsx index 449a8f5ed936..30d1a389627a 100644 --- a/code/core/src/component-testing/components/Interaction.stories.tsx +++ b/code/core/src/component-testing/components/Interaction.stories.tsx @@ -28,7 +28,7 @@ export default { component: Interaction, decorators: [ (Story) => ( -
    +
    ), From 3f76e6f1c71b8d6b5617e6b376299e7f6a443f49 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 3 Apr 2026 13:08:07 +0200 Subject: [PATCH 13/13] Fix story VRT regressions --- .../components/Interaction.stories.tsx | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/code/core/src/component-testing/components/Interaction.stories.tsx b/code/core/src/component-testing/components/Interaction.stories.tsx index 30d1a389627a..1cd0ace8b2b0 100644 --- a/code/core/src/component-testing/components/Interaction.stories.tsx +++ b/code/core/src/component-testing/components/Interaction.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent } from 'storybook/test'; import { type Call, CallStates } from '../../instrumenter/types.ts'; import { getCalls } from '../mocks/index.ts'; @@ -76,13 +76,12 @@ export const Failed: Story = { export const Done: Story = { args: { - call: getCalls(CallStates.DONE)[1], + call: getCalls(CallStates.DONE, -1)[0], }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await expect( canvas.getByRole('button', { - name: 'Go to interaction row: Click button. Status: passed.', + name: 'Go to interaction row: toHaveBeenCalled. Status: passed.', }) ).toBeInTheDocument(); }, @@ -96,13 +95,11 @@ export const WithParent: Story = { export const Disabled: Story = { args: { ...Done.args, controlStates: { ...ToolbarStories.args.controlStates, goto: false } }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect( - canvas.getByRole('button', { - name: 'Interaction row: Click button. Status: passed.', - }) - ).toBeInTheDocument(); + play: async ({ canvas }) => { + const button = canvas.getByRole('button', { + name: 'Interaction row: toHaveBeenCalled. Status: passed.', + }); + await expect(button).toBeInTheDocument(); }, }; @@ -110,8 +107,7 @@ export const TrimmedStepLabelAria: Story = { args: { call: createCall({ args: [' My step '] }), }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await expect( canvas.getByRole('button', { name: 'Go to interaction row: My step. Status: passed.', @@ -126,8 +122,7 @@ export const EmptyStepLabelFallbackAria: Story = { args: [' '], }), }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await expect( canvas.getByRole('button', { name: 'Go to interaction row: step. Status: passed.', @@ -155,8 +150,7 @@ export const StepMethodFallbackAria: Story = { status: CallStates.WAITING, }, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await expect( canvas.getByRole('button', { name: 'Go to interaction row: step. Status: pending.', @@ -172,8 +166,7 @@ export const NestedStepMethodFallbackAria: Story = { args: ['Should be ignored'], }), }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await expect( canvas.getByRole('button', { name: 'Go to interaction row: step. Status: passed.', @@ -188,8 +181,7 @@ export const ExpandedNestedStepAria: Story = { childCallIds: ['child-call-id'], isCollapsed: false, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await expect( canvas.getByRole('button', { name: 'Collapse nested interaction steps for Click button', @@ -204,8 +196,7 @@ export const CollapsedNestedStepAria: Story = { childCallIds: ['child-call-id'], isCollapsed: true, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await expect( canvas.getByRole('button', { name: 'Expand nested interaction steps for Click button', @@ -217,8 +208,7 @@ export const CollapsedNestedStepAria: Story = { export const Hovered: Story = { ...Done, globals: { sb_theme: 'light' }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + play: async ({ canvas }) => { await userEvent.hover(canvas.getByRole('button')); await expect(canvas.getByTestId('icon-active')).toBeInTheDocument(); },