Skip to content

feat: checkable menu items and pressed-state toggle buttons - #5706

Closed
DavertMik wants to merge 3 commits into
4.xfrom
feat/checkable-toggles-and-menu-items
Closed

feat: checkable menu items and pressed-state toggle buttons#5706
DavertMik wants to merge 3 commits into
4.xfrom
feat/checkable-toggles-and-menu-items

Conversation

@DavertMik

Copy link
Copy Markdown
Contributor

Stacked on #5704 (feat/checkoption-aria-roles). It targets 4.x and will show P1's two commits until #5704 merges; review only feat: checkable menu items and pressed-state toggle buttons. Rebase/retarget once P1 lands.

Implements plan P6: the two component families the checkable actions could not reach — checkbox/radio items inside Dropdown Menus, and aria-pressed toggle buttons — on Radix and Base UI.

What changed

checkOption reaches menu items. role=menuitemcheckbox and role=menuitemradio join the ARIA role pass in all three helpers (Playwright's getByRole loop, Puppeteer's ::-p-aria([name][role]) loop, WebDriver's keepCheckable filter).

Playwright takes a click-plus-verify path for those two roles. It reads aria-checked, clicks only if the state differs from the one requested, then waits for the new state. Native inputs and role=checkbox|radio|switch keep using check()/uncheck(), which P1 proved works for them.

WebDriver's seeCheckboxIsChecked falls back to findCheckable. proceedSeeCheckbox goes through findFields (unlike Playwright and Puppeteer, which go through findCheckable), and no menu item can satisfy a field lookup. The fallback runs only when findFields comes back empty, so nothing that resolves today changes.

Pressed state gets its own API. I.toggleButton(locator), I.seeButtonIsPressed(locator), I.dontSeeButtonIsPressed(locator) on all three helpers, with docs/webapi/*.mustache partials.

grabCheckedElementStatus reads ARIA. aria-checked then aria-pressed, instead of requiring type=checkbox|radio; when it still cannot answer it names the role it found. The existing error-message assertion in Playwright_test.js is updated to match.

The API question, for you to override

The plan flagged this and recommended the split implemented here: menu items are genuinely checkable (aria-checked), so they belong under checkOption; a toggle button is not a checkbox, so I.checkOption('Bold') would otherwise mean two different things.

The alternative is to route aria-pressed through checkOption/seeCheckboxIsChecked as well. That is a small change — setCheckableState and the state reader would take aria-pressed alongside aria-checked — and toggleButton/seeButtonIsPressed would either go away or become aliases. Say the word and I will switch it.

A related, smaller call: seeCheckboxIsChecked on an aria-pressed button currently throws "is a toggle button with aria-pressed=…, use seeButtonIsPressed" rather than answering. The plan's §3 asked for a silent fallback to aria-pressed there; I made it a pointed error instead, so checked-ness and pressed-ness stay distinct in assertions the way they do in actions. One-line reversal if you prefer the fallback.

Findings that correct the plan and the report

  • Playwright's check() does support menuitemcheckbox. The report's "hangs until timeout" is specifically the Radix case: check() clicks, Radix closes the menu on select, and the post-click verification then waits on a detached node until the timeout. Base UI (closeOnClick defaults to false) passed check() in 47 ms. So the click-plus-verify path exists to tolerate the element disappearing, not to work around a role refusal — and that is why it treats a vanished element as "applied".
  • WebDriver reaches menu items by label. P1 had to skip Radix checkables on WebDriver because an icon-only <button role=checkbox> has no accessible name of its own. A menu item's text content is its accessible name, so aria/Show status bar resolves and no WebDriver skips were needed here. The only WebDriver gap was the findFields lookup above.
  • checkOption on menu items already worked on Puppeteer and WebDriver before this PR's role-list change, and is already idempotent there — both read aria-checked and click only on a mismatch. They regressed only because P1's role restriction (c4e7fed) narrowed the ARIA pass to checkbox|radio|switch; adding the two menu roles restores them.

Tests

Four fixtures (form/menu/{radix,baseui}.php, form/toggle/{radix,baseui}.php) built with the importmap + esm.sh + window.__ready pattern, using the real libraries. Menu items exist only while the menu is open, and Radix closes it on select, so the spec reopens the menu before each read — which also makes the idempotency case real: check, reopen, check again, still aria-checked="true".

Specs live in test/helper/webapi.js next to P1's block, so they run on all three helpers; grabCheckedElementStatus is Playwright-only and is covered in Playwright_test.js next to the existing cases.

Local, against a PHP fixture server, run serially:

suite result
Playwright, full 3 failures, all pre-existing env (mock server on :3001, seeTraffic against codecept.io)
Puppeteer, full 5 failures, all pre-existing env (cookies, seeTraffic, a Chrome sandbox crash in the Trace suite)
WebDriver, full 405 passing, 0 failing, 27 pending
new blocks on all three helpers green
npm run test:unit 770 passing, 0 failing
npm run lint, npm run def + npm run dtslint clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi

DavertMik and others added 3 commits September 8, 2026 21:06
`checkOption('Accept terms')` failed on every headless component library.
Radix renders a checkbox as `<button role="checkbox" aria-checked>`, Base UI
as `<span role="checkbox">`; neither is an `<input>`, and all three strategies
in `Locator.checkable` hard-code `.//input[@type='checkbox' or @type='radio']`.

Playwright's `findCheckable` now runs a `getByRole('checkbox'|'radio'|'switch',
{ name })` pass — exact across the three roles first, then substring — mirroring
what `findClickable` already does for `button`/`link`. Native inputs expose
those roles too, so the pass is a superset of the XPath strategies it precedes.
`seeCheckboxIsChecked` / `dontSeeCheckboxIsChecked` route through the same
lookup and are fixed by it.

WebDriver and Puppeteer already had an ARIA fallback but ran it *after* the
label XPath, which is too late. Base UI renders a hidden mirror `<input>`, moves
the author's id onto it and points `<label for>` at it, so the label XPath
succeeds and resolves a 1x1 `aria-hidden` input at x:-1,y:-1 — the click is then
intercepted or reported outside the viewport. `aria-hidden` keeps that input out
of the accessibility tree, so the ARIA lookup lands on the visible control
instead. Both fallbacks now run before the label XPath.

Adds Radix and Base UI fixtures under /form/checkable and a shared spec block
asserting `aria-checked` flips on the visible element. Radix checkables stay
skipped on WebDriver: webdriverio's `aria/` selector resolves `<label for>` to
input/textarea only, and a Radix `<button role=checkbox>` has no accessible name
of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
Moving the accessible-name lookup above the label XPath fixed Base UI but
widened the net: `aria/…` and `::-p-aria(…)` match on name alone, so a heading
sharing a checkbox label's text now won on document order where `byText`
previously reached the input. Measured on the new collision fixture, both
returned `[H2, INPUT#terms-box]`.

Puppeteer loops the three checkable roles as `::-p-aria([name][role])`,
following the buildRoleSelector convention already in the file. `::-p-aria`
matches names exactly and case-sensitively, so one pass per role is enough;
a name that cannot be parsed falls through to the XPath as before.

WebDriver has no attribute filter on `aria/`, so its results are post-filtered
to `input[type=checkbox|radio]` and `[role=checkbox|radio|switch]` in a single
`browser.execute` round trip regardless of match count, using the
`execute(fn, ...elements)` form already used in the file.

Both now resolve `[INPUT#terms-box]`, matching Playwright's role-scoped pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
`checkOption` now reaches `role=menuitemcheckbox` and `role=menuitemradio`,
and pressed-state buttons get their own pair of actions.

Menu items are added to the ARIA role pass of all three helpers. Playwright's
`check()` clicks such an item but then waits for the state on a node the menu
may already have removed (Radix closes its menu on select, Base UI does not),
so those two roles take a click-plus-verify path instead: read `aria-checked`,
click only on a mismatch so a second `checkOption` cannot toggle the item back
off, then wait for the new state and treat a vanished element as applied.
Puppeteer and WebDriver already read `aria-checked` and already click only on
a mismatch, so only their role lists changed; WebDriver's
`seeCheckboxIsChecked` went through `findFields`, which no menu item can
satisfy, and now falls back to `findCheckable`.

`aria-pressed` is a different semantic from checked-ness, so Toggle and Toggle
Group (multiple) get `toggleButton`, `seeButtonIsPressed` and
`dontSeeButtonIsPressed` rather than an overloaded `checkOption`.
`seeCheckboxIsChecked` on such a button now names the right action instead of
surfacing Playwright's "Not a checkbox or radio button".

`grabCheckedElementStatus` reads `aria-checked` and `aria-pressed` instead of
requiring `type=checkbox|radio`, and names what it found when it still cannot
answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
@DavertMik DavertMik closed this Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant