Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main replaced this two-dot scope check in every PR evidence step with .github/scripts/pr-scope-touches.sh, loaded from BASE_SHA (#7470): on a branch cut before the base gained a file, the base-tip diff lists that file as changed. Once this lands, every older branch would run this step against spec files its head lacks and fail pr-a11y with “No tests found” until rebased. After rebasing, reuse the Link step's helper block with this pattern.

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
Expand Down
113 changes: 113 additions & 0 deletions apps/storybook/stories/MenuButtonA11y.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MenuButton.a11y.renders.tsx says the story renders through it, but these stories re-declare each state and the item list. Building them from MENU_BUTTON_BINDING_STATES and renderMenuButtonState, as the sibling a11y stories do, keeps the jsdom and Chromium lanes on one tree.

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<typeof meta>;

const items = [
{label: 'Rename'},
{label: 'Duplicate'},
{label: 'Archive', isDisabled: true},
{label: 'Delete'},
];

function After() {
return <button type="button">After the menu</button>;
}

export const DropdownButtonClosed: Story = {
render: () => (
<div>
<DropdownMenu
button={{label: 'Actions'}}
items={items}
presentation="popover"
/>
<After />
</div>
),
};

export const DropdownButtonOpen: Story = {
render: () => (
<div>
<DropdownMenu
button={{label: 'Actions'}}
items={items}
presentation="popover"
isMenuOpen
onOpenChange={() => {}}
/>
<After />
</div>
),
};

export const DropdownRenderTriggerClosed: Story = {
render: () => (
<div>
<DropdownMenu
items={items}
presentation="popover"
renderTrigger={({ref, ...props}) => (
<button type="button" ref={ref} {...props}>
Row actions
</button>
)}
/>
<After />
</div>
),
};

export const DropdownButtonDisabled: Story = {
render: () => (
<div>
<DropdownMenu
button={{label: 'Actions', isDisabled: true}}
items={items}
presentation="popover"
/>
<After />
</div>
),
};

export const MoreMenuClosed: Story = {
render: () => (
<div>
<MoreMenu label="More options" items={items} presentation="popover" />
<After />
</div>
),
};

export const MoreMenuDisabled: Story = {
render: () => (
<div>
<MoreMenu
label="More options"
items={items}
presentation="popover"
isDisabled
/>
<After />
</div>
),
};
36 changes: 19 additions & 17 deletions internal/a11y-spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,32 @@ 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
```

## 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
Expand Down
4 changes: 4 additions & 0 deletions internal/a11y-spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
103 changes: 103 additions & 0 deletions internal/a11y-spec/src/patterns/menu-button.chromium.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<!doctype html><html lang="en"><body>${html}<button type="button" id="after">After menu</button></body></html>`;
}

async function results(
page: Page,
cdp: CDPSession,
target: MenuButtonFixture,
only?: readonly string[],
): Promise<readonly ExpectationResult[]> {
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('');
});
}
}
});
Loading
Loading