diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0554b558a910..9db2e37e5327a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1580,6 +1580,56 @@ jobs: retention-days: 30 if-no-files-found: warn + # The menu-button contract's control role and name, the focus landing on + # Enter, Space, ArrowDown, and ArrowUp opens, and the pointer round trip + # are accessibility-tree and real-browser facts that jsdom cannot observe. + # Run the contract's own fixture proof and the DropdownMenu/MoreMenu + # binding whenever either component's source, the binding's stories, or + # the contract changes. Its own --output directory; the JSON report is + # the head-bound record, uploaded next. + - name: Run Menu Button accessibility contract + id: menu-button-contract + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + PLAYWRIGHT_JSON_OUTPUT_FILE: menu-button-contract/report.json + run: | + should_run=false + # DropdownMenu, MoreMenu, and the contract are matched by file name. + # The helper comes from the base, since an older PR head lacks it. + # Exit 1 means out of scope; 0 in scope; 2 unknown, so run. + scope=2 + helper="$(mktemp)" + if git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \ + && git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then + scope=0 + bash "$helper" "$BASE_SHA" \ + '^(internal/a11y-spec/src/patterns/menu-button|packages/core/src/DropdownMenu/|packages/core/src/MoreMenu/|apps/storybook/stories/MenuButtonA11y)' || scope=$? + fi + if [ "$scope" != 1 ]; then + should_run=true + fi + echo "should_run=${should_run}" >> "$GITHUB_OUTPUT" + if [ "$should_run" != true ]; then + echo "DropdownMenu, MoreMenu, and the menu-button contract are not in PR scope; skipping menu-button evidence." + exit 0 + fi + pnpm exec playwright test \ + internal/a11y-spec/src/patterns/menu-button.chromium.spec.ts \ + packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts \ + --project chromium \ + --reporter list,json \ + --output menu-button-contract-output + + - name: Upload Menu Button accessibility contract evidence + if: ${{ !cancelled() && steps.menu-button-contract.outputs.should_run == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: menu-button-contract-evidence-${{ needs.build-storybook.outputs.short_hash }} + path: menu-button-contract/ + retention-days: 30 + if-no-files-found: warn + # The accordion contract's header role and name, the page Tab step between # headers, the absence of arrow-key focus movement, and the single-expansion # sibling collapse are accessibility-tree and real-browser facts that jsdom diff --git a/apps/storybook/stories/MenuButtonA11y.stories.tsx b/apps/storybook/stories/MenuButtonA11y.stories.tsx new file mode 100644 index 0000000000000..b53899f1f1527 --- /dev/null +++ b/apps/storybook/stories/MenuButtonA11y.stories.tsx @@ -0,0 +1,42 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MenuButtonA11y.stories.tsx + * @input The DropdownMenu and MoreMenu menu-button binding states + * @output One checked-in story per state for the Chromium accessibility lane; excluded from the visual matrix + * @position Browser fixtures for component:DropdownMenu AR4 and component:MoreMenu AR1 (MENU_BUTTON_PATTERN) + */ + +import type {Meta, StoryObj} from '@storybook/react'; +import {renderMenuButtonState} from '../../../packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders'; +import {MENU_BUTTON_BINDING_STATES} from '../../../packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states'; + +const meta = { + title: 'A11y/MenuButton', + tags: ['no-visual'], + parameters: {layout: 'padded'}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function storyFor( + id: (typeof MENU_BUTTON_BINDING_STATES)[number]['id'], +): Story { + const state = MENU_BUTTON_BINDING_STATES.find( + candidate => candidate.id === id, + ); + if (state == null) { + throw new Error(`no Menu Button binding state "${id}"`); + } + return {render: () => renderMenuButtonState(state)}; +} + +export const DropdownButtonClosed = storyFor('dropdown-button-closed'); +export const DropdownButtonOpen = storyFor('dropdown-button-open'); +export const DropdownRenderTriggerClosed = storyFor( + 'dropdown-render-trigger-closed', +); +export const DropdownButtonDisabled = storyFor('dropdown-button-disabled'); +export const MoreMenuClosed = storyFor('more-menu-closed'); +export const MoreMenuDisabled = storyFor('more-menu-disabled'); diff --git a/internal/a11y-spec/README.md b/internal/a11y-spec/README.md index 97ead2badc4a3..676f8471a2226 100644 --- a/internal/a11y-spec/README.md +++ b/internal/a11y-spec/README.md @@ -51,6 +51,7 @@ src/ ├── listbox.* listbox, group, and option semantics ├── spinbutton.* numeric role, value, bounds, state, and arrow stepping ├── disclosure.* standalone disclosure state/content semantics + ├── menu-button.* a control's button role and name, aria-haspopup, expanded state, controls, and keyboard opens ├── window-splitter.* a focusable resize separator's role, name, position/bounds, pane relationship, and arrow/Enter keys (Home/End advisory) ├── grid.* a composite grid's role, name, row/cell structure, single Tab stop, selection, and arrow/Home/End/Enter keys ├── alert-dialog.* a confirmation surface's alertdialog role, title/message references, modality, initial focus, Escape and click-beside dismissal @@ -66,31 +67,32 @@ src/ ## The patterns -| Pattern | Adopted from | Bound by | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `radio-group` | [APG radio group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) | RadioList, SegmentedControl; role/state portions of DropdownMenu radio items | -| `checkbox` | [APG checkbox](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) | CheckboxInput, CheckboxListItem, DropdownMenuCheckboxItem, SelectableCard | -| `switch` | [APG switch](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) | Switch | -| `button` | [APG button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) | Button, IconButton, ClickableCard, SideNavCollapseButton, ChatSendButton | -| `toggle-button` | Toggle-button semantics in the [APG button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) | ToggleButton, including standalone and grouped states | -| `text-input` | Native HTML controls and [WAI-ARIA textbox](https://www.w3.org/TR/wai-aria-1.2/#textbox) | TextInput, TextArea | -| `modal-dialog` | [APG dialog (modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) | Dialog | -| `status-message` | [WCAG 2.2 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | Toast, FieldStatus, Spinner, ChatSystemMessage, ChatTypingIndicator, ProgressBar | -| `tabs` | [APG Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) | Explicit `role="tablist"` TabList, Tab, and caller-authored tabpanels | -| `listbox` | [WAI-ARIA 1.2 Listbox](https://www.w3.org/TR/wai-aria-1.2/#listbox) and WCAG 2.2 semantics | Selector and MultiSelector popup listbox, group, and option parts | -| `spinbutton` | WCAG 2.2 semantics and [APG Spinbutton](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/) | NumberInput | -| `disclosure` | [APG Disclosure](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) | Standalone Collapsible triggers and their controlled content | -| `accordion` | [APG Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) required parts | CollapsibleGroup headers: button role and name, expanded state, controlled panel, Enter/Space, single-expansion sibling collapse, page Tab order, no arrow-key movement (single-expansion collapse, grouped Tab order, no arrow movement are advisory until `component:CollapsibleGroup` AR4 is current) | -| `breadcrumb` | WCAG 2.2 semantics; [APG Breadcrumb](https://www.w3.org/WAI/ARIA/apg/patterns/breadcrumb/) as context | Breadcrumbs landmarks, list trails, current-page state, and separators | -| `combobox` | WCAG 2.2 + [WAI-ARIA 1.2 Combobox](https://www.w3.org/TR/wai-aria-1.2/#combobox); APG as context | Selector trigger identity, state, popup, and active-descendant relationships | -| `landmark` | WCAG 2.2 semantics; [APG Landmark Regions](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/) as context | LayoutHeader, LayoutContent, LayoutFooter, and LayoutPanel caller-declared landmarks | -| `slider` | [APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) role, values, name, orientation and keys | Slider thumbs: role, name, now/min/max, value text, orientation, arrows (RTL-mirrored), Page, Home/End, disabled keeps value | -| `treeview` | [APG Tree View](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/) structure and keyboard model | TreeList rows: tree/treeitem/group, aria-expanded, level/position, one Tab stop, Up/Down, Right/Left (RTL-mirrored), Home/End, Enter or Space, typeahead | -| `carousel` | [APG Carousel](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/) and WCAG 2.2 semantics | Carousel container, slides, scroll area, and stable navigation-control focus | -| `alertdialog` | [APG Alert Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/) role, references, modality, focus, dismissal | AlertDialog: alertdialog role and name, aria-describedby to the message, modal, Cancel first (advisory), Escape closes, click beside inert (advisory); modal mechanics stay with the modal-dialog contract through Dialog | -| `grid` | [APG Grid](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) role, structure, name, selection and data-grid keys | Calendar month grids: grid role/name, row and gridcell/columnheader structure, one Tab stop, aria-selected/aria-multiselectable, arrows in reading order, Home/End, Enter selects (advisory) | -| `windowsplitter` | [APG Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) role, values, name, relationship and keys | ResizeHandle: separator role/name, now/min/max, orientation, aria-controls to the pane, arrows, Enter collapse/restore, Home/End, disabled keeps position | -| `link` | WCAG 2.2 semantics; [APG Link](https://www.w3.org/WAI/ARIA/apg/patterns/link/) role and Enter mechanics | Link, including LinkProvider and `as` router components | +| Pattern | Adopted from | Bound by | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `radio-group` | [APG radio group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) | RadioList, SegmentedControl; role/state portions of DropdownMenu radio items | +| `checkbox` | [APG checkbox](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) | CheckboxInput, CheckboxListItem, DropdownMenuCheckboxItem, SelectableCard | +| `switch` | [APG switch](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) | Switch | +| `button` | [APG button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) | Button, IconButton, ClickableCard, SideNavCollapseButton, ChatSendButton | +| `toggle-button` | Toggle-button semantics in the [APG button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) | ToggleButton, including standalone and grouped states | +| `text-input` | Native HTML controls and [WAI-ARIA textbox](https://www.w3.org/TR/wai-aria-1.2/#textbox) | TextInput, TextArea | +| `modal-dialog` | [APG dialog (modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) | Dialog | +| `status-message` | [WCAG 2.2 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | Toast, FieldStatus, Spinner, ChatSystemMessage, ChatTypingIndicator, ProgressBar | +| `tabs` | [APG Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) | Explicit `role="tablist"` TabList, Tab, and caller-authored tabpanels | +| `listbox` | [WAI-ARIA 1.2 Listbox](https://www.w3.org/TR/wai-aria-1.2/#listbox) and WCAG 2.2 semantics | Selector and MultiSelector popup listbox, group, and option parts | +| `spinbutton` | WCAG 2.2 semantics and [APG Spinbutton](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/) | NumberInput | +| `disclosure` | [APG Disclosure](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) | Standalone Collapsible triggers and their controlled content | +| `menu-button` | [APG Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) required parts; the optional ArrowDown/ArrowUp opens Astryx ships are advisory | Anchored DropdownMenu and MoreMenu triggers: button role and name, `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`, Enter/Space open on the first enabled item (advisory: ArrowDown on the first, ArrowUp on the last) | +| `accordion` | [APG Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) required parts | CollapsibleGroup headers: button role and name, expanded state, controlled panel, Enter/Space, single-expansion sibling collapse, page Tab order, no arrow-key movement (single-expansion collapse, grouped Tab order, no arrow movement are advisory until `component:CollapsibleGroup` AR4 is current) | +| `breadcrumb` | WCAG 2.2 semantics; [APG Breadcrumb](https://www.w3.org/WAI/ARIA/apg/patterns/breadcrumb/) as context | Breadcrumbs landmarks, list trails, current-page state, and separators | +| `combobox` | WCAG 2.2 + [WAI-ARIA 1.2 Combobox](https://www.w3.org/TR/wai-aria-1.2/#combobox); APG as context | Selector trigger identity, state, popup, and active-descendant relationships | +| `landmark` | WCAG 2.2 semantics; [APG Landmark Regions](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/) as context | LayoutHeader, LayoutContent, LayoutFooter, and LayoutPanel caller-declared landmarks | +| `slider` | [APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) role, values, name, orientation and keys | Slider thumbs: role, name, now/min/max, value text, orientation, arrows (RTL-mirrored), Page, Home/End, disabled keeps value | +| `treeview` | [APG Tree View](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/) structure and keyboard model | TreeList rows: tree/treeitem/group, aria-expanded, level/position, one Tab stop, Up/Down, Right/Left (RTL-mirrored), Home/End, Enter or Space, typeahead | +| `carousel` | [APG Carousel](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/) and WCAG 2.2 semantics | Carousel container, slides, scroll area, and stable navigation-control focus | +| `alertdialog` | [APG Alert Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/) role, references, modality, focus, dismissal | AlertDialog: alertdialog role and name, aria-describedby to the message, modal, Cancel first (advisory), Escape closes, click beside inert (advisory); modal mechanics stay with the modal-dialog contract through Dialog | +| `grid` | [APG Grid](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) role, structure, name, selection and data-grid keys | Calendar month grids: grid role/name, row and gridcell/columnheader structure, one Tab stop, aria-selected/aria-multiselectable, arrows in reading order, Home/End, Enter selects (advisory) | +| `windowsplitter` | [APG Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) role, values, name, relationship and keys | ResizeHandle: separator role/name, now/min/max, orientation, aria-controls to the pane, arrows, Enter collapse/restore, Home/End, disabled keeps position | +| `link` | WCAG 2.2 semantics; [APG Link](https://www.w3.org/WAI/ARIA/apg/patterns/link/) role and Enter mechanics | Link, including LinkProvider and `as` router components | The toggle-button contract owns the persistent-action extension of button semantics: the pressed state must be exposed, reversible by pointer, Enter, and diff --git a/internal/a11y-spec/src/index.ts b/internal/a11y-spec/src/index.ts index 6768e0367731b..beeafde4e82c9 100644 --- a/internal/a11y-spec/src/index.ts +++ b/internal/a11y-spec/src/index.ts @@ -162,6 +162,10 @@ export { WINDOW_SPLITTER_PATTERN, type WindowSplitterStateFacts, } from './patterns/window-splitter'; +export { + MENU_BUTTON_PATTERN, + type MenuButtonStateFacts, +} from './patterns/menu-button'; export { LINK_PATTERN, type LinkCurrentToken, diff --git a/internal/a11y-spec/src/patterns/menu-button.chromium.spec.ts b/internal/a11y-spec/src/patterns/menu-button.chromium.spec.ts new file mode 100644 index 0000000000000..e4c2d78f38f99 --- /dev/null +++ b/internal/a11y-spec/src/patterns/menu-button.chromium.spec.ts @@ -0,0 +1,103 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file menu-button.chromium.spec.ts + * @input MENU_BUTTON_PATTERN and its fixtures in real Chromium + * @output Proof that every expectation passes conforming fixtures and fails its mutation at the layers only a browser observes + * @position Contract self-test, Chromium lane + */ + +import {expect, test, type CDPSession, type Page} from '@playwright/test'; +import {describeExpectation, requiredLayers} from '../contract'; +import {checkAccessibilitySpec, type ExpectationResult} from '../check'; +import { + CHROMIUM_OBSERVES, + createChromiumHarness, + holdMotionStill, +} from '../harness/chromium'; +import {MENU_BUTTON_PATTERN} from './menu-button'; +import { + MENU_BUTTON_MUTATIONS, + CONFORMING_FIXTURES, + FIRST_ITEM_SELECTOR, + LAST_ITEM_SELECTOR, + MENU_SELECTOR, + SUBJECT_SELECTOR, + fixture, + type MenuButtonFixture, +} from './menu-button.fixtures'; + +function fixturePage(html: string): string { + return `${html}`; +} + +async function results( + page: Page, + cdp: CDPSession, + target: MenuButtonFixture, + only?: readonly string[], +): Promise { + const run = await checkAccessibilitySpec({ + spec: MENU_BUTTON_PATTERN, + binding: 'fixture', + state: target.id, + facts: target.facts, + only, + mount: async () => { + await page.setContent(fixturePage(target.html)); + await holdMotionStill(page); + return createChromiumHarness({ + page, + cdp, + subject: page.locator(SUBJECT_SELECTOR), + related: { + menu: page.locator(MENU_SELECTOR), + 'first-item': page.locator(FIRST_ITEM_SELECTOR), + 'last-item': page.locator(LAST_ITEM_SELECTOR), + }, + }); + }, + }); + return run.results; +} + +test.describe('menu-button contract — conforming fixtures', () => { + for (const id of CONFORMING_FIXTURES) { + test(`${id}: every applicable expectation passes`, async ({page}) => { + const cdp = await page.context().newCDPSession(page); + const observed = await results(page, cdp, fixture(id)); + expect( + observed + .filter( + result => + result.status !== 'pass' && result.status !== 'not-applicable', + ) + .map(result => `${result.expectation}: ${result.detail ?? ''}`), + ).toEqual([]); + }); + } +}); + +test.describe('menu-button contract — deliberately violating fixtures', () => { + for (const expectation of MENU_BUTTON_PATTERN.expectations) { + if ( + !requiredLayers(expectation).every(layer => + CHROMIUM_OBSERVES.includes(layer), + ) + ) { + continue; + } + for (const fixtureId of MENU_BUTTON_MUTATIONS[expectation.id] ?? []) { + test(`${describeExpectation(expectation)} — fails against ${fixtureId}`, async ({ + page, + }) => { + const cdp = await page.context().newCDPSession(page); + const [result] = await results(page, cdp, fixture(fixtureId), [ + expectation.id, + ]); + expect(result?.status, result?.detail ?? 'no result').toBe('fail'); + expect(result?.detail ?? '').not.toBe(''); + }); + } + } +}); diff --git a/internal/a11y-spec/src/patterns/menu-button.fixtures.ts b/internal/a11y-spec/src/patterns/menu-button.fixtures.ts new file mode 100644 index 0000000000000..1c0d70bd8868f --- /dev/null +++ b/internal/a11y-spec/src/patterns/menu-button.fixtures.ts @@ -0,0 +1,301 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file menu-button.fixtures.ts + * @input Plain HTML menu buttons with a three-item menu, conforming and deliberately violating + * @output MENU_BUTTON_FIXTURES, the subject and relation selectors, and the expectation → violating-fixture map + * @position Contract self-test data for menu-button.jsdom.test.ts and menu-button.chromium.spec.ts + */ + +import type {MenuButtonStateFacts} from './menu-button'; + +const SUBJECT_ATTRIBUTE = 'data-a11y-subject'; +export const SUBJECT_SELECTOR = `[${SUBJECT_ATTRIBUTE}]`; +export const MENU_SELECTOR = '#menu'; +export const FIRST_ITEM_SELECTOR = '#item-1'; +export const LAST_ITEM_SELECTOR = '#item-3'; + +export interface MenuButtonFixture { + readonly id: string; + readonly summary: string; + readonly facts: MenuButtonStateFacts; + readonly html: string; +} + +/** + * The inline handlers are the smallest menu-button behavior: toggle on click, + * open on Enter/Space/ArrowDown to the first item and ArrowUp to the last, + * close on Escape. Fixtures mutate pieces of this to remove one outcome. + */ +const OPEN = `const m = document.getElementById('menu'); m.hidden = false; this.setAttribute('aria-expanded', 'true');`; +const CLOSE = `const m = document.getElementById('menu'); m.hidden = true; this.setAttribute('aria-expanded', 'false');`; +const TOGGLE = `if (this.getAttribute('aria-expanded') === 'true') { ${CLOSE} } else { ${OPEN} }`; +const FOCUS_FIRST = `document.getElementById('item-1').focus();`; +const FOCUS_LAST = `document.getElementById('item-3').focus();`; +const KEYS = `if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} } else if (event.key === 'ArrowUp') { event.preventDefault(); ${OPEN} ${FOCUS_LAST} }`; +const KEYS_ENTER_NO_FOCUS = KEYS.replace( + `if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} }`, + `if (event.key === 'Enter') { event.preventDefault(); ${OPEN} } else if (event.key === ' ' || event.key === 'ArrowDown') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} }`, +); +const KEYS_SPACE_BLOCKED = `if (event.key === ' ') { event.preventDefault(); return; } ${KEYS}`; +const KEYS_DOWN_LAST = KEYS.replace( + `if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} }`, + `if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} } else if (event.key === 'ArrowDown') { event.preventDefault(); ${OPEN} ${FOCUS_LAST} }`, +); +const KEYS_UP_FIRST = KEYS.replace( + `else if (event.key === 'ArrowUp') { event.preventDefault(); ${OPEN} ${FOCUS_LAST} }`, + `else if (event.key === 'ArrowUp') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} }`, +); +const MENU_KEYS = `if (event.key === 'Escape') { event.preventDefault(); const b = document.querySelector('[${SUBJECT_ATTRIBUTE}]'); document.getElementById('menu').hidden = true; b.setAttribute('aria-expanded', 'false'); b.focus(); }`; + +function menuButton({ + open = false, + tag = 'button', + haspopup = 'menu', + controls = 'menu', + expanded = String(open), + onclick = TOGGLE, + onkeydown = KEYS, + onpointerdown = '', + menuHidden = !open, + menuRole = 'menu', + disabled = false, + focusableDisabled = false, +}: { + open?: boolean; + tag?: string; + haspopup?: string | null; + controls?: string | null; + expanded?: string | null; + onclick?: string; + onkeydown?: string; + onpointerdown?: string; + menuHidden?: boolean; + menuRole?: string; + disabled?: boolean; + focusableDisabled?: boolean; +} = {}): string { + const typeAttr = tag === 'button' ? ' type="button"' : ''; + const button = `<${tag}${typeAttr} ${SUBJECT_ATTRIBUTE} id="trigger"${haspopup == null ? '' : ` aria-haspopup="${haspopup}"`}${expanded == null ? '' : ` aria-expanded="${expanded}"`}${controls == null ? '' : ` aria-controls="${controls}"`}${disabled ? ' aria-disabled="true" tabindex="-1"' : focusableDisabled ? ' aria-disabled="true"' : ''}${onclick === '' ? '' : ` onclick="${onclick}"`}${onkeydown === '' ? '' : ` onkeydown="${onkeydown}"`}${onpointerdown === '' ? '' : ` onpointerdown="${onpointerdown}"`}>Actions`; + const items = [1, 2, 3] + .map( + index => + ``, + ) + .join(''); + const menu = ``; + return `${button}${menu}`; +} + +const facts = ( + overrides: Partial = {}, +): MenuButtonStateFacts => ({ + open: false, + operable: true, + focusable: true, + controls: true, + arrowKeysOpen: true, + ...overrides, +}); + +export const MENU_BUTTON_FIXTURES: readonly MenuButtonFixture[] = [ + { + id: 'conforming-closed', + summary: + 'a closed menu button that opens on pointer, Enter, Space, and the arrows', + facts: facts(), + html: menuButton(), + }, + { + id: 'conforming-open', + summary: 'a menu button whose menu is already displayed', + facts: facts({open: true}), + html: menuButton({open: true}), + }, + { + id: 'conforming-haspopup-true', + summary: + 'a menu button using aria-haspopup="true", the other APG-permitted value', + facts: facts(), + html: menuButton({haspopup: 'true'}), + }, + { + id: 'conforming-no-controls', + summary: 'a menu button using the APG-optional form without aria-controls', + facts: facts({controls: false}), + html: menuButton({controls: null}), + }, + { + id: 'conforming-no-arrow-keys', + summary: + 'a menu button that opens on Enter and Space only, not claiming the optional arrow keys', + facts: facts({arrowKeysOpen: false}), + html: menuButton({ + onkeydown: `if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} }`, + }), + }, + { + id: 'conforming-disabled-closed', + summary: 'an unavailable menu button that keeps its menu hidden', + facts: facts({operable: false, focusable: false}), + html: menuButton({onclick: '', onkeydown: '', disabled: true}), + }, + { + id: 'conforming-disabled-focusable', + summary: + 'an unavailable menu button that stays in the Tab order (for its tooltip) and keeps its menu closed on every key and press', + facts: facts({operable: false, focusable: true}), + html: menuButton({onclick: '', onkeydown: '', focusableDisabled: true}), + }, + { + id: 'violating-not-a-button', + summary: 'a control rendered as a div with no role', + facts: facts(), + html: menuButton({tag: 'div'}).replace( + `${SUBJECT_ATTRIBUTE} id="trigger"`, + `${SUBJECT_ATTRIBUTE} id="trigger" tabindex="0"`, + ), + }, + { + id: 'violating-haspopup-missing', + summary: 'a control with no aria-haspopup', + facts: facts(), + html: menuButton({haspopup: null}), + }, + { + id: 'violating-haspopup-listbox', + summary: 'a control that claims a listbox popup', + facts: facts(), + html: menuButton({haspopup: 'listbox'}), + }, + { + id: 'violating-expanded-missing', + summary: 'a control with no aria-expanded', + facts: facts(), + html: menuButton({expanded: null}), + }, + { + id: 'violating-expanded-opposite', + summary: 'a displayed menu whose control reports collapsed', + facts: facts({open: true}), + html: menuButton({open: true, expanded: 'false'}), + }, + { + id: 'violating-controls-unrelated', + summary: 'an aria-controls that points at a different menu', + facts: facts(), + html: menuButton({controls: 'other'}), + }, + { + id: 'violating-controls-not-menu', + summary: 'an aria-controls that points at an element without role menu', + facts: facts(), + html: menuButton({menuRole: 'group'}), + }, + { + id: 'violating-menu-visible-while-collapsed', + summary: 'a collapsed control whose menu stays visible', + facts: facts(), + html: menuButton({menuHidden: false}), + }, + { + id: 'violating-enter-opens-without-focus', + summary: 'Enter opens the menu but leaves focus on the control', + facts: facts(), + html: menuButton({onkeydown: KEYS_ENTER_NO_FOCUS}), + }, + { + id: 'violating-space-blocked', + summary: 'a control that blocks Space', + facts: facts(), + html: menuButton({onkeydown: KEYS_SPACE_BLOCKED}), + }, + { + id: 'violating-arrow-down-focuses-last', + summary: 'ArrowDown opens the menu on its last item', + facts: facts(), + html: menuButton({onkeydown: KEYS_DOWN_LAST}), + }, + { + id: 'violating-arrow-up-focuses-first', + summary: 'ArrowUp opens the menu on its first item', + facts: facts(), + html: menuButton({onkeydown: KEYS_UP_FIRST}), + }, + { + id: 'violating-disabled-opens-on-arrow', + summary: + 'an unavailable, focusable menu button that still opens on ArrowDown', + facts: facts({operable: false, focusable: true}), + html: menuButton({ + onclick: '', + onkeydown: `if (event.key === 'ArrowDown') { event.preventDefault(); ${OPEN} ${FOCUS_FIRST} }`, + focusableDisabled: true, + }), + }, + { + id: 'violating-pointer-one-way', + summary: 'a pointer opens the menu but cannot close it again', + facts: facts(), + html: menuButton({onclick: OPEN}), + }, + { + id: 'violating-down-event-activation', + summary: + 'a control that opens on pointer-down before the user can cancel the press', + facts: facts(), + html: menuButton({onclick: '', onpointerdown: TOGGLE}), + }, +]; + +export function fixture(id: string): MenuButtonFixture { + const found = MENU_BUTTON_FIXTURES.find(candidate => candidate.id === id); + if (found == null) { + throw new Error(`unknown menu-button fixture "${id}"`); + } + return found; +} + +export const CONFORMING_FIXTURES: readonly string[] = + MENU_BUTTON_FIXTURES.filter(candidate => + candidate.id.startsWith('conforming-'), + ).map(candidate => candidate.id); + +export const MENU_BUTTON_MUTATIONS: Readonly< + Record +> = { + 'menu-button.control.role-and-name': ['violating-not-a-button'], + 'menu-button.control.haspopup-menu': [ + 'violating-haspopup-missing', + 'violating-haspopup-listbox', + ], + 'menu-button.control.expanded-state': [ + 'violating-expanded-missing', + 'violating-expanded-opposite', + ], + 'menu-button.control.controls-menu': [ + 'violating-controls-unrelated', + 'violating-controls-not-menu', + ], + 'menu-button.menu.matches-state': [ + 'violating-menu-visible-while-collapsed', + 'violating-expanded-opposite', + ], + 'menu-button.open.enter-focuses-first-item': [ + 'violating-enter-opens-without-focus', + ], + 'menu-button.open.space-focuses-first-item': ['violating-space-blocked'], + 'menu-button.open.arrow-down-focuses-first-item': [ + 'violating-arrow-down-focuses-last', + ], + 'menu-button.open.arrow-up-focuses-last-item': [ + 'violating-arrow-up-focuses-first', + ], + 'menu-button.control.unavailable-stays-closed': [ + 'violating-disabled-opens-on-arrow', + ], + 'menu-button.open.pointer-round-trip': ['violating-pointer-one-way'], + 'menu-button.control.survives-an-aborted-press': [ + 'violating-down-event-activation', + ], +}; diff --git a/internal/a11y-spec/src/patterns/menu-button.jsdom.test.ts b/internal/a11y-spec/src/patterns/menu-button.jsdom.test.ts new file mode 100644 index 0000000000000..ee29deca7f73b --- /dev/null +++ b/internal/a11y-spec/src/patterns/menu-button.jsdom.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +/** @vitest-environment jsdom */ + +/** + * @file menu-button.jsdom.test.ts + * @input MENU_BUTTON_PATTERN and its conforming and violating fixtures + * @output Proof that the contract is complete, that every DOM-layer expectation passes conforming fixtures and fails its mutation, and that jsdom reports browser layers as unrun + * @position Contract self-test, jsdom lane + */ + +import {afterEach, describe, expect, it} from 'vitest'; +import { + citeSource, + describeExpectation, + requiredLayers, + unansweredDimensions, +} from '../contract'; +import {checkAccessibilitySpec, type ExpectationResult} from '../check'; +import {JSDOM_OBSERVES, createJsdomHarness} from '../harness/jsdom'; +import {MENU_BUTTON_PATTERN} from './menu-button'; +import { + MENU_BUTTON_MUTATIONS, + CONFORMING_FIXTURES, + FIRST_ITEM_SELECTOR, + LAST_ITEM_SELECTOR, + MENU_SELECTOR, + SUBJECT_SELECTOR, + fixture, + type MenuButtonFixture, +} from './menu-button.fixtures'; + +const observableHere = MENU_BUTTON_PATTERN.expectations.filter(expectation => + requiredLayers(expectation).every(layer => JSDOM_OBSERVES.includes(layer)), +); + +afterEach(() => document.body.replaceChildren()); + +async function resultsFor(target: MenuButtonFixture) { + const container = document.createElement('div'); + document.body.append(container); + const result = await checkAccessibilitySpec({ + spec: MENU_BUTTON_PATTERN, + binding: 'fixture', + state: target.id, + facts: target.facts, + mount: async () => { + container.innerHTML = target.html; + const subject = container.querySelector(SUBJECT_SELECTOR); + const menu = container.querySelector(MENU_SELECTOR); + const firstItem = container.querySelector(FIRST_ITEM_SELECTOR); + const lastItem = container.querySelector(LAST_ITEM_SELECTOR); + if ( + subject == null || + menu == null || + firstItem == null || + lastItem == null + ) { + throw new Error( + `fixture "${target.id}" is missing its subject or a relation`, + ); + } + return createJsdomHarness({ + subject, + related: {menu, 'first-item': firstItem, 'last-item': lastItem}, + }); + }, + unmount: () => { + container.innerHTML = ''; + }, + }); + return result.results; +} + +function resultFor( + results: readonly ExpectationResult[], + id: string, +): ExpectationResult { + const found = results.find(result => result.expectation === id); + if (found == null) { + throw new Error(`no result for ${id}`); + } + return found; +} + +describe('menu-button contract — completeness', () => { + it('answers every completeness dimension', () => { + expect(unansweredDimensions(MENU_BUTTON_PATTERN)).toEqual([]); + }); + + it('gives every expectation at least one deliberately violating fixture', () => { + const missing = MENU_BUTTON_PATTERN.expectations + .filter( + expectation => + (MENU_BUTTON_MUTATIONS[expectation.id] ?? []).length === 0, + ) + .map(expectation => expectation.id); + expect(missing).toEqual([]); + }); + + it('has an expectation for every mutation it records', () => { + const ids = new Set( + MENU_BUTTON_PATTERN.expectations.map(expectation => expectation.id), + ); + expect( + Object.keys(MENU_BUTTON_MUTATIONS).filter(id => !ids.has(id)), + ).toEqual([]); + }); + + it('names every fixture it records a mutation against', () => { + for (const fixtures of Object.values(MENU_BUTTON_MUTATIONS)) { + for (const id of fixtures) { + expect(() => fixture(id)).not.toThrow(); + } + } + }); +}); + +describe('menu-button contract — the jsdom harness stays within its evidence boundary', () => { + it('runs DOM expectations and reports browser expectations as unrun', async () => { + const results = await resultsFor(fixture('conforming-closed')); + expect(results.some(result => result.status === 'pass')).toBe(true); + const above = results.filter(result => result.status === 'unrun'); + expect(above.length).toBeGreaterThan(0); + for (const result of above) { + expect( + (result.missingLayers ?? []).some( + layer => layer === 'real-browser' || layer === 'accessibility-tree', + ), + ).toBe(true); + } + }); +}); + +describe.each(observableHere.map(expectation => [expectation.id] as const))( + '%s', + id => { + const expectation = MENU_BUTTON_PATTERN.expectations.find( + candidate => candidate.id === id, + )!; + + it.each(CONFORMING_FIXTURES.map(name => [name] as const))( + 'passes against %s (or does not apply to it)', + async name => { + const result = resultFor(await resultsFor(fixture(name)), id); + expect( + ['pass', 'not-applicable'], + `${id} against ${name}: ${result.detail ?? ''}`, + ).toContain(result.status); + }, + ); + + it.each((MENU_BUTTON_MUTATIONS[id] ?? []).map(name => [name] as const))( + 'fails against %s, which removes its outcome', + async name => { + const result = resultFor(await resultsFor(fixture(name)), id); + expect(result.status, `${id} against ${name}`).toBe('fail'); + expect(result.detail ?? '').not.toBe(''); + }, + ); + + it('carries its id and primary source into every result description', async () => { + const target = fixture( + MENU_BUTTON_MUTATIONS[id]?.[0] ?? CONFORMING_FIXTURES[0]!, + ); + const result = resultFor(await resultsFor(target), id); + expect(result.description).toContain(id); + expect(result.description).toContain(citeSource(expectation.sources[0])); + expect(describeExpectation(expectation)).toBe(result.description); + }); + }, +); diff --git a/internal/a11y-spec/src/patterns/menu-button.ts b/internal/a11y-spec/src/patterns/menu-button.ts new file mode 100644 index 0000000000000..6fd58a5ccf179 --- /dev/null +++ b/internal/a11y-spec/src/patterns/menu-button.ts @@ -0,0 +1,693 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file menu-button.ts + * @input WCAG 2.2 A/AA success criteria and the APG Menu Button pattern's role, state, relationship, and keyboard requirements + * @output MENU_BUTTON_PATTERN — the reusable contract a control that opens an anchored menu binds to when it adopts Menu Button's required parts, plus the ArrowDown/ArrowUp opening Astryx ships as a named exception + * @position Pattern contract; bound by the DropdownMenu and MoreMenu anchored triggers under their records' AR4/AR1 (`spec:AST-020/FR15`) + */ + +import { + definePattern, + type ApgRequirement, + type AstryxRecord, + type PatternContract, + type WcagCriterion, +} from '../contract'; +import type {Harness, Subject} from '../harness'; + +const APG_URL = 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/'; +const UNDERSTANDING = 'https://www.w3.org/WAI/WCAG22/Understanding'; + +const WCAG_1_3_1: WcagCriterion = { + standard: 'wcag', + id: '1.3.1', + name: 'Info and Relationships', + level: 'A', + url: `${UNDERSTANDING}/info-and-relationships.html`, +}; +const WCAG_2_1_1: WcagCriterion = { + standard: 'wcag', + id: '2.1.1', + name: 'Keyboard', + level: 'A', + url: `${UNDERSTANDING}/keyboard.html`, +}; +const WCAG_2_5_2: WcagCriterion = { + standard: 'wcag', + id: '2.5.2', + name: 'Pointer Cancellation', + level: 'A', + url: `${UNDERSTANDING}/pointer-cancellation.html`, +}; +const WCAG_4_1_2: WcagCriterion = { + standard: 'wcag', + id: '4.1.2', + name: 'Name, Role, Value', + level: 'A', + url: `${UNDERSTANDING}/name-role-value.html`, +}; + +const APG_ROLE: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: 'The element that opens the menu has role button.', + url: `${APG_URL}#wai-ariaroles,states,andproperties`, +}; +const APG_HASPOPUP: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: + 'The element with role button has aria-haspopup set to either menu or true.', + url: `${APG_URL}#wai-ariaroles,states,andproperties`, +}; +const APG_EXPANDED: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: + 'When the menu is displayed, the element with role button has aria-expanded set to true. When the menu is hidden, aria-expanded is set to false.', + url: `${APG_URL}#wai-ariaroles,states,andproperties`, +}; +const APG_CONTROLS: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: + 'Optionally, the element with role button has a value specified for aria-controls that refers to the element with role menu.', + url: `${APG_URL}#wai-ariaroles,states,andproperties`, +}; +const APG_ENTER: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: 'Enter: opens the menu and places focus on the first menu item.', + url: `${APG_URL}#keyboardinteraction`, +}; +const APG_SPACE: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: 'Space: Opens the menu and places focus on the first menu item.', + url: `${APG_URL}#keyboardinteraction`, +}; +const APG_DOWN: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: + '(Optional) Down Arrow: opens the menu and moves focus to the first menu item.', + url: `${APG_URL}#keyboardinteraction`, +}; +const APG_UP: ApgRequirement = { + standard: 'apg', + pattern: 'menu-button', + requirement: + '(Optional) Up Arrow: opens the menu and moves focus to the last menu item.', + url: `${APG_URL}#keyboardinteraction`, +}; + +/** + * The current Astryx adoption of the pattern's required parts: a control that + * already plays the menu button role takes its role, name, state and + * relationship properties, and Enter/Space activation. + */ +const AST_020_FR15: AstryxRecord = { + standard: 'astryx', + id: 'spec:AST-020', + clause: 'FR15', + url: 'https://github.com/facebook/astryx/blob/878fad9d7ac23cd3f4c5def64595abd770bb01a3/docs/specs/AST-020/spec.md#L83-L92', + requirement: + "Where a component part already plays an APG pattern's role, Astryx adopts that pattern's required parts for it: the role, the accessible name, the state and relationship properties, and activation by Tab plus Enter or Space.", +}; + +/** + * What a binding knows about one menu button. The subject is the control that + * opens the menu; `menu` is the element with role menu it controls, and + * `first-item` and `last-item` are that menu's first and last enabled items. + */ +export interface MenuButtonStateFacts { + /** Whether the menu is meant to be displayed in this state. */ + readonly open: boolean; + /** Whether the user is meant to be able to open the menu. */ + readonly operable: boolean; + /** Whether the control is meant to accept keyboard focus. */ + readonly focusable: boolean; + /** Whether the binding declares the optional aria-controls relationship. */ + readonly controls: boolean; + /** + * Whether the binding opens on ArrowDown (first item) and ArrowUp (last + * item), the APG-optional keys Astryx ships for anchored menus. + */ + readonly arrowKeysOpen: boolean; +} + +const ALWAYS = { + condition: 'the binding designates a control that opens a menu', + test: () => true, +}; + +async function authoredExpanded(subject: Subject): Promise { + const value = await subject.attribute('aria-expanded'); + return value === 'true' ? true : value === 'false' ? false : null; +} + +async function assertOpen( + subject: Subject, + menu: Subject, + expected: boolean, + after: string, +): Promise { + const state = await authoredExpanded(subject); + if (state !== expected) { + throw new Error( + `${after} left aria-expanded ${state == null ? 'absent or invalid' : state} instead of ${expected}`, + ); + } + const visible = await menu.isVisible(); + if (visible !== expected) { + throw new Error( + `${after} left the control ${expected ? 'expanded' : 'collapsed'} while the menu was ${visible ? 'visible' : 'hidden'}`, + ); + } +} + +async function focusOrFail(subject: Subject, forWhat: string): Promise { + await subject.focus(); + if (!(await subject.isFocused())) { + throw new Error( + `the menu button did not take focus, so ${forWhat} never reaches it`, + ); + } +} + +/** + * Opens the menu with one key from the focused control, checks where focus + * landed, then presses the control again so the next expectation starts from + * the state the binding declared (the pointer round trip proves that press + * closes the menu). A binding whose menu does not close that way fails here + * with that reason rather than leaking an open menu into the next check. + */ +async function openWithKey( + harness: Harness, + subject: Subject, + key: 'Enter' | 'Space' | 'ArrowDown' | 'ArrowUp', + landing: 'first-item' | 'last-item', +): Promise { + const menu = await harness.related('menu'); + const target = await harness.related(landing); + await assertOpen(subject, menu, false, 'the starting state'); + await focusOrFail(subject, key); + await harness.press(key); + await assertOpen( + subject, + menu, + true, + `pressing ${key} on the focused control`, + ); + if (!(await target.isFocused())) { + throw new Error( + `pressing ${key} opened the menu without moving focus to its ${landing === 'first-item' ? 'first' : 'last'} enabled item`, + ); + } + await harness.click(subject); + if ((await authoredExpanded(subject)) !== false) { + throw new Error( + 'pressing the control again did not close the menu, so it cannot be returned to its starting state', + ); + } +} + +export const MENU_BUTTON_PATTERN: PatternContract = + definePattern({ + pattern: 'menu-button', + url: APG_URL, + scope: + "A menu button's role and name, aria-haspopup, expanded state, optional aria-controls to its menu, Enter and Space opening with focus on the first item, and — where a binding ships them — ArrowDown and ArrowUp opening on the first and last items.", + expectations: [ + { + id: 'menu-button.control.role-and-name', + outcome: + 'The browser exposes the control as a named button, so a user hears what the menu is for before opening it.', + sources: [WCAG_4_1_2, APG_ROLE], + covers: ['4.1.2-name-role-value', 'apg-interaction'], + appliesWhen: ALWAYS, + evidenceLayer: 'accessibility-tree', + enforcement: 'required', + run: async ({subject}) => { + const {role, name} = await subject.computed(); + if (role !== 'button') { + throw new Error( + `the browser exposes this control as ${role == null ? 'no role' : `"${role}"`}, not as a button`, + ); + } + if (!name || name.trim() === '') { + throw new Error( + 'the browser computes no accessible name for this control', + ); + } + }, + }, + { + id: 'menu-button.control.haspopup-menu', + outcome: + 'The control says it opens a menu, so a user knows a press reveals choices rather than acting at once.', + sources: [WCAG_4_1_2, APG_HASPOPUP, AST_020_FR15], + covers: ['4.1.2-name-role-value', 'apg-interaction'], + appliesWhen: ALWAYS, + evidenceLayer: 'dom', + enforcement: 'required', + run: async ({subject}) => { + const value = await subject.attribute('aria-haspopup'); + if (value !== 'menu' && value !== 'true') { + throw new Error( + `aria-haspopup is ${value ?? 'absent'}; a menu button says "menu" or "true"`, + ); + } + }, + }, + { + id: 'menu-button.control.expanded-state', + outcome: + 'The control reports whether its menu is displayed, in both states.', + sources: [WCAG_4_1_2, APG_EXPANDED, AST_020_FR15], + covers: ['4.1.2-name-role-value', 'apg-interaction'], + appliesWhen: ALWAYS, + evidenceLayer: 'dom', + enforcement: 'required', + run: async ({subject, facts}) => { + const expanded = await subject.attribute('aria-expanded'); + if (expanded !== String(facts.open)) { + throw new Error( + `the menu is ${facts.open ? 'displayed' : 'hidden'} but aria-expanded is ${expanded ?? 'absent'}`, + ); + } + }, + }, + { + id: 'menu-button.control.controls-menu', + outcome: + 'The declared control relationship identifies the menu element.', + sources: [WCAG_1_3_1, APG_CONTROLS, AST_020_FR15], + covers: ['1.3.1-info-and-relationships', 'apg-interaction'], + appliesWhen: { + condition: + 'the binding declares the optional aria-controls relationship', + test: facts => facts.controls, + }, + evidenceLayer: 'dom', + enforcement: 'required', + run: async ({harness, subject}) => { + const menu = await harness.related('menu'); + if (!(await harness.references(subject, 'aria-controls', menu))) { + throw new Error('aria-controls does not identify the menu'); + } + if ((await menu.attribute('role')) !== 'menu') { + throw new Error('the controlled element does not have role menu'); + } + }, + }, + { + id: 'menu-button.menu.matches-state', + outcome: + 'A displayed menu is visible and a hidden menu is not, so the exposed state describes what the user receives.', + sources: [WCAG_4_1_2, APG_EXPANDED], + covers: ['4.1.2-name-role-value', 'apg-interaction'], + appliesWhen: ALWAYS, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'required', + run: async ({harness, subject, facts}) => { + await assertOpen( + subject, + await harness.related('menu'), + facts.open, + 'the rendered state', + ); + }, + }, + { + id: 'menu-button.open.enter-focuses-first-item', + outcome: + 'With focus on the control, Enter opens the menu and places focus on its first enabled item.', + sources: [WCAG_2_1_1, APG_ENTER, AST_020_FR15], + covers: ['2.1.1-keyboard', '2.4.3-focus-order', 'apg-interaction'], + appliesWhen: { + condition: + 'the menu starts hidden and the control can be operated and focused', + test: facts => !facts.open && facts.operable && facts.focusable, + }, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'required', + run: async ({harness, subject}) => { + await openWithKey(harness, subject, 'Enter', 'first-item'); + }, + }, + { + id: 'menu-button.open.space-focuses-first-item', + outcome: + 'With focus on the control, Space opens the menu and places focus on its first enabled item.', + sources: [WCAG_2_1_1, APG_SPACE, AST_020_FR15], + covers: ['2.1.1-keyboard', '2.4.3-focus-order', 'apg-interaction'], + appliesWhen: { + condition: + 'the menu starts hidden and the control can be operated and focused', + test: facts => !facts.open && facts.operable && facts.focusable, + }, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'required', + run: async ({harness, subject}) => { + await openWithKey(harness, subject, 'Space', 'first-item'); + }, + }, + { + id: 'menu-button.open.arrow-down-focuses-first-item', + outcome: + 'With focus on the control, ArrowDown opens the menu and places focus on its first enabled item.', + sources: [APG_DOWN], + wcagOutcome: + 'Supports WCAG 2.2 2.1.1 by keeping the implemented arrow-key open keyboard-operable; the APG recommends it, so this row is advisory until the binding record adopts it as current.', + covers: ['2.4.3-focus-order', 'apg-interaction'], + appliesWhen: { + condition: + 'the binding ships arrow-key opening, the menu starts hidden, and the control can be operated and focused', + test: facts => + facts.arrowKeysOpen && + !facts.open && + facts.operable && + facts.focusable, + }, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'advisory', + advisoryBecause: + "ArrowDown and ArrowUp opening are optional in the APG pattern; this becomes required once the binding's component record adopts them as current (AST-020 FR9).", + run: async ({harness, subject}) => { + await openWithKey(harness, subject, 'ArrowDown', 'first-item'); + }, + }, + { + id: 'menu-button.open.arrow-up-focuses-last-item', + outcome: + 'With focus on the control, ArrowUp opens the menu and places focus on its last enabled item.', + sources: [APG_UP], + wcagOutcome: + 'Supports WCAG 2.2 2.1.1 by keeping the implemented arrow-key open keyboard-operable; the APG recommends it, so this row is advisory until the binding record adopts it as current.', + covers: ['2.4.3-focus-order', 'apg-interaction'], + appliesWhen: { + condition: + 'the binding ships arrow-key opening, the menu starts hidden, and the control can be operated and focused', + test: facts => + facts.arrowKeysOpen && + !facts.open && + facts.operable && + facts.focusable, + }, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'advisory', + advisoryBecause: + "ArrowDown and ArrowUp opening are optional in the APG pattern; this becomes required once the binding's component record adopts them as current (AST-020 FR9).", + run: async ({harness, subject}) => { + await openWithKey(harness, subject, 'ArrowUp', 'last-item'); + }, + }, + { + id: 'menu-button.open.pointer-round-trip', + outcome: + 'A pointer press opens the menu and a second press closes it, with the exposed state following both.', + sources: [WCAG_4_1_2, APG_EXPANDED], + covers: ['4.1.2-name-role-value', 'apg-interaction'], + appliesWhen: { + condition: 'the menu starts hidden and the control can be operated', + test: facts => !facts.open && facts.operable, + }, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'required', + run: async ({harness, subject}) => { + const menu = await harness.related('menu'); + await assertOpen(subject, menu, false, 'the starting state'); + await harness.click(subject); + await assertOpen(subject, menu, true, 'clicking the control'); + await harness.click(subject); + await assertOpen(subject, menu, false, 'clicking the control again'); + }, + }, + { + id: 'menu-button.control.unavailable-stays-closed', + outcome: + 'An unavailable control that still takes focus keeps its menu closed on every opening key and on a press, so a disabled trigger never surprises the user with choices it says it cannot offer.', + sources: [WCAG_4_1_2, APG_EXPANDED], + covers: ['4.1.2-name-role-value', 'apg-interaction'], + appliesWhen: { + condition: + 'the control cannot be operated but can receive focus, and the menu starts hidden', + test: facts => !facts.operable && facts.focusable && !facts.open, + }, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'required', + run: async ({harness, subject}) => { + const menu = await harness.related('menu'); + await assertOpen(subject, menu, false, 'the starting state'); + await focusOrFail(subject, 'the opening keys'); + for (const key of [ + 'Enter', + 'Space', + 'ArrowDown', + 'ArrowUp', + ] as const) { + await harness.press(key); + if ((await authoredExpanded(subject)) !== false) { + throw new Error( + `an unavailable control opened its menu on ${key}`, + ); + } + } + await harness.click(subject, {ignoreAvailability: true}); + if ((await authoredExpanded(subject)) !== false) { + throw new Error( + 'an unavailable control opened its menu on a press', + ); + } + }, + }, + { + id: 'menu-button.control.survives-an-aborted-press', + outcome: + 'A press released away from the control leaves the menu hidden, so an unintended press can be cancelled.', + sources: [WCAG_2_5_2], + covers: ['2.5.2-pointer-cancellation'], + appliesWhen: { + condition: 'the menu starts hidden and the control can be operated', + test: facts => !facts.open && facts.operable, + }, + evidenceLayer: 'real-browser', + alsoNeeds: ['dom'], + enforcement: 'required', + run: async ({harness, subject}) => { + const menu = await harness.related('menu'); + await assertOpen(subject, menu, false, 'the starting state'); + await harness.abortedPress(subject); + await assertOpen( + subject, + menu, + false, + 'releasing the press away from the control', + ); + }, + }, + ], + exemptions: { + '1.1.1-non-text-content': { + owner: 'the binding component', + verifiedBy: + "the component's decorative-icon tests and the repository axe audit, `pnpm a11y:audit`", + reason: + 'The menu button contract owns trigger state and controlled-content behavior. Decorative graphics such as a chevron are binding-specific composition.', + }, + '1.3.1-info-and-relationships': { + owner: 'the binding component and composing page', + verifiedBy: + "the binding's component tests and page-level relationship review", + reason: + 'This contract encodes the optional trigger-to-content relationship. Other structure and relationships inside or around that content belong to the binding and its composition.', + coversRemainderOnly: true, + }, + '1.3.2-meaningful-sequence': { + owner: 'the binding component and composing page', + verifiedBy: + "the component's DOM-order tests and page-level reading-order review", + reason: + 'The page decides where the trigger and revealed section sit relative to surrounding content.', + }, + '1.3.5-identify-input-purpose': { + owner: 'the composing form and caller content', + verifiedBy: + 'form integration review for any user-information input inside the disclosed content', + reason: + 'A menu button trigger does not itself collect information about the user.', + }, + '1.4.1-use-of-color': { + owner: 'the binding component and theme', + verifiedBy: 'the repository visual gate and forced-colors review', + reason: + 'Color and any non-color menu button cue are rendered presentation facts, not trigger-state semantics.', + }, + '1.4.3-contrast-minimum': { + owner: 'the binding component and theme', + verifiedBy: + 'the repository axe audit over component stories plus the visual gate', + reason: + 'Text contrast depends on resolved colors and backdrop in the rendered binding.', + }, + '1.4.11-non-text-contrast': { + owner: 'the binding component and theme', + verifiedBy: + 'the repository axe audit plus rendered review of the trigger boundary, state cue, and focus indicator', + reason: + 'Control and menu button-cue contrast are paint outcomes owned by the binding and theme.', + }, + '2.1.1-keyboard': { + owner: 'the button pattern and composing page', + verifiedBy: + 'BUTTON_PATTERN focus-navigation expectations plus page-level keyboard-order review', + reason: + 'This contract encodes the Enter, Space, ArrowDown, and ArrowUp opening transitions. Generic reachability and other page keyboard paths remain with the button and composition owners.', + coversRemainderOnly: true, + }, + '2.1.2-no-keyboard-trap': { + owner: 'the button pattern and composing page', + verifiedBy: + 'BUTTON_PATTERN for adopters, plus binding-local focus reach/escape tests and page-level keyboard review', + reason: + 'A menu button adds no focus trap; Tab entry and exit are generic button and composition behavior.', + }, + '2.4.2-page-titled': { + owner: 'the page', + verifiedBy: 'page-level review', + reason: 'A menu button rendered in isolation owns no document title.', + }, + '2.4.3-focus-order': { + owner: 'the menu pattern and the composing page', + verifiedBy: + "the menu contract's focus-return evidence and page-level focus-order review; this contract proves only where focus lands on open", + reason: + "Where focus goes after the menu closes, and the control's place among surrounding controls, belong to the menu contract and the composition.", + coversRemainderOnly: true, + }, + '2.4.4-link-purpose': { + owner: 'the link pattern and caller content', + verifiedBy: 'review of links inside or around the disclosed content', + reason: 'The adopted menu button trigger is a button, not a link.', + }, + '2.4.6-headings-and-labels': { + owner: 'the button pattern and caller content', + verifiedBy: + 'BUTTON_PATTERN naming checks plus content review of the trigger label', + reason: + 'Whether the trigger label describes its purpose is a caller-content judgement; generic button naming is already owned by BUTTON_PATTERN.', + }, + '2.4.7-focus-visible': { + owner: + 'the interaction-modality architecture record, binding component, and theme', + verifiedBy: + "the component's focus-ring styling and the repository visual gate", + reason: 'A focus indicator is a paint outcome.', + }, + '2.4.11-focus-not-obscured': { + owner: 'the composing page and overlay system', + verifiedBy: 'page-level and overlay visibility review', + reason: + 'Whether another surface covers the focused trigger or revealed content depends on composition.', + }, + '2.5.3-label-in-name': { + owner: 'the button pattern and caller content', + verifiedBy: + 'BUTTON_PATTERN for adopters, plus binding-local visible-label and accessible-name checks', + reason: + 'The menu button adds state to a button without changing who owns the button label and name.', + }, + '2.5.8-target-size': { + owner: 'the binding component and composing page', + verifiedBy: + "the repository axe audit's target-size rule over the trigger and neighbouring targets", + reason: + 'Target size and spacing exceptions depend on the binding geometry and neighbouring controls.', + }, + '3.1.1-language-of-page': { + owner: 'the page', + verifiedBy: 'page-level language review', + reason: + 'A menu button rendered in isolation owns no document language.', + }, + '3.2.2-on-input': { + owner: 'the menu pattern and the composing page', + verifiedBy: + "the menu contract's focus-return evidence and integration review of any navigation a chosen item causes", + reason: + "Opening a menu from its button moves focus into the menu by the pattern's own design, and the menu contract owns focus return on close; what a chosen item then does is caller behavior.", + }, + '3.2.4-consistent-identification': { + owner: 'the design system and caller content', + verifiedBy: + 'this shared contract across bindings plus content review of repeated menu button labels', + reason: + 'Consistency is a property of the full set of repeated functions, not one isolated menu button.', + }, + '3.3.1-error-identification': { + owner: 'the binding component and composing form', + verifiedBy: 'form-level error-state and text review', + reason: 'The menu button pattern defines no validation error state.', + }, + '3.3.2-labels-or-instructions': { + owner: 'the composing form and caller content', + verifiedBy: 'form-level review of labels and instructions', + reason: + 'The menu button trigger does not collect user input that needs form instructions.', + }, + '4.1.2-name-role-value': { + owner: 'the button pattern and binding component', + verifiedBy: + 'BUTTON_PATTERN for adopters, plus binding-local role, name, and unavailable-state tests', + reason: + 'This contract encodes the control role and name, aria-haspopup, the expanded state, and their transitions. Other button semantics remain with BUTTON_PATTERN.', + coversRemainderOnly: true, + }, + '4.1.3-status-messages': { + owner: + 'the caller and the assistive-technology verification record, spec:AST-009', + verifiedBy: + 'the status-message pattern and real-AT evidence under AST-009 when a change claims spoken or braille output', + reason: + 'Showing or hiding ordinary content is not itself a status-message contract, and this pattern makes no announcement claim.', + }, + 'apg-interaction': { + owner: 'the button pattern', + verifiedBy: + 'BUTTON_PATTERN for adopters, plus binding-local role, name, reachability, and activation tests', + reason: + "This contract encodes the menu button's state, relationship, and opening transitions. The menu itself — item roles, arrow navigation inside it, Escape and focus return — is the menu pattern's; the trigger's generic button semantics stay with BUTTON_PATTERN.", + coversRemainderOnly: true, + }, + 'forced-colors': { + owner: 'the binding component and theme', + verifiedBy: + "the component's forced-colors tests plus rendered Windows High Contrast review", + reason: + 'Forced-colors output is a paint result owned by the binding and theme.', + }, + 'reduced-motion': { + owner: 'the binding component and theme', + verifiedBy: + "the component's reduced-motion source tests and rendered motion review", + reason: + 'Reveal motion is binding-specific paint over time and is not part of menu button semantics.', + }, + 'at-facing-strings': { + owner: 'the binding component and caller content', + verifiedBy: + 'the repository i18n catalog check and source review of authored labels and descriptions', + reason: + 'The menu button contract introduces no string; trigger and content text come from the binding or caller.', + }, + }, + }); diff --git a/packages/core/src/AlertDialog/AlertDialog.spec.md b/packages/core/src/AlertDialog/AlertDialog.spec.md index 8e8575fcc5ec3..668faec508ebb 100644 --- a/packages/core/src/AlertDialog/AlertDialog.spec.md +++ b/packages/core/src/AlertDialog/AlertDialog.spec.md @@ -186,7 +186,7 @@ The dialog surface (the Dialog element AlertDialog renders) carries `alert-dialo | Contract | Verification | Representative states | Mutation or failure expectation | Audit section | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1–FR5 | `AlertDialog.test.tsx` ("calls onOpenChange(false) on Escape", "does not call onOpenChange when action is clicked", "does not call onAction on Escape", "does not disable the cancel button while the action is loading", "exposes a named group instead, with no aria-modal") and `useImperativeAlertDialog.test.tsx` ("opens on show()", "closes on hide()") | open, loading, inline, imperative | Escape not cancelling, the action closing by itself, or the inline path claiming the role fails. | `audit:AlertDialog/behavior` | +| FR1–FR5 | `AlertDialog.test.tsx` ("calls onOpenChange(false) on Escape", "does not call onOpenChange when action is clicked", "does not call onAction on Escape", "does not disable the cancel button while the action is loading", "exposes a named group instead, with no aria-modal") and `useImperativeAlertDialog.test.tsx` ("opens on show()", "closes on hide()") | open, loading, inline, imperative | Escape not cancelling, the action closing by itself, or the inline path claiming the role fails. | `audit:AlertDialog/behavior` | | AR1 | `AlertDialog.test.tsx` ("renders with alertdialog role", "links title via aria-labelledby", "links description via aria-describedby", "marks cancel as the initial focus target", "keeps the initial focus target on the least destructive action", "calls onOpenChange(false) on Escape", "stacks long destructive action labels above cancel on a narrow fine-pointer screen", "reaches both buttons by Tab, cancel first", "is a modal alertdialog", "does not claim the alertdialog role") and `useImperativeAlertDialog.test.tsx`; the no-backdrop-dismiss outcome and focus delegation under the Alert Dialog accessibility contract (`internal/a11y-spec`, pending) | open modal; after Escape; after Cancel | The role, name link, description link, or Cancel initial focus is lost, or a click outside dismisses the dialog. | `audit:AlertDialog/accessibility` | ## Decision log diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..fd45da35bd27d --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts @@ -0,0 +1,136 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MenuButton.a11y.chromium.spec.ts + * @input Built menu-button stories and MENU_BUTTON_PATTERN in real Chromium + * @output Browser evidence for every menu-button expectation across the DropdownMenu and MoreMenu trigger states + * @position Chromium lane of the menu-button binding (component:DropdownMenu AR4, component:MoreMenu AR1) + */ + +import { + expect, + test, + type CDPSession, + type Locator, + type Page, +} from '@playwright/test'; +import { + MENU_BUTTON_PATTERN, + blockingResults, + checkAccessibilitySpec, + formatFailures, + neverExercised, + summarize, + unmatchedKnownFailures, + type BindingResult, +} from '@astryxdesign/a11y-spec'; +import { + createChromiumHarness, + holdMotionStill, +} from '@astryxdesign/a11y-spec/chromium'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; +import {MENU_BUTTON_KNOWN_FAILURES} from './MenuButton.a11y.known-failures'; +import { + MENU_BUTTON_BINDING_STATES, + type MenuButtonBindingState, +} from './MenuButton.a11y.states'; + +let storybook: StaticServer; + +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); + +test.afterAll(async () => { + await storybook?.close(); +}); + +function storyUrl(storyId: string): string { + return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`; +} + +function trigger(page: Page, label: string): Locator { + return page + .locator('#storybook-root') + .getByRole('button', {name: label, includeHidden: true}); +} + +async function mountState( + page: Page, + state: MenuButtonBindingState, +): Promise<{subject: Locator; related: Record}> { + await page.goto(storyUrl(state.storyId), {waitUntil: 'load'}); + await holdMotionStill(page); + const subject = trigger(page, state.label); + await subject.waitFor({state: 'attached'}); + const id = await subject.getAttribute('aria-controls'); + if (id == null) { + throw new Error(`menu-button state "${state.id}" has no aria-controls`); + } + const menu = page.locator(`[id="${id}"]`); + const related: Record = { + menu, + 'first-item': page.getByRole('menuitem', { + name: state.firstItem, + includeHidden: true, + }), + 'last-item': page.getByRole('menuitem', { + name: state.lastItem, + includeHidden: true, + }), + }; + return {subject, related}; +} + +async function runState( + page: Page, + cdp: CDPSession, + state: MenuButtonBindingState, +): Promise { + return checkAccessibilitySpec({ + spec: MENU_BUTTON_PATTERN, + binding: 'DropdownMenu.trigger', + state: state.id, + facts: state.facts, + knownFailures: MENU_BUTTON_KNOWN_FAILURES, + mount: async () => { + const {subject, related} = await mountState(page, state); + return createChromiumHarness({page, cdp, subject, related}); + }, + }); +} + +test('every menu-button expectation is exercised by a DropdownMenu or MoreMenu state', async ({ + page, +}) => { + const cdp = await page.context().newCDPSession(page); + const results: BindingResult[] = []; + for (const state of MENU_BUTTON_BINDING_STATES) { + results.push(await runState(page, cdp, state)); + } + expect(neverExercised(results)).toEqual([]); + expect(unmatchedKnownFailures(MENU_BUTTON_KNOWN_FAILURES, results)).toEqual( + [], + ); +}); + +for (const state of MENU_BUTTON_BINDING_STATES) { + test(`DropdownMenu.trigger [${state.id}] — ${state.summary}`, async ({ + page, + }) => { + const cdp = await page.context().newCDPSession(page); + const result = await runState(page, cdp, state); + const report = summarize(MENU_BUTTON_PATTERN, [result]); + // Required rows block (AST-020 FR9); advisory rows and exact known + // failures report. + expect(formatFailures(blockingResults([result]))).toBe(''); + expect(report.unrunLayers).toEqual([]); + expect(report.counts.unexpectedPass).toBe(0); + }); +} diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts new file mode 100644 index 0000000000000..a6b6deb428a2a --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts @@ -0,0 +1,54 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MenuButton.a11y.known-failures.ts + * @input The exact menu-button contract failures the shipped triggers produce today + * @output MENU_BUTTON_KNOWN_FAILURES — records both lanes reconcile against, so a fix shows up as an unexpected pass and any other failure stays a failure + * @position Known-failure ledger for the DropdownMenu/MoreMenu menu-button binding + */ + +import type {KnownFailure} from '@astryxdesign/a11y-spec'; + +const ABORTED_PRESS_STANDARD = + 'WCAG 2.2 2.5.2 Pointer Cancellation — a single-pointer function does not complete on the down-event unless the up-event reverses it or it can be aborted'; +const ABORTED_PRESS_REASON = + "The anchored trigger opens on press-down by module:DropdownMenu/useMenuPress FR8 and DEC-4, which are still proposed (that record's OQ1). This test-only binding records the shipped behavior; the press model's owner decision either makes it conformant or brings the fix."; + +export const MENU_BUTTON_KNOWN_FAILURES: ReadonlyArray = [ + { + expectation: 'menu-button.control.survives-an-aborted-press', + binding: 'DropdownMenu.trigger', + state: 'dropdown-button-closed', + evidenceLayer: 'real-browser', + failureEquals: + 'releasing the press away from the control left aria-expanded true instead of false', + standardsReference: ABORTED_PRESS_STANDARD, + userImpact: + 'A mouse user who presses the Actions button and slides off before releasing, to cancel, still gets the menu: it opens on press-down and stays open.', + reason: ABORTED_PRESS_REASON, + }, + { + expectation: 'menu-button.control.survives-an-aborted-press', + binding: 'DropdownMenu.trigger', + state: 'dropdown-render-trigger-closed', + evidenceLayer: 'real-browser', + failureEquals: + 'releasing the press away from the control left aria-expanded true instead of false', + standardsReference: ABORTED_PRESS_STANDARD, + userImpact: + 'A mouse user who presses a caller-rendered trigger and slides off before releasing, to cancel, still gets the menu: it opens on press-down and stays open.', + reason: ABORTED_PRESS_REASON, + }, + { + expectation: 'menu-button.control.survives-an-aborted-press', + binding: 'DropdownMenu.trigger', + state: 'more-menu-closed', + evidenceLayer: 'real-browser', + failureEquals: + 'releasing the press away from the control left aria-expanded true instead of false', + standardsReference: ABORTED_PRESS_STANDARD, + userImpact: + 'A mouse user who presses the MoreMenu button and slides off before releasing, to cancel, still gets the menu: it opens on press-down and stays open.', + reason: ABORTED_PRESS_REASON, + }, +]; diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx new file mode 100644 index 0000000000000..859ddeea07969 --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx @@ -0,0 +1,67 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MenuButton.a11y.renders.tsx + * @input One menu-button binding state from MenuButton.a11y.states.ts + * @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook) + * @position Shared fixture for the DropdownMenu and MoreMenu menu-button binding + */ + +import type {ReactElement} from 'react'; +import {DropdownMenu} from '../DropdownMenu'; +import {MoreMenu} from '../../MoreMenu'; +import { + MENU_BUTTON_ITEMS, + type MenuButtonBindingState, +} from './MenuButton.a11y.states'; + +const items = MENU_BUTTON_ITEMS.map(item => ({...item})); + +export function renderMenuButtonState( + state: Pick< + MenuButtonBindingState, + 'component' | 'label' | 'open' | 'disabled' + >, +): ReactElement { + let menu: ReactElement; + switch (state.component) { + case 'MoreMenu': + menu = ( + + ); + break; + case 'DropdownMenu.renderTrigger': + menu = ( + ( + + )} + /> + ); + break; + case 'DropdownMenu': + menu = ( + {}} : {})} + /> + ); + } + return ( +
+ {menu} + +
+ ); +} diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts new file mode 100644 index 0000000000000..7bd3ef42f6689 --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts @@ -0,0 +1,141 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MenuButton.a11y.states.ts + * @input The anchored DropdownMenu and MoreMenu triggers' representative states and the facts MENU_BUTTON_PATTERN reads + * @output MENU_BUTTON_BINDING_STATES and the explicit ownership exclusions for this binding + * @position Binding inventory shared by the jsdom test and the Chromium spec (component:DropdownMenu AR4, component:MoreMenu AR1) + */ + +import type {MenuButtonStateFacts} from '@astryxdesign/a11y-spec'; + +export interface MenuButtonBindingState { + readonly id: string; + readonly summary: string; + readonly storyId: string; + /** Which component renders the trigger. */ + readonly component: + 'DropdownMenu' | 'DropdownMenu.renderTrigger' | 'MoreMenu'; + /** The trigger's accessible name. */ + readonly label: string; + /** Labels of the menu's first and last enabled items. */ + readonly firstItem: string; + readonly lastItem: string; + readonly facts: MenuButtonStateFacts; + /** Whether the menu is mounted open (controlled `isMenuOpen`). */ + readonly open?: boolean; + /** Whether the trigger is disabled. */ + readonly disabled?: boolean; +} + +export const MENU_BUTTON_ITEMS = [ + {label: 'Rename'}, + {label: 'Duplicate'}, + {label: 'Archive', isDisabled: true}, + {label: 'Delete'}, +] as const; + +function facts( + overrides: Partial = {}, +): MenuButtonStateFacts { + return { + open: false, + operable: true, + focusable: true, + controls: true, + arrowKeysOpen: true, + ...overrides, + }; +} + +const story = (id: string) => `a11y-menubutton--${id}`; + +export const MENU_BUTTON_BINDING_STATES = [ + { + id: 'dropdown-button-closed', + summary: 'the default DropdownMenu Button trigger with its menu hidden', + storyId: story('dropdown-button-closed'), + component: 'DropdownMenu', + label: 'Actions', + firstItem: 'Rename', + lastItem: 'Delete', + facts: facts(), + }, + { + id: 'dropdown-button-open', + summary: 'the default DropdownMenu Button trigger with its menu displayed', + storyId: story('dropdown-button-open'), + component: 'DropdownMenu', + label: 'Actions', + firstItem: 'Rename', + lastItem: 'Delete', + facts: facts({open: true}), + open: true, + }, + { + id: 'dropdown-render-trigger-closed', + summary: + 'a caller-rendered trigger that spreads the DropdownMenu trigger props onto its own button', + storyId: story('dropdown-render-trigger-closed'), + component: 'DropdownMenu.renderTrigger', + label: 'Row actions', + firstItem: 'Rename', + lastItem: 'Delete', + facts: facts(), + }, + { + id: 'dropdown-button-disabled', + summary: 'a disabled DropdownMenu trigger that keeps its menu hidden', + storyId: story('dropdown-button-disabled'), + component: 'DropdownMenu', + label: 'Actions', + firstItem: 'Rename', + lastItem: 'Delete', + facts: facts({operable: false, focusable: false}), + disabled: true, + }, + { + id: 'more-menu-closed', + summary: 'the MoreMenu icon-only trigger with its menu hidden', + storyId: story('more-menu-closed'), + component: 'MoreMenu', + label: 'More options', + firstItem: 'Rename', + lastItem: 'Delete', + facts: facts(), + }, + { + id: 'more-menu-disabled', + summary: + 'a disabled MoreMenu trigger that stays focusable for its tooltip and must keep its menu closed', + storyId: story('more-menu-disabled'), + component: 'MoreMenu', + label: 'More options', + firstItem: 'Rename', + lastItem: 'Delete', + facts: facts({operable: false, focusable: true}), + disabled: true, + }, +] as const satisfies ReadonlyArray; + +export const MENU_BUTTON_EXCLUSIONS = [ + { + id: 'menu-surface-semantics', + owner: 'the menu pattern (queued) and DropdownMenu.test.tsx', + reason: + 'Item roles, arrow navigation inside the open menu, typeahead, Escape, selection, and focus return on close are the menu surface contract. This binding proves only how the trigger exposes itself and where focus lands when it opens the menu.', + }, + { + id: 'bottom-sheet-presentation', + owner: 'component:DropdownMenu AR4 and the modal-dialog pattern', + reason: + 'On a compact touch display the trigger opens a bottom sheet and says aria-haspopup="dialog"; that presentation is a dialog, not a menu button, and AR4 places it outside this pattern (MoreMenu AR1 follows DropdownMenu).', + }, + { + id: 'header-footer-popover', + owner: + 'component:DropdownMenu AR4 and AR5 (the popover adopts the modal-dialog pattern)', + reason: + 'A pointer menu with a header or footer opens a popover that holds the menu beside caller regions, so its trigger says aria-haspopup="dialog" and controls the popover; AR4 places it outside this pattern and AR5 gives it the dialog pattern, so none of these menu-button states renders one.', + }, +] as const; diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx new file mode 100644 index 0000000000000..948f4c668e846 --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx @@ -0,0 +1,138 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +/** @vitest-environment jsdom */ + +/** + * @file MenuButton.a11y.test.tsx + * @input MENU_BUTTON_PATTERN bound to the anchored DropdownMenu and MoreMenu triggers + * @output jsdom-lane proof of the DOM expectations and an honest "unrun" report for the browser layers + * @position Binding test for component:DropdownMenu AR4 and component:MoreMenu AR1 (the Chromium spec covers the rest) + */ + +import {cleanup, render, screen} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; +import { + MENU_BUTTON_PATTERN, + checkAccessibilitySpec, + createJsdomHarness, + expectAccessibilitySpec, + summarize, + type BindingResult, +} from '@astryxdesign/a11y-spec'; +import {MENU_BUTTON_KNOWN_FAILURES} from './MenuButton.a11y.known-failures'; +import {renderMenuButtonState} from './MenuButton.a11y.renders'; +import { + MENU_BUTTON_BINDING_STATES, + MENU_BUTTON_EXCLUSIONS, + type MenuButtonBindingState, +} from './MenuButton.a11y.states'; + +function trigger(state: MenuButtonBindingState): HTMLElement { + return screen.getByRole('button', {name: state.label}); +} + +function relatedFor( + state: MenuButtonBindingState, +): Readonly> { + const subject = trigger(state); + const controls = subject.getAttribute('aria-controls'); + const menu = controls == null ? null : document.getElementById(controls); + if (menu == null) { + throw new Error(`menu-button state "${state.id}" has no controlled menu`); + } + return { + menu, + 'first-item': screen.getByRole('menuitem', { + name: state.firstItem, + hidden: true, + }), + 'last-item': screen.getByRole('menuitem', { + name: state.lastItem, + hidden: true, + }), + }; +} + +async function checkState( + state: MenuButtonBindingState, +): Promise { + return checkAccessibilitySpec({ + spec: MENU_BUTTON_PATTERN, + binding: 'DropdownMenu.trigger', + state: state.id, + facts: state.facts, + knownFailures: MENU_BUTTON_KNOWN_FAILURES, + mount: async () => { + render(renderMenuButtonState(state)); + return createJsdomHarness({ + subject: trigger(state), + related: relatedFor(state), + }); + }, + unmount: cleanup, + }); +} + +describe('DropdownMenu and MoreMenu triggers — menu-button pattern, jsdom lane', () => { + it.each( + MENU_BUTTON_BINDING_STATES.map( + state => [state.id, state.summary, state] as const, + ), + )('%s — %s', async (_id, _summary, state) => { + await expectAccessibilitySpec({ + spec: MENU_BUTTON_PATTERN, + binding: 'DropdownMenu.trigger', + state: state.id, + facts: state.facts, + knownFailures: MENU_BUTTON_KNOWN_FAILURES, + render: () => { + render(renderMenuButtonState(state)); + }, + subject: () => trigger(state), + related: () => relatedFor(state), + cleanup, + }); + }); + + it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => { + const results: BindingResult[] = []; + for (const state of MENU_BUTTON_BINDING_STATES) { + results.push(await checkState(state)); + } + const report = summarize(MENU_BUTTON_PATTERN, results); + expect(report.counts.pass).toBeGreaterThan(0); + expect(report.unrunLayers).toEqual(['accessibility-tree', 'real-browser']); + expect(report.counts.unexpectedPass).toBe(0); + }); +}); + +describe('menu-button binding inventory', () => { + it('gives every representative state a distinct checked-in story', () => { + const stories = MENU_BUTTON_BINDING_STATES.map(state => state.storyId); + expect(new Set(stories).size).toBe(stories.length); + }); + + it('keeps menu-surface, bottom-sheet and header/footer ownership explicit', () => { + expect(MENU_BUTTON_EXCLUSIONS.map(row => row.id)).toEqual([ + 'menu-surface-semantics', + 'bottom-sheet-presentation', + 'header-footer-popover', + ]); + for (const exclusion of MENU_BUTTON_EXCLUSIONS) { + expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20); + expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80); + } + }); + + it('renders exactly one trigger and its menu relations for every state', () => { + for (const state of MENU_BUTTON_BINDING_STATES) { + render(renderMenuButtonState(state)); + expect(trigger(state), state.id).toBeTruthy(); + expect(Object.keys(relatedFor(state)), state.id).toEqual([ + 'menu', + 'first-item', + 'last-item', + ]); + cleanup(); + } + }); +});