feat: checkable menu items and pressed-state toggle buttons - #5706
Closed
DavertMik wants to merge 3 commits into
Closed
feat: checkable menu items and pressed-state toggle buttons#5706DavertMik wants to merge 3 commits into
DavertMik wants to merge 3 commits into
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements plan P6: the two component families the checkable actions could not reach — checkbox/radio items inside Dropdown Menus, and
aria-pressedtoggle buttons — on Radix and Base UI.What changed
checkOptionreaches menu items.role=menuitemcheckboxandrole=menuitemradiojoin the ARIA role pass in all three helpers (Playwright'sgetByRoleloop, Puppeteer's::-p-aria([name][role])loop, WebDriver'skeepCheckablefilter).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 androle=checkbox|radio|switchkeep usingcheck()/uncheck(), which P1 proved works for them.WebDriver's
seeCheckboxIsCheckedfalls back tofindCheckable.proceedSeeCheckboxgoes throughfindFields(unlike Playwright and Puppeteer, which go throughfindCheckable), and no menu item can satisfy a field lookup. The fallback runs only whenfindFieldscomes 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, withdocs/webapi/*.mustachepartials.grabCheckedElementStatusreads ARIA.aria-checkedthenaria-pressed, instead of requiringtype=checkbox|radio; when it still cannot answer it names the role it found. The existing error-message assertion inPlaywright_test.jsis 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 undercheckOption; a toggle button is not a checkbox, soI.checkOption('Bold')would otherwise mean two different things.The alternative is to route
aria-pressedthroughcheckOption/seeCheckboxIsCheckedas well. That is a small change —setCheckableStateand the state reader would takearia-pressedalongsidearia-checked— andtoggleButton/seeButtonIsPressedwould either go away or become aliases. Say the word and I will switch it.A related, smaller call:
seeCheckboxIsCheckedon anaria-pressedbutton currently throws "is a toggle button with aria-pressed=…, use seeButtonIsPressed" rather than answering. The plan's §3 asked for a silent fallback toaria-pressedthere; 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
check()does supportmenuitemcheckbox. 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 (closeOnClickdefaults to false) passedcheck()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".<button role=checkbox>has no accessible name of its own. A menu item's text content is its accessible name, soaria/Show status barresolves and no WebDriver skips were needed here. The only WebDriver gap was thefindFieldslookup above.checkOptionon menu items already worked on Puppeteer and WebDriver before this PR's role-list change, and is already idempotent there — both readaria-checkedand click only on a mismatch. They regressed only because P1's role restriction (c4e7fed) narrowed the ARIA pass tocheckbox|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.__readypattern, 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, stillaria-checked="true".Specs live in
test/helper/webapi.jsnext to P1's block, so they run on all three helpers;grabCheckedElementStatusis Playwright-only and is covered inPlaywright_test.jsnext to the existing cases.Local, against a PHP fixture server, run serially:
seeTrafficagainst codecept.io)seeTraffic, a Chrome sandbox crash in the Trace suite)npm run test:unitnpm run lint,npm run def+npm run dtslint🤖 Generated with Claude Code
https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi