Skip to content

Commit cfc9545

Browse files
DavertMikDavertMikclaude
authored
feat: checkOption finds checkables by ARIA role (#5704)
* feat: checkOption finds checkables by ARIA role `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 * fix: role-restrict the hoisted ARIA checkable lookup 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 --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6a98cae commit cfc9545

7 files changed

Lines changed: 253 additions & 19 deletions

File tree

lib/helper/Playwright.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ let defaultSelectorEnginesInitialized = false
5050
const popupStore = new Popup()
5151
const consoleLogStore = new Console()
5252
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
53+
const checkableRoles = ['checkbox', 'radio', 'switch']
5354

5455
import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
5556
import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
@@ -4388,6 +4389,17 @@ async function findCheckable(locator, context) {
43884389
return findElements.call(this, contextEl, matchedLocator)
43894390
}
43904391

4392+
for (const exact of [true, false]) {
4393+
for (const role of checkableRoles) {
4394+
try {
4395+
const roleEls = await contextEl.getByRole(role, { name: matchedLocator.value, exact }).all()
4396+
if (roleEls.length) return roleEls
4397+
} catch (err) {
4398+
// getByRole not supported or failed
4399+
}
4400+
}
4401+
}
4402+
43914403
const literal = xpathLocator.literal(matchedLocator.value)
43924404
let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
43934405
if (els.length) {

lib/helper/Puppeteer.js

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function wrapError(e) {
6464
let perfTiming
6565
const popupStore = new Popup()
6666
const consoleLogStore = new Console()
67+
const checkableRoles = ['checkbox', 'radio', 'switch']
6768

6869
/**
6970
* ## Configuration
@@ -3195,8 +3196,19 @@ async function findCheckable(locator, context) {
31953196
return findElements.call(this, contextEl, matchedLocator)
31963197
}
31973198

3199+
// Try ARIA selector for accessible name
3200+
let els
3201+
for (const role of checkableRoles) {
3202+
try {
3203+
els = await contextEl.$$(`::-p-aria([name="${matchedLocator.value}"][role="${role}"])`)
3204+
if (els.length) return els
3205+
} catch (err) {
3206+
// ARIA selector not supported or failed
3207+
}
3208+
}
3209+
31983210
const literal = xpathLocator.literal(matchedLocator.value)
3199-
let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
3211+
els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
32003212
if (els.length) {
32013213
return els
32023214
}
@@ -3205,14 +3217,6 @@ async function findCheckable(locator, context) {
32053217
return els
32063218
}
32073219

3208-
// Try ARIA selector for accessible name
3209-
try {
3210-
els = await contextEl.$$(`::-p-aria(${matchedLocator.value})`)
3211-
if (els.length) return els
3212-
} catch (err) {
3213-
// ARIA selector not supported or failed
3214-
}
3215-
32163220
return findElements.call(this, contextEl, matchedLocator.value)
32173221
}
32183222

lib/helper/WebDriver.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3248,24 +3248,39 @@ async function findCheckable(locator, locateFn) {
32483248
if (locator.isRole()) return locateFn(locator, true)
32493249
if (!locator.isFuzzy()) return locateFn(locator, true)
32503250

3251-
const literal = xpathLocator.literal(locator.value)
3252-
els = await locateFn(Locator.checkable.byText(literal))
3253-
if (els.length) return els
3254-
32553251
// Try ARIA selector for accessible name
32563252
try {
3257-
els = await locateFn(`aria/${locator.value}`)
3253+
els = await keepCheckable.call(this, await locateFn(`aria/${locator.value}`))
32583254
if (els.length) return els
32593255
} catch (e) {
32603256
// ARIA selector not supported or failed
32613257
}
32623258

3259+
const literal = xpathLocator.literal(locator.value)
3260+
els = await locateFn(Locator.checkable.byText(literal))
3261+
if (els.length) return els
3262+
32633263
els = await locateFn(Locator.checkable.byName(literal))
32643264
if (els.length) return els
32653265

32663266
return await locateFn(locator.value) // by css or xpath
32673267
}
32683268

3269+
async function keepCheckable(els) {
3270+
if (!els || !els.length) return []
3271+
3272+
const checkable = await this.browser.execute(function () {
3273+
return Array.prototype.slice.call(arguments).map(function (el) {
3274+
if (!el) return false
3275+
const role = el.getAttribute('role')
3276+
if (role) return ['checkbox', 'radio', 'switch'].indexOf(role) > -1
3277+
return el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')
3278+
})
3279+
}, ...els)
3280+
3281+
return els.filter((el, index) => checkable[index])
3282+
}
3283+
32693284
function withStrictLocator(locator) {
32703285
locator = new Locator(locator)
32713286
return locator.simplify()
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Base UI Checkables</title>
6+
<style>
7+
body { font-family: Arial, sans-serif; }
8+
.row { margin: 12px 0; display: flex; align-items: center; gap: 8px; }
9+
label { font-weight: bold; }
10+
span[role="checkbox"], span[role="radio"] { display: inline-block; width: 20px; height: 20px; border: 1px solid #666; background: #fff; border-radius: 3px; }
11+
span[role="checkbox"][data-checked], span[role="radio"][data-checked] { background: #2a6; }
12+
span[role="switch"] { display: inline-block; width: 42px; height: 22px; border: 1px solid #666; background: #ddd; border-radius: 11px; }
13+
span[role="switch"][data-checked] { background: #2a6; }
14+
</style>
15+
<script type="importmap">
16+
{"imports": {
17+
"react": "https://esm.sh/react@19.2.0",
18+
"react/jsx-runtime": "https://esm.sh/react@19.2.0/jsx-runtime",
19+
"react-dom": "https://esm.sh/react-dom@19.2.0",
20+
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client"
21+
}}
22+
</script>
23+
</head>
24+
<body>
25+
<h1>Base UI Checkables</h1>
26+
<div id="root"></div>
27+
<script type="module">
28+
import * as React from 'react'
29+
import { createRoot } from 'react-dom/client'
30+
import { Checkbox } from 'https://esm.sh/@base-ui/react@1.8.0/checkbox?external=react,react-dom'
31+
import { Switch } from 'https://esm.sh/@base-ui/react@1.8.0/switch?external=react,react-dom'
32+
import { Radio } from 'https://esm.sh/@base-ui/react@1.8.0/radio?external=react,react-dom'
33+
import { RadioGroup } from 'https://esm.sh/@base-ui/react@1.8.0/radio-group?external=react,react-dom'
34+
35+
const h = React.createElement
36+
37+
function App() {
38+
React.useEffect(() => { window.__ready = true }, [])
39+
return h('div', null,
40+
h('div', { className: 'row' },
41+
h(Checkbox.Root, { id: 'terms', className: 'ctl-terms' }, h(Checkbox.Indicator, null)),
42+
h('label', { htmlFor: 'terms' }, 'Accept terms')),
43+
h('div', { className: 'row' },
44+
h(Switch.Root, { id: 'airplane', className: 'ctl-airplane' }, h(Switch.Thumb, null)),
45+
h('label', { htmlFor: 'airplane' }, 'Airplane mode')),
46+
h(RadioGroup, { defaultValue: 'default', 'aria-label': 'Density' },
47+
h('div', { className: 'row' },
48+
h(Radio.Root, { value: 'default', id: 'r-default', className: 'ctl-default' }, h(Radio.Indicator, null)),
49+
h('label', { htmlFor: 'r-default' }, 'Default')),
50+
h('div', { className: 'row' },
51+
h(Radio.Root, { value: 'comfortable', id: 'r-comfortable', className: 'ctl-comfortable' }, h(Radio.Indicator, null)),
52+
h('label', { htmlFor: 'r-comfortable' }, 'Comfortable'))))
53+
}
54+
55+
createRoot(document.getElementById('root')).render(h(App))
56+
</script>
57+
</body>
58+
</html>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Checkable name collision</title>
6+
</head>
7+
<body>
8+
<h2>Accept terms</h2>
9+
<form action="/form/complex" method="POST">
10+
<label for="terms-box">Accept terms</label>
11+
<input type="checkbox" id="terms-box" name="terms" value="agree" />
12+
<input type="submit" value="Submit" />
13+
</form>
14+
</body>
15+
</html>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Radix Checkables</title>
6+
<style>
7+
body { font-family: Arial, sans-serif; }
8+
.row { margin: 12px 0; display: flex; align-items: center; gap: 8px; }
9+
label { font-weight: bold; }
10+
button[role="checkbox"], button[role="radio"] { width: 20px; height: 20px; border: 1px solid #666; background: #fff; border-radius: 3px; }
11+
button[role="checkbox"][data-state="checked"], button[role="radio"][data-state="checked"] { background: #2a6; }
12+
button[role="switch"] { width: 42px; height: 22px; border: 1px solid #666; background: #ddd; border-radius: 11px; }
13+
button[role="switch"][data-state="checked"] { background: #2a6; }
14+
</style>
15+
<script type="importmap">
16+
{"imports": {
17+
"react": "https://esm.sh/react@19.2.0",
18+
"react/jsx-runtime": "https://esm.sh/react@19.2.0/jsx-runtime",
19+
"react-dom": "https://esm.sh/react-dom@19.2.0",
20+
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client"
21+
}}
22+
</script>
23+
</head>
24+
<body>
25+
<h1>Radix Checkables</h1>
26+
<div id="root"></div>
27+
<script type="module">
28+
import * as React from 'react'
29+
import { createRoot } from 'react-dom/client'
30+
import { Checkbox, Switch, RadioGroup } from 'https://esm.sh/radix-ui@1.6.7?external=react,react-dom'
31+
32+
const h = React.createElement
33+
34+
function App() {
35+
React.useEffect(() => { window.__ready = true }, [])
36+
return h('div', null,
37+
h('div', { className: 'row' },
38+
h(Checkbox.Root, { id: 'terms', className: 'ctl-terms' }, h(Checkbox.Indicator, null)),
39+
h('label', { htmlFor: 'terms' }, 'Accept terms')),
40+
h('div', { className: 'row' },
41+
h(Switch.Root, { id: 'airplane', className: 'ctl-airplane' }, h(Switch.Thumb, null)),
42+
h('label', { htmlFor: 'airplane' }, 'Airplane mode')),
43+
h(RadioGroup.Root, { defaultValue: 'default', 'aria-label': 'Density' },
44+
h('div', { className: 'row' },
45+
h(RadioGroup.Item, { value: 'default', id: 'r-default', className: 'ctl-default' }, h(RadioGroup.Indicator, null)),
46+
h('label', { htmlFor: 'r-default' }, 'Default')),
47+
h('div', { className: 'row' },
48+
h(RadioGroup.Item, { value: 'comfortable', id: 'r-comfortable', className: 'ctl-comfortable' }, h(RadioGroup.Indicator, null)),
49+
h('label', { htmlFor: 'r-comfortable' }, 'Comfortable'))))
50+
}
51+
52+
createRoot(document.getElementById('root')).render(h(App))
53+
</script>
54+
</body>
55+
</html>

test/helper/webapi.js

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,85 @@ export function tests() {
548548
})
549549
})
550550

551+
describe('#checkOption - ARIA roles', function () {
552+
this.timeout(60000)
553+
554+
async function open(page) {
555+
await I.amOnPage(`/form/checkable/${page}`)
556+
await I.waitForFunction(() => window.__ready === true, [], 30)
557+
}
558+
559+
async function ariaChecked(css) {
560+
return I.grabAttributeFrom(css, 'aria-checked')
561+
}
562+
563+
for (const page of ['radix', 'baseui']) {
564+
describe(page, () => {
565+
beforeEach(function () {
566+
// webdriverio resolves `<label for>` to input/textarea only, and a Radix
567+
// `<button role=checkbox>` carries no accessible name of its own
568+
if (page === 'radix' && isHelper('WebDriver')) this.skip()
569+
})
570+
571+
it('checks and unchecks a checkbox by its label', async () => {
572+
await open(page)
573+
await I.dontSeeCheckboxIsChecked('Accept terms')
574+
575+
await I.checkOption('Accept terms')
576+
expect(await ariaChecked('.ctl-terms')).to.equal('true')
577+
await I.seeCheckboxIsChecked('Accept terms')
578+
579+
await I.uncheckOption('Accept terms')
580+
expect(await ariaChecked('.ctl-terms')).to.equal('false')
581+
await I.dontSeeCheckboxIsChecked('Accept terms')
582+
})
583+
584+
it('checks a switch by its label', async () => {
585+
await open(page)
586+
await I.checkOption('Airplane mode')
587+
expect(await ariaChecked('.ctl-airplane')).to.equal('true')
588+
await I.seeCheckboxIsChecked('Airplane mode')
589+
})
590+
591+
it('checks a radio by its label', async () => {
592+
await open(page)
593+
await I.dontSeeCheckboxIsChecked('Comfortable')
594+
595+
await I.checkOption('Comfortable')
596+
expect(await ariaChecked('.ctl-comfortable')).to.equal('true')
597+
expect(await ariaChecked('.ctl-default')).to.equal('false')
598+
await I.seeCheckboxIsChecked('Comfortable')
599+
})
600+
})
601+
}
602+
603+
it('resolves the visible control and not the hidden input the label points at', async () => {
604+
await open('baseui')
605+
// the author id sits on a 1x1 aria-hidden mirror input at x:-1,y:-1 which <label for> targets;
606+
// [role=checkbox] can only be the visible span
607+
expect(await I.grabAttributeFrom('#terms', 'aria-hidden')).to.equal('true')
608+
609+
await I.checkOption('Accept terms')
610+
expect(await ariaChecked('[role=checkbox]')).to.equal('true')
611+
})
612+
613+
it('still checks a plain input by its label', async () => {
614+
await I.amOnPage('/form/checkbox')
615+
await I.checkOption('I Agree')
616+
await I.seeCheckboxIsChecked('I Agree')
617+
await I.click('Submit')
618+
assert.equal(formContents('terms'), 'agree')
619+
})
620+
621+
it('ignores a non-control sharing the accessible name', async () => {
622+
await I.amOnPage('/form/checkable/collision')
623+
await I.dontSeeCheckboxIsChecked('#terms-box')
624+
625+
await I.checkOption('Accept terms')
626+
await I.seeCheckboxIsChecked('#terms-box')
627+
})
628+
})
629+
551630
describe('#selectOption', () => {
552631
it('should select option by css', async () => {
553632
await I.amOnPage('/form/select')
@@ -2620,8 +2699,6 @@ export function tests() {
26202699
})
26212700

26222701
it('should check options by aria-label', async () => {
2623-
if (!isHelper('WebDriver')) return
2624-
26252702
await I.amOnPage('/form/role_elements')
26262703

26272704
await I.dontSeeCheckboxIsChecked('I agree to the terms and conditions')
@@ -2642,9 +2719,7 @@ export function tests() {
26422719
await I.fillField('your@email.com', 'bob@company.com')
26432720
await I.fillField('Enter your message', 'Test message')
26442721

2645-
if (isHelper('WebDriver')) {
2646-
await I.checkOption('Subscribe to newsletter')
2647-
}
2722+
await I.checkOption('Subscribe to newsletter')
26482723

26492724
await I.click('Submit')
26502725
await I.see('Form Submitted!')

0 commit comments

Comments
 (0)