Skip to content

refactor(locator): find ARIA checkables in Locator, not in each helper - #5708

Open
DavertMik wants to merge 1 commit into
4.xfrom
fix/aria-checkable-in-locator
Open

refactor(locator): find ARIA checkables in Locator, not in each helper#5708
DavertMik wants to merge 1 commit into
4.xfrom
fix/aria-checkable-in-locator

Conversation

@DavertMik

Copy link
Copy Markdown
Contributor

Follow-up to #5704, which I wrote and which you were right to find inelegant. It solved the problem three times — once per helper — and left the concept out of Locator, where it belongs.

The idea

Locator.checkable already knows how to find a control by its label: <label for>@id, nesting inside the label, @aria-label, @aria-labelledby. Those mechanisms are correct. The only thing wrong is that they hard-code the tag. Headless component libraries express the same semantics with a role.

const checkable = `self::input[@type = 'checkbox' or @type = 'radio'] or @role = 'checkbox' or @role = 'radio' or @role = 'switch'`
const visibleCheckable = `.//*[${checkable}][not(@aria-hidden = 'true')]`

Generalise the tag test to a tag-or-role test, skip aria-hidden. Nothing else. Mechanisms 3 and 4 already existed on Locator.field; this brings them to Locator.checkable, where they were missing.

What it removes

#5704 here
Playwright getByRole loop over 3 roles × exact/substring
Puppeteer role-scoped ::-p-aria loop + checkableRoles
WebDriver hoisted aria/ + keepCheckable filter
Locator untouched one predicate

Puppeteer's and WebDriver's ARIA fallbacks return to their original position after the XPath. Net −25 lines of library code, no new public methods, and nothing in the diff names a component library.

An XPath predicate is role-scoped by construction, so the precision problem #5704 had to solve per-helper — a heading sharing the label text winning over the control — cannot arise, and the filtering that guarded against it is gone.

The not(@aria-hidden = 'true') guard is what skips the hidden mirror input these libraries render and point <label for> at, so the visible control is resolved instead. Same guard #5703 uses for Locator.field.

Verification

Against real radix-ui@1.6.7 and @base-ui/react@1.8.0, standalone and inside a <form>checkOption / seeCheckboxIsChecked on Checkbox, Switch, Radio Group, Checkbox Group work on both libraries.

  • Playwright 53 passing, Puppeteer 49 passing, WebDriver 41 passing / 3 pending, 0 failing
  • unit 815 passing, plus 81 in locator_test.js including 5 new cases pinning the predicate
  • lint clean
  • On existing native fixtures the generated XPath returns an identical node set — no behaviour change for plain HTML

#5704's own behaviour-level tests are kept unchanged and still pass; they were good, and they are what proves this is a refactor rather than a rewrite.

Two things I checked and did not claim

Scope

Deliberately excluded: aria-pressed toggles (a toggle button is not a checkable, and I.click('Bold') already works), role=menuitemcheckbox, and sliders. None of them need new public API to state, and none were the ask.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi

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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
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