From 5fa1e04b8dd7ffb1dc0fde04356a65c45a13d96e Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 13 Oct 2026 08:33:34 -0700 Subject: [PATCH 1/7] test(a11y): Menu Button contract and DropdownMenu/MoreMenu trigger binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MENU_BUTTON_PATTERN encodes the APG Menu Button parts component:DropdownMenu AR4 and component:MoreMenu AR1 adopt for the anchored trigger: button role and name, aria-haspopup menu, aria-expanded, aria-controls to the role=menu element, Enter and Space opening with focus on the first enabled item, the pointer round trip and aborted press, and — as the records' named exception — ArrowDown opening on the first and ArrowUp on the last enabled item. Fixtures give every expectation a violating twin; jsdom and Chromium self-tests, the binding (default Button trigger closed/open/disabled, a renderTrigger control, MoreMenu), stories, a CI step, and the README row. Test-only: no runtime or docs change. --- .github/workflows/ci.yml | 44 ++ .../stories/MenuButtonA11y.stories.tsx | 99 +++ internal/a11y-spec/README.md | 36 +- internal/a11y-spec/src/index.ts | 4 + .../src/patterns/menu-button.chromium.spec.ts | 103 +++ .../src/patterns/menu-button.fixtures.ts | 278 ++++++++ .../src/patterns/menu-button.jsdom.test.ts | 171 +++++ .../a11y-spec/src/patterns/menu-button.ts | 638 ++++++++++++++++++ .../MenuButton.a11y.chromium.spec.ts | 133 ++++ .../__tests__/MenuButton.a11y.renders.tsx | 62 ++ .../__tests__/MenuButton.a11y.states.ts | 122 ++++ .../__tests__/MenuButton.a11y.test.tsx | 134 ++++ 12 files changed, 1807 insertions(+), 17 deletions(-) create mode 100644 apps/storybook/stories/MenuButtonA11y.stories.tsx create mode 100644 internal/a11y-spec/src/patterns/menu-button.chromium.spec.ts create mode 100644 internal/a11y-spec/src/patterns/menu-button.fixtures.ts create mode 100644 internal/a11y-spec/src/patterns/menu-button.jsdom.test.ts create mode 100644 internal/a11y-spec/src/patterns/menu-button.ts create mode 100644 packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts create mode 100644 packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx create mode 100644 packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts create mode 100644 packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14934b4bd5063..382d725cd6931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1565,6 +1565,50 @@ 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. + # If the base cannot be fetched, run. + if git fetch --quiet --depth=1 origin "$BASE_SHA"; then + if git diff --name-only "$BASE_SHA" HEAD \ + | grep -qE '^(internal/a11y-spec/src/patterns/menu-button|packages/core/src/DropdownMenu/|packages/core/src/MoreMenu/|apps/storybook/stories/MenuButtonA11y)'; then + should_run=true + fi + else + 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/ + # The link contract's role, name, Tab, Enter, and pointer-release outcomes # are accessibility-tree and real-browser facts that jsdom cannot observe. # Run the contract's own fixture proof and the Link binding whenever Link diff --git a/apps/storybook/stories/MenuButtonA11y.stories.tsx b/apps/storybook/stories/MenuButtonA11y.stories.tsx new file mode 100644 index 0000000000000..6ae5df94a9049 --- /dev/null +++ b/apps/storybook/stories/MenuButtonA11y.stories.tsx @@ -0,0 +1,99 @@ +// 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 {DropdownMenu} from '@astryxdesign/core/DropdownMenu'; +import {MoreMenu} from '@astryxdesign/core/MoreMenu'; + +const meta = { + title: 'A11y/Menu Button', + tags: ['no-visual'], + parameters: {layout: 'padded'}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const items = [ + {label: 'Rename'}, + {label: 'Duplicate'}, + {label: 'Archive', isDisabled: true}, + {label: 'Delete'}, +]; + +function After() { + return ; +} + +export const DropdownButtonClosed: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const DropdownButtonOpen: Story = { + render: () => ( +
+ {}} + /> + +
+ ), +}; + +export const DropdownRenderTriggerClosed: Story = { + render: () => ( +
+ ( + + )} + /> + +
+ ), +}; + +export const DropdownButtonDisabled: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const MoreMenuClosed: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/internal/a11y-spec/README.md b/internal/a11y-spec/README.md index 44bcc8561163c..b2f8b46208b70 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 ├── breadcrumb.* landmark, list-trail, current-page, and separator semantics ├── landmark.* one component-owned landmark region's role, name, and content boundary └── link.* one available link's role, name, current state, focus, and activation @@ -58,23 +59,24 @@ 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 | -| `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 | -| `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 | -| `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 plus the optional ArrowDown/ArrowUp opens Astryx ships | Anchored DropdownMenu and MoreMenu triggers: button role and name, `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`, Enter/Space/ArrowDown open on the first enabled item, ArrowUp on the last | +| `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 | +| `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 | +| `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 825067e960ba8..155e0b590847f 100644 --- a/internal/a11y-spec/src/index.ts +++ b/internal/a11y-spec/src/index.ts @@ -139,6 +139,10 @@ export { type LandmarkStateFacts, } from './patterns/landmark'; +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..d5a2c44a92d07 --- /dev/null +++ b/internal/a11y-spec/src/patterns/menu-button.fixtures.ts @@ -0,0 +1,278 @@ +// 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, +}: { + 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; +} = {}): 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"' : ''}${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: '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-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.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..9a2e4febefe0d --- /dev/null +++ b/internal/a11y-spec/src/patterns/menu-button.ts @@ -0,0 +1,638 @@ +// 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 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_3_2_2: WcagCriterion = { + standard: 'wcag', + id: '3.2.2', + name: 'On Input', + level: 'A', + url: `${UNDERSTANDING}/on-input.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`, +}; + +/** + * 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], + 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], + 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], + 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], + 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], + 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: [WCAG_2_1_1, APG_DOWN], + covers: ['2.1.1-keyboard', '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: 'required', + 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: [WCAG_2_1_1, APG_UP], + covers: ['2.1.1-keyboard', '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: 'required', + 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.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/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..20b426cd7bfbd --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts @@ -0,0 +1,133 @@ +// 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, + 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_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, + 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([]); +}); + +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]); + expect(formatFailures(blockingResults([result]))).toBe(''); + expect( + result.results.filter( + row => row.status !== 'pass' && row.status !== 'not-applicable', + ), + ).toEqual([]); + expect(report.unrunLayers).toEqual([]); + expect(report.counts.unexpectedPass).toBe(0); + }); +} 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..152aca022565a --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx @@ -0,0 +1,62 @@ +// 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..888723c1fbb57 --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts @@ -0,0 +1,122 @@ +// 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-menu-button--${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(), + }, +] 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 FR13 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 is outside this binding by the records\' own scope (DropdownMenu AR4, MoreMenu AR1).', + }, +] 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..28a1d5597bb7c --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx @@ -0,0 +1,134 @@ +// 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 {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, + 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, + 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 and bottom-sheet ownership explicit', () => { + expect(MENU_BUTTON_EXCLUSIONS.map(row => row.id)).toEqual([ + 'menu-surface-semantics', + 'bottom-sheet-presentation', + ]); + 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(); + } + }); +}); From 43023d16c6df84e43ae83ece111d6d320a7f42ee Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 13 Oct 2026 09:36:43 -0700 Subject: [PATCH 2/7] test(a11y): menu-button contract honors the press model, keeps the optional arrows advisory, and gates an unavailable trigger closed - opensOnPress fact: a press-model trigger (DropdownMenu opens on pointer press-down) is not judged by the aborted-press check; its module owns what a release elsewhere does. Recorded as a binding exclusion. - ArrowDown/ArrowUp opens stay advisory until the component records adopt them as current (AST-020 FR9). - menu-button.control.unavailable-stays-closed: a focusable aria-disabled trigger keeps its menu closed on Enter, Space, ArrowDown, ArrowUp and a press; conforming and violating fixtures. The disabled MoreMenu state records the shipped gap as an exact known failure (ArrowDown opens it) for its own spec-backed fix. --- .../stories/MenuButtonA11y.stories.tsx | 14 ++++ .../src/patterns/menu-button.fixtures.ts | 26 +++++++- .../a11y-spec/src/patterns/menu-button.ts | 66 +++++++++++++++---- .../MenuButton.a11y.chromium.spec.ts | 11 +++- .../MenuButton.a11y.known-failures.ts | 26 ++++++++ .../__tests__/MenuButton.a11y.renders.tsx | 7 +- .../__tests__/MenuButton.a11y.states.ts | 20 ++++++ .../__tests__/MenuButton.a11y.test.tsx | 6 +- 8 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts diff --git a/apps/storybook/stories/MenuButtonA11y.stories.tsx b/apps/storybook/stories/MenuButtonA11y.stories.tsx index 6ae5df94a9049..9771a10086b0e 100644 --- a/apps/storybook/stories/MenuButtonA11y.stories.tsx +++ b/apps/storybook/stories/MenuButtonA11y.stories.tsx @@ -97,3 +97,17 @@ export const MoreMenuClosed: Story = { ), }; + +export const MoreMenuDisabled: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/internal/a11y-spec/src/patterns/menu-button.fixtures.ts b/internal/a11y-spec/src/patterns/menu-button.fixtures.ts index d5a2c44a92d07..ad5562345a309 100644 --- a/internal/a11y-spec/src/patterns/menu-button.fixtures.ts +++ b/internal/a11y-spec/src/patterns/menu-button.fixtures.ts @@ -60,6 +60,7 @@ function menuButton({ menuHidden = !open, menuRole = 'menu', disabled = false, + focusableDisabled = false, }: { open?: boolean; tag?: string; @@ -72,9 +73,10 @@ function menuButton({ 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"' : ''}${onclick === '' ? '' : ` onclick="${onclick}"`}${onkeydown === '' ? '' : ` onkeydown="${onkeydown}"`}${onpointerdown === '' ? '' : ` onpointerdown="${onpointerdown}"`}>Actions`; + 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 => @@ -93,6 +95,7 @@ const facts = ( focusable: true, controls: true, arrowKeysOpen: true, + opensOnPress: false, ...overrides, }); @@ -138,6 +141,13 @@ export const MENU_BUTTON_FIXTURES: readonly MenuButtonFixture[] = [ 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', @@ -213,6 +223,17 @@ export const MENU_BUTTON_FIXTURES: readonly MenuButtonFixture[] = [ 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', @@ -271,6 +292,9 @@ export const MENU_BUTTON_MUTATIONS: Readonly< '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.ts b/internal/a11y-spec/src/patterns/menu-button.ts index 9a2e4febefe0d..e3ce88c7590ee 100644 --- a/internal/a11y-spec/src/patterns/menu-button.ts +++ b/internal/a11y-spec/src/patterns/menu-button.ts @@ -39,13 +39,6 @@ const WCAG_2_5_2: WcagCriterion = { level: 'A', url: `${UNDERSTANDING}/pointer-cancellation.html`, }; -const WCAG_3_2_2: WcagCriterion = { - standard: 'wcag', - id: '3.2.2', - name: 'On Input', - level: 'A', - url: `${UNDERSTANDING}/on-input.html`, -}; const WCAG_4_1_2: WcagCriterion = { standard: 'wcag', id: '4.1.2', @@ -127,6 +120,13 @@ export interface MenuButtonStateFacts { * item), the APG-optional keys Astryx ships for anchored menus. */ readonly arrowKeysOpen: boolean; + /** + * Whether the binding opens the menu on pointer press-down (a press model + * where the row under the release acts) rather than on click. A press-model + * control cannot be judged by the aborted-press check: the press itself is + * the open, and its module owns what a release elsewhere does. + */ + readonly opensOnPress: boolean; } const ALWAYS = { @@ -366,7 +366,9 @@ export const MENU_BUTTON_PATTERN: PatternContract = }, evidenceLayer: 'real-browser', alsoNeeds: ['dom'], - enforcement: 'required', + 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'); }, @@ -388,7 +390,9 @@ export const MENU_BUTTON_PATTERN: PatternContract = }, evidenceLayer: 'real-browser', alsoNeeds: ['dom'], - enforcement: 'required', + 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'); }, @@ -415,6 +419,45 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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: @@ -422,8 +465,9 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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, + condition: + 'the menu starts hidden, the control can be operated, and it opens on click rather than on press-down', + test: facts => !facts.open && facts.operable && !facts.opensOnPress, }, evidenceLayer: 'real-browser', alsoNeeds: ['dom'], diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts index 20b426cd7bfbd..ad7fb4f47f33b 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts @@ -21,6 +21,7 @@ import { formatFailures, neverExercised, summarize, + unmatchedKnownFailures, type BindingResult, } from '@astryxdesign/a11y-spec'; import { @@ -32,6 +33,7 @@ import { 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, @@ -96,6 +98,7 @@ async function runState( 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}); @@ -112,6 +115,9 @@ test('every menu-button expectation is exercised by a DropdownMenu or MoreMenu s 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) { @@ -124,7 +130,10 @@ for (const state of MENU_BUTTON_BINDING_STATES) { expect(formatFailures(blockingResults([result]))).toBe(''); expect( result.results.filter( - row => row.status !== 'pass' && row.status !== 'not-applicable', + row => + row.status !== 'pass' && + row.status !== 'not-applicable' && + row.status !== 'known-failure', ), ).toEqual([]); expect(report.unrunLayers).toEqual([]); 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..17a3ff3aa4371 --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts @@ -0,0 +1,26 @@ +// 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'; + +export const MENU_BUTTON_KNOWN_FAILURES: ReadonlyArray = [ + { + expectation: 'menu-button.control.unavailable-stays-closed', + binding: 'DropdownMenu.trigger', + state: 'more-menu-disabled', + evidenceLayer: 'real-browser', + failureEquals: 'an unavailable control opened its menu on ArrowDown', + standardsReference: + 'WCAG 2.2 4.1.2 Name, Role, Value; APG Menu Button — a control that says aria-disabled must not open its menu; Astryx spec:AST-021 FR4', + userImpact: + 'A keyboard user on a disabled MoreMenu hears it is unavailable, yet ArrowDown (and ArrowUp) opens the menu: Button suppresses Enter and Space for an aria-disabled control but lets the arrow keys through to the DropdownMenu open handler.', + reason: + 'This test-only binding records the gap; the fix is a DropdownMenu change that ships as its own spec-backed patch.', + }, +]; diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx index 152aca022565a..859ddeea07969 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.renders.tsx @@ -27,7 +27,12 @@ export function renderMenuButtonState( switch (state.component) { case 'MoreMenu': menu = ( - + ); break; case 'DropdownMenu.renderTrigger': diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts index 888723c1fbb57..4d501bb26d930 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts @@ -44,6 +44,7 @@ function facts( focusable: true, controls: true, arrowKeysOpen: true, + opensOnPress: true, ...overrides, }; } @@ -104,6 +105,18 @@ export const MENU_BUTTON_BINDING_STATES = [ 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 = [ @@ -113,6 +126,13 @@ export const MENU_BUTTON_EXCLUSIONS = [ 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: 'press-model-pointer-cancellation', + owner: + 'module:DropdownMenu/useMenuPress and MenuPress.a11y.chromium.spec.ts', + reason: + "The anchored trigger opens on pointer press-down and the row under the release acts; what a release away from the trigger does is the press model's own contract, proven by its module evidence, so the generic aborted-press check does not apply here (opensOnPress).", + }, { id: 'bottom-sheet-presentation', owner: 'component:DropdownMenu FR13 and the modal-dialog pattern', diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx index 28a1d5597bb7c..a230ce023f7e4 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx @@ -18,6 +18,7 @@ import { 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, @@ -59,6 +60,7 @@ async function checkState( binding: 'DropdownMenu.trigger', state: state.id, facts: state.facts, + knownFailures: MENU_BUTTON_KNOWN_FAILURES, mount: async () => { render(renderMenuButtonState(state)); return createJsdomHarness({ @@ -81,6 +83,7 @@ describe('DropdownMenu and MoreMenu triggers — menu-button pattern, jsdom lane binding: 'DropdownMenu.trigger', state: state.id, facts: state.facts, + knownFailures: MENU_BUTTON_KNOWN_FAILURES, render: () => { render(renderMenuButtonState(state)); }, @@ -108,9 +111,10 @@ describe('menu-button binding inventory', () => { expect(new Set(stories).size).toBe(stories.length); }); - it('keeps menu-surface and bottom-sheet ownership explicit', () => { + it('keeps menu-surface, press-model and bottom-sheet ownership explicit', () => { expect(MENU_BUTTON_EXCLUSIONS.map(row => row.id)).toEqual([ 'menu-surface-semantics', + 'press-model-pointer-cancellation', 'bottom-sheet-presentation', ]); for (const exclusion of MENU_BUTTON_EXCLUSIONS) { From 0ec7adf0738e05495bfc79a8f4441dafa1787692 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 13 Oct 2026 10:05:15 -0700 Subject: [PATCH 3/7] test(a11y): the menu-button Chromium lane tolerates the one expectation the press model excludes --- .../__tests__/MenuButton.a11y.chromium.spec.ts | 9 ++++++++- .../src/DropdownMenu/__tests__/MenuButton.a11y.states.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts index ad7fb4f47f33b..193136a0a7f36 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts @@ -36,6 +36,7 @@ import { import {MENU_BUTTON_KNOWN_FAILURES} from './MenuButton.a11y.known-failures'; import { MENU_BUTTON_BINDING_STATES, + PRESS_MODEL_EXCLUDED_EXPECTATIONS, type MenuButtonBindingState, } from './MenuButton.a11y.states'; @@ -114,7 +115,13 @@ test('every menu-button expectation is exercised by a DropdownMenu or MoreMenu s for (const state of MENU_BUTTON_BINDING_STATES) { results.push(await runState(page, cdp, state)); } - expect(neverExercised(results)).toEqual([]); + // The aborted-press check never applies to a press-model trigger; the + // binding's exclusion names its owner. + expect( + neverExercised(results).filter( + id => !PRESS_MODEL_EXCLUDED_EXPECTATIONS.includes(id), + ), + ).toEqual([]); expect(unmatchedKnownFailures(MENU_BUTTON_KNOWN_FAILURES, results)).toEqual( [], ); diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts index 4d501bb26d930..d51b976e625ed 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts @@ -140,3 +140,11 @@ export const MENU_BUTTON_EXCLUSIONS = [ '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 is outside this binding by the records\' own scope (DropdownMenu AR4, MoreMenu AR1).', }, ] as const; + +/** + * Expectations no binding state can exercise because every anchored trigger + * opens on press-down (see the `press-model-pointer-cancellation` exclusion). + */ +export const PRESS_MODEL_EXCLUDED_EXPECTATIONS: ReadonlyArray = [ + 'menu-button.control.survives-an-aborted-press', +]; From 673349672278bde97333d020ee1b9ef5adb958d0 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 13 Oct 2026 11:22:22 -0700 Subject: [PATCH 4/7] test(a11y): menu-button Chromium lane blocks on required rows only; arrow rows APG-primary with a wcagOutcome; stories render through renderMenuButtonState --- .../stories/MenuButtonA11y.stories.tsx | 113 ++++-------------- .../a11y-spec/src/patterns/menu-button.ts | 12 +- .../MenuButton.a11y.chromium.spec.ts | 10 +- 3 files changed, 31 insertions(+), 104 deletions(-) diff --git a/apps/storybook/stories/MenuButtonA11y.stories.tsx b/apps/storybook/stories/MenuButtonA11y.stories.tsx index 9771a10086b0e..b53899f1f1527 100644 --- a/apps/storybook/stories/MenuButtonA11y.stories.tsx +++ b/apps/storybook/stories/MenuButtonA11y.stories.tsx @@ -8,11 +8,11 @@ */ import type {Meta, StoryObj} from '@storybook/react'; -import {DropdownMenu} from '@astryxdesign/core/DropdownMenu'; -import {MoreMenu} from '@astryxdesign/core/MoreMenu'; +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/Menu Button', + title: 'A11y/MenuButton', tags: ['no-visual'], parameters: {layout: 'padded'}, } satisfies Meta; @@ -20,94 +20,23 @@ const meta = { export default meta; type Story = StoryObj; -const items = [ - {label: 'Rename'}, - {label: 'Duplicate'}, - {label: 'Archive', isDisabled: true}, - {label: 'Delete'}, -]; - -function After() { - return ; +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: Story = { - render: () => ( -
- - -
- ), -}; - -export const DropdownButtonOpen: Story = { - render: () => ( -
- {}} - /> - -
- ), -}; - -export const DropdownRenderTriggerClosed: Story = { - render: () => ( -
- ( - - )} - /> - -
- ), -}; - -export const DropdownButtonDisabled: Story = { - render: () => ( -
- - -
- ), -}; - -export const MoreMenuClosed: Story = { - render: () => ( -
- - -
- ), -}; - -export const MoreMenuDisabled: Story = { - render: () => ( -
- - -
- ), -}; +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/src/patterns/menu-button.ts b/internal/a11y-spec/src/patterns/menu-button.ts index e3ce88c7590ee..f2346148cc229 100644 --- a/internal/a11y-spec/src/patterns/menu-button.ts +++ b/internal/a11y-spec/src/patterns/menu-button.ts @@ -353,8 +353,10 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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: [WCAG_2_1_1, APG_DOWN], - covers: ['2.1.1-keyboard', '2.4.3-focus-order', 'apg-interaction'], + 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', @@ -377,8 +379,10 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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: [WCAG_2_1_1, APG_UP], - covers: ['2.1.1-keyboard', '2.4.3-focus-order', 'apg-interaction'], + 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', diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts index 193136a0a7f36..8cfa7cefd82b9 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts @@ -134,15 +134,9 @@ for (const state of MENU_BUTTON_BINDING_STATES) { 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( - result.results.filter( - row => - row.status !== 'pass' && - row.status !== 'not-applicable' && - row.status !== 'known-failure', - ), - ).toEqual([]); expect(report.unrunLayers).toEqual([]); expect(report.counts.unexpectedPass).toBe(0); }); From 673b45dddcb8c0fa8acb3fba25e0079b53689bda Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 13 Oct 2026 12:51:56 -0700 Subject: [PATCH 5/7] test(a11y): menu-button story ids match the A11y/MenuButton title; CI step decides scope through pr-scope-touches.sh --- .github/scripts/pr-scope-touches.test.mjs | 4 +++- .github/workflows/ci.yml | 18 +++++++++++------- .../__tests__/MenuButton.a11y.states.ts | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/scripts/pr-scope-touches.test.mjs b/.github/scripts/pr-scope-touches.test.mjs index 17b7861db4150..12570d2680842 100644 --- a/.github/scripts/pr-scope-touches.test.mjs +++ b/.github/scripts/pr-scope-touches.test.mjs @@ -179,7 +179,9 @@ describe('pr-scope-touches', () => { workflow.match( /git show "\$BASE_SHA:\.github\/scripts\/pr-scope-touches\.sh" > "\$helper"/g, ) ?? []; - expect(uses.length).toBe(4); + // Every consumer loads the helper from the base; new contract steps add + // consumers, so the count is a floor, not an exact tally. + expect(uses.length).toBeGreaterThanOrEqual(4); expect(fromBase.length).toBe(uses.length); expect(workflow).not.toMatch(/bash \.github\/scripts\/pr-scope-touches/); }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eb328ccd4096..ab23fd96be2fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1596,13 +1596,17 @@ jobs: run: | should_run=false # DropdownMenu, MoreMenu, and the contract are matched by file name. - # If the base cannot be fetched, run. - if git fetch --quiet --depth=1 origin "$BASE_SHA"; then - if git diff --name-only "$BASE_SHA" HEAD \ - | grep -qE '^(internal/a11y-spec/src/patterns/menu-button|packages/core/src/DropdownMenu/|packages/core/src/MoreMenu/|apps/storybook/stories/MenuButtonA11y)'; then - should_run=true - fi - else + # 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" diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts index d51b976e625ed..d76e639649aa6 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts @@ -49,7 +49,7 @@ function facts( }; } -const story = (id: string) => `a11y-menu-button--${id}`; +const story = (id: string) => `a11y-menubutton--${id}`; export const MENU_BUTTON_BINDING_STATES = [ { From ab6b654a344a6c70fc733a69fa265c5239d5957a Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 15 Oct 2026 07:20:33 -0700 Subject: [PATCH 6/7] test(a11y): run the aborted-press check on every menu-button state and record its exact failures The press-to-open model the opensOnPress exclusion relied on is still proposed (useMenuPress FR8/DEC-4, OQ1), so the required WCAG 2.5.2 row now applies to every operable closed trigger and the three press-opened states carry exact known-failure records. The disabled-MoreMenu known failure is gone: main now keeps a disabled trigger closed. The adopted required parts cite spec:AST-020 FR15. --- .../src/patterns/menu-button.fixtures.ts | 1 - .../a11y-spec/src/patterns/menu-button.ts | 37 +++++++++------- .../MenuButton.a11y.chromium.spec.ts | 9 +--- .../MenuButton.a11y.known-failures.ts | 44 +++++++++++++++---- .../__tests__/MenuButton.a11y.states.ts | 16 ------- .../__tests__/MenuButton.a11y.test.tsx | 3 +- 6 files changed, 60 insertions(+), 50 deletions(-) diff --git a/internal/a11y-spec/src/patterns/menu-button.fixtures.ts b/internal/a11y-spec/src/patterns/menu-button.fixtures.ts index ad5562345a309..1c0d70bd8868f 100644 --- a/internal/a11y-spec/src/patterns/menu-button.fixtures.ts +++ b/internal/a11y-spec/src/patterns/menu-button.fixtures.ts @@ -95,7 +95,6 @@ const facts = ( focusable: true, controls: true, arrowKeysOpen: true, - opensOnPress: false, ...overrides, }); diff --git a/internal/a11y-spec/src/patterns/menu-button.ts b/internal/a11y-spec/src/patterns/menu-button.ts index f2346148cc229..6fd58a5ccf179 100644 --- a/internal/a11y-spec/src/patterns/menu-button.ts +++ b/internal/a11y-spec/src/patterns/menu-button.ts @@ -10,6 +10,7 @@ import { definePattern, type ApgRequirement, + type AstryxRecord, type PatternContract, type WcagCriterion, } from '../contract'; @@ -101,6 +102,20 @@ const APG_UP: ApgRequirement = { 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 @@ -120,13 +135,6 @@ export interface MenuButtonStateFacts { * item), the APG-optional keys Astryx ships for anchored menus. */ readonly arrowKeysOpen: boolean; - /** - * Whether the binding opens the menu on pointer press-down (a press model - * where the row under the release acts) rather than on click. A press-model - * control cannot be judged by the aborted-press check: the press itself is - * the open, and its module owns what a release elsewhere does. - */ - readonly opensOnPress: boolean; } const ALWAYS = { @@ -239,7 +247,7 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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], + sources: [WCAG_4_1_2, APG_HASPOPUP, AST_020_FR15], covers: ['4.1.2-name-role-value', 'apg-interaction'], appliesWhen: ALWAYS, evidenceLayer: 'dom', @@ -257,7 +265,7 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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], + sources: [WCAG_4_1_2, APG_EXPANDED, AST_020_FR15], covers: ['4.1.2-name-role-value', 'apg-interaction'], appliesWhen: ALWAYS, evidenceLayer: 'dom', @@ -275,7 +283,7 @@ export const MENU_BUTTON_PATTERN: PatternContract = id: 'menu-button.control.controls-menu', outcome: 'The declared control relationship identifies the menu element.', - sources: [WCAG_1_3_1, APG_CONTROLS], + sources: [WCAG_1_3_1, APG_CONTROLS, AST_020_FR15], covers: ['1.3.1-info-and-relationships', 'apg-interaction'], appliesWhen: { condition: @@ -317,7 +325,7 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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], + sources: [WCAG_2_1_1, APG_ENTER, AST_020_FR15], covers: ['2.1.1-keyboard', '2.4.3-focus-order', 'apg-interaction'], appliesWhen: { condition: @@ -335,7 +343,7 @@ export const MENU_BUTTON_PATTERN: PatternContract = 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], + sources: [WCAG_2_1_1, APG_SPACE, AST_020_FR15], covers: ['2.1.1-keyboard', '2.4.3-focus-order', 'apg-interaction'], appliesWhen: { condition: @@ -469,9 +477,8 @@ export const MENU_BUTTON_PATTERN: PatternContract = sources: [WCAG_2_5_2], covers: ['2.5.2-pointer-cancellation'], appliesWhen: { - condition: - 'the menu starts hidden, the control can be operated, and it opens on click rather than on press-down', - test: facts => !facts.open && facts.operable && !facts.opensOnPress, + condition: 'the menu starts hidden and the control can be operated', + test: facts => !facts.open && facts.operable, }, evidenceLayer: 'real-browser', alsoNeeds: ['dom'], diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts index 8cfa7cefd82b9..fd45da35bd27d 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.chromium.spec.ts @@ -36,7 +36,6 @@ import { import {MENU_BUTTON_KNOWN_FAILURES} from './MenuButton.a11y.known-failures'; import { MENU_BUTTON_BINDING_STATES, - PRESS_MODEL_EXCLUDED_EXPECTATIONS, type MenuButtonBindingState, } from './MenuButton.a11y.states'; @@ -115,13 +114,7 @@ test('every menu-button expectation is exercised by a DropdownMenu or MoreMenu s for (const state of MENU_BUTTON_BINDING_STATES) { results.push(await runState(page, cdp, state)); } - // The aborted-press check never applies to a press-model trigger; the - // binding's exclusion names its owner. - expect( - neverExercised(results).filter( - id => !PRESS_MODEL_EXCLUDED_EXPECTATIONS.includes(id), - ), - ).toEqual([]); + expect(neverExercised(results)).toEqual([]); expect(unmatchedKnownFailures(MENU_BUTTON_KNOWN_FAILURES, results)).toEqual( [], ); diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts index 17a3ff3aa4371..a6b6deb428a2a 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.known-failures.ts @@ -9,18 +9,46 @@ 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.unavailable-stays-closed', + 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-disabled', + state: 'more-menu-closed', evidenceLayer: 'real-browser', - failureEquals: 'an unavailable control opened its menu on ArrowDown', - standardsReference: - 'WCAG 2.2 4.1.2 Name, Role, Value; APG Menu Button — a control that says aria-disabled must not open its menu; Astryx spec:AST-021 FR4', + failureEquals: + 'releasing the press away from the control left aria-expanded true instead of false', + standardsReference: ABORTED_PRESS_STANDARD, userImpact: - 'A keyboard user on a disabled MoreMenu hears it is unavailable, yet ArrowDown (and ArrowUp) opens the menu: Button suppresses Enter and Space for an aria-disabled control but lets the arrow keys through to the DropdownMenu open handler.', - reason: - 'This test-only binding records the gap; the fix is a DropdownMenu change that ships as its own spec-backed patch.', + '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.states.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts index d76e639649aa6..eb9726b771485 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts @@ -44,7 +44,6 @@ function facts( focusable: true, controls: true, arrowKeysOpen: true, - opensOnPress: true, ...overrides, }; } @@ -126,13 +125,6 @@ export const MENU_BUTTON_EXCLUSIONS = [ 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: 'press-model-pointer-cancellation', - owner: - 'module:DropdownMenu/useMenuPress and MenuPress.a11y.chromium.spec.ts', - reason: - "The anchored trigger opens on pointer press-down and the row under the release acts; what a release away from the trigger does is the press model's own contract, proven by its module evidence, so the generic aborted-press check does not apply here (opensOnPress).", - }, { id: 'bottom-sheet-presentation', owner: 'component:DropdownMenu FR13 and the modal-dialog pattern', @@ -140,11 +132,3 @@ export const MENU_BUTTON_EXCLUSIONS = [ '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 is outside this binding by the records\' own scope (DropdownMenu AR4, MoreMenu AR1).', }, ] as const; - -/** - * Expectations no binding state can exercise because every anchored trigger - * opens on press-down (see the `press-model-pointer-cancellation` exclusion). - */ -export const PRESS_MODEL_EXCLUDED_EXPECTATIONS: ReadonlyArray = [ - 'menu-button.control.survives-an-aborted-press', -]; diff --git a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx index a230ce023f7e4..598bebb69bc25 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx @@ -111,10 +111,9 @@ describe('menu-button binding inventory', () => { expect(new Set(stories).size).toBe(stories.length); }); - it('keeps menu-surface, press-model and bottom-sheet ownership explicit', () => { + it('keeps menu-surface and bottom-sheet ownership explicit', () => { expect(MENU_BUTTON_EXCLUSIONS.map(row => row.id)).toEqual([ 'menu-surface-semantics', - 'press-model-pointer-cancellation', 'bottom-sheet-presentation', ]); for (const exclusion of MENU_BUTTON_EXCLUSIONS) { From 42c924f81c92257440198ce500eb642d8afb4db6 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 15 Oct 2026 07:54:24 -0700 Subject: [PATCH 7/7] test(a11y): name the header/footer popover exclusion and mark the arrow opens advisory The bottom-sheet exclusion points at DropdownMenu AR4, which places it outside the pattern; a pointer menu with a header or footer (AR4/AR5, its trigger says aria-haspopup="dialog") gets its own exclusion row. The README row marks ArrowDown/ArrowUp opening advisory, as the contract does. --- internal/a11y-spec/README.md | 52 +++++++++---------- .../__tests__/MenuButton.a11y.states.ts | 11 +++- .../__tests__/MenuButton.a11y.test.tsx | 3 +- 3 files changed, 37 insertions(+), 29 deletions(-) diff --git a/internal/a11y-spec/README.md b/internal/a11y-spec/README.md index 79363fb548020..676f8471a2226 100644 --- a/internal/a11y-spec/README.md +++ b/internal/a11y-spec/README.md @@ -67,32 +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 | -| `menu-button` | [APG Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) required parts plus the optional ArrowDown/ArrowUp opens Astryx ships | Anchored DropdownMenu and MoreMenu triggers: button role and name, `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`, Enter/Space/ArrowDown open on the first enabled item, 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 | +| 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/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts index eb9726b771485..7bd3ef42f6689 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.states.ts @@ -127,8 +127,15 @@ export const MENU_BUTTON_EXCLUSIONS = [ }, { id: 'bottom-sheet-presentation', - owner: 'component:DropdownMenu FR13 and the modal-dialog pattern', + 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 is outside this binding by the records\' own scope (DropdownMenu AR4, MoreMenu AR1).', + '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 index 598bebb69bc25..948f4c668e846 100644 --- a/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx +++ b/packages/core/src/DropdownMenu/__tests__/MenuButton.a11y.test.tsx @@ -111,10 +111,11 @@ describe('menu-button binding inventory', () => { expect(new Set(stories).size).toBe(stories.length); }); - it('keeps menu-surface and bottom-sheet ownership explicit', () => { + 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);