Skip to content

feat: checkOption finds checkables by ARIA role - #5704

Merged
DavertMik merged 2 commits into
4.xfrom
feat/checkoption-aria-roles
Sep 8, 2026
Merged

feat: checkOption finds checkables by ARIA role#5704
DavertMik merged 2 commits into
4.xfrom
feat/checkoption-aria-roles

Conversation

@DavertMik

@DavertMik DavertMik commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

I.checkOption('Accept terms') fails 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']. The user gets "Checkbox or radio … was not found by text|CSS|XPath".

This is a lookup gap, not an action gap: checkOption({ role: 'checkbox', name: 'Accept terms' }) already works today, because a strict role locator goes through handleRoleLocator and Playwright's check() / isChecked() fully support role=checkbox|radio|switch with aria-checked. Only the fuzzy-string path was missing.

All three helpers now look up checkables through the accessibility tree before the label XPath, and all three restrict that lookup to the checkable roles.

Playwright — add the ARIA pass

findCheckable now tries getByRole('checkbox'|'radio'|'switch', { name }) before the XPath strategies, mirroring what findClickable already does for button / link. Native <input type=checkbox|radio> expose those roles too, so the pass is a superset of the strategies it precedes.

Exact across all three roles first, then substring across all three. Playwright's name option defaults to case-insensitive substring, and exact-first keeps the precision of the XPath path being replaced. Two passes rather than exact-then-substring per role, so a checkbox substring hit can't beat a radio exact hit.

seeCheckboxIsChecked / dontSeeCheckboxIsChecked route through the same lookup (proceedIsCheckedfindCheckable) and are fixed by it. (On WebDriver they go through findFields instead, so this PR does not change them there.)

WebDriver / Puppeteer — the ordering is the point

Both helpers already had an ARIA fallback, but ran it after the label XPath. That is too late.

Base UI always renders a hidden mirror <input>, moves the author's id onto it, and points <label for> at it, giving the visible control a generated id plus aria-labelledby:

<span role="checkbox" aria-checked="false" aria-labelledby="terms-label"></span>
<input id="terms" tabindex="-1" aria-hidden="true" type="checkbox">
<label for="terms" id="terms-label">Accept terms</label>

So the label XPath succeeds — and resolves the mirror, which measures 1×1 px at x:-1, y:-1. Measured on this branch with the ARIA lookup disabled, Playwright fails as:

locator.check: Element is outside of the viewport
Call log:
  - waiting for locator('xpath=.//input[@type = 'checkbox' or @type = 'radio'][(@id = //label[@for][contains(...,'Accept terms')]/@for) …')
    - locator resolved to <input id="terms" tabindex="-1" type="checkbox" aria-hidden="true"/>

and WebDriver, with its aria/ lookup moved back after the XPath:

element click intercepted: Element <input id="terms" tabindex="-1" aria-hidden="true" type="checkbox" …>
is not clickable at point (0, 0)

Because aria-hidden="true" removes that input from the accessibility tree, an ARIA lookup lands on the visible control instead. Hence the ARIA strategy must run before the label XPath, not as a fallback after it — in all three helpers.

Keeping the reorder from costing precision

Hoisting an accessible-name lookup above the XPath widens the net, because aria/… and ::-p-aria(…) match on name alone and are not role-scoped. On a page where a heading shares the checkbox label's text, the non-control wins on document order where byText previously reached the input. Measured on the new collision fixture:

puppeteer ::-p-aria(Accept terms)               -> [ H2, INPUT#terms-box ]
webdriverio aria/ union (document order)        -> [ H2, INPUT#terms-box ]

So both hoisted lookups are role-restricted as well as reordered:

  • lib/helper/Puppeteer.js — loops the three checkable roles as ::-p-aria([name="…"][role="…"]), following the buildRoleSelector convention already in the file. ::-p-aria name matching is exact and case-sensitive (::-p-aria(Accept) matches nothing on a control named "Accept terms"), so there is no exact/substring distinction to make — one pass per role is the whole story. Falls through to the XPath on any parse failure, so a name containing a quote behaves exactly as it did before.
  • lib/helper/WebDriver.js — webdriverio's aria/ takes no attribute filter, so the returned elements are post-filtered to input[type=checkbox|radio] or [role=checkbox|radio|switch]. This is one browser.execute round trip regardless of match count (measured 6–11 ms), not one per element, using the execute(fn, ...elements) form already used elsewhere in the file.

After the restriction both return [ INPUT#terms-box ], matching Playwright's role-scoped behaviour. The reorder now costs no precision on any helper.

Tests

Fixtures render the real components via importmap + esm.sh, following the convention from #5701:

  • test/data/app/view/form/checkable/radix.phpradix-ui@1.6.7
  • test/data/app/view/form/checkable/baseui.php@base-ui/react@1.8.0
  • test/data/app/view/form/checkable/collision.php — plain HTML: an <h2> sharing the checkbox label's accessible name

Each component page has a Checkbox, a Switch and a Radio Group with visible labels; the Base UI page reproduces the <label for> → hidden mirror input wiring, which is the case that proves the ordering. Each control carries a .ctl-* class so assertions can target the visible element without depending on library-generated ids.

test/helper/webapi.js gains a #checkOption - ARIA roles block (shared across all three helpers, no parallel spec) that asserts observable statearia-checked flipping on the visible element — for checkOption, uncheckOption, seeCheckboxIsChecked and dontSeeCheckboxIsChecked, plus two regression cases: a plain <input type=checkbox> + <label>, and the collision fixture (asserted through a CSS locator so the assertion path cannot be fooled by the lookup under test).

Two existing tests in aria selectors without role locators were guarded to WebDriver only because Playwright could not reach a <span role=checkbox aria-label=…>; the guards are removed and they now pass on all three helpers.

Known limitation, kept as a this.skip() with the reason in the test: Radix checkables are unreachable by label on WebDriver regardless of ordering. webdriverio's aria/ selector resolves <label for> to input/textarea only, and Radix's <button role=checkbox> has no aria-label, no aria-labelledby and no text of its own — so neither the ARIA union nor Locator.checkable can find it. Fixing that needs a new WebDriver strategy, which is more than a reorder.

Verification

Each change was checked by reverting it and watching the matching test fail:

reverted result
Playwright ARIA pass 7 of 8 new tests fail (the plain-input regression case still passes, which is the point)
WebDriver reorder all three Base UI cases fail — element click intercepted on the mirror input
Puppeteer role restriction collision case fails — the <h2> is resolved and clicked
WebDriver role post-filter collision case fails — expected checkable field "#terms-box" to be checked

Local suites (run serially against a private fixture server and selenium/standalone-chrome:4.27): Playwright, Puppeteer and WebDriver helper suites green apart from environment-dependent cases (network-traffic tests needing live codecept.io, cookie tests, one puppeteer sandbox launch) — all of which pass in CI. npm run test:unit 770 passing, npm run lint clean.

No JSDoc on a public action changed, so no npm run def regeneration.

Out of scope

aria-pressed toggles and role=menuitemcheckbox (Playwright's check() refuses both), Locator.field, dragSlider / moveCursorTo, proceedSelect — separate follow-ups.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi

DavertMik and others added 2 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
@DavertMik
DavertMik merged commit cfc9545 into 4.x Sep 8, 2026
15 of 17 checks passed
@DavertMik
DavertMik deleted the feat/checkoption-aria-roles branch September 8, 2026 23:33
DavertMik added a commit that referenced this pull request Sep 9, 2026
#5708)

Locator.checkable already knows how to find a control by its label. It just
hard-coded the tag. Headless component libraries express the same semantics
with a role, so generalise the tag test to a tag-or-role test and skip
aria-hidden elements.

    self::input[@type='checkbox' or @type='radio']
      or @ROLE='checkbox' or @ROLE='radio' or @ROLE='switch'

This replaces the three per-helper implementations added in #5704 — a
getByRole loop in Playwright, a role-scoped ::-p-aria loop in Puppeteer, and a
hoisted aria/ lookup plus keepCheckable filter in WebDriver — with one XPath
predicate that all three helpers inherit, since they all call
Locator.checkable.byText. Puppeteer's and WebDriver's ARIA fallbacks return to
their original position after the XPath.

An XPath predicate is role-scoped by construction, so there is no precision
trade-off to manage: a heading sharing the label text cannot match, and the
per-helper filtering that guarded against it is no longer needed.

The aria-hidden guard is what skips the hidden mirror input that libraries
render and point <label for> at, so the visible control is resolved instead.

Behaviour is unchanged for native controls: on existing fixtures the generated
XPath returns an identical node set.

Verified against real radix-ui@1.6.7 and @base-ui/react@1.8.0 components:
checkOption and seeCheckboxIsChecked work for Checkbox, Switch, Radio Group and
Checkbox Group on both libraries, standalone and inside a form.

Playwright 53 passing, Puppeteer 49 passing, WebDriver 41 passing (3 pending),
unit 815 + 81 locator, lint clean. Net -25 lines of library code.


Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi

Co-authored-by: DavertMik <davert@testomat.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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