Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ let defaultSelectorEnginesInitialized = false
const popupStore = new Popup()
const consoleLogStore = new Console()
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
const checkableRoles = ['checkbox', 'radio', 'switch']

import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
Expand Down Expand Up @@ -4385,6 +4386,17 @@ async function findCheckable(locator, context) {
return findElements.call(this, contextEl, matchedLocator)
}

for (const exact of [true, false]) {
for (const role of checkableRoles) {
try {
const roleEls = await contextEl.getByRole(role, { name: matchedLocator.value, exact }).all()
if (roleEls.length) return roleEls
} catch (err) {
// getByRole not supported or failed
}
}
}

const literal = xpathLocator.literal(matchedLocator.value)
let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
if (els.length) {
Expand Down
22 changes: 13 additions & 9 deletions lib/helper/Puppeteer.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function wrapError(e) {
let perfTiming
const popupStore = new Popup()
const consoleLogStore = new Console()
const checkableRoles = ['checkbox', 'radio', 'switch']

/**
* ## Configuration
Expand Down Expand Up @@ -3192,8 +3193,19 @@ async function findCheckable(locator, context) {
return findElements.call(this, contextEl, matchedLocator)
}

// Try ARIA selector for accessible name
let els
for (const role of checkableRoles) {
try {
els = await contextEl.$$(`::-p-aria([name="${matchedLocator.value}"][role="${role}"])`)
if (els.length) return els
} catch (err) {
// ARIA selector not supported or failed
}
}

const literal = xpathLocator.literal(matchedLocator.value)
let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
if (els.length) {
return els
}
Expand All @@ -3202,14 +3214,6 @@ async function findCheckable(locator, context) {
return els
}

// Try ARIA selector for accessible name
try {
els = await contextEl.$$(`::-p-aria(${matchedLocator.value})`)
if (els.length) return els
} catch (err) {
// ARIA selector not supported or failed
}

return findElements.call(this, contextEl, matchedLocator.value)
}

Expand Down
25 changes: 20 additions & 5 deletions lib/helper/WebDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -3245,24 +3245,39 @@ async function findCheckable(locator, locateFn) {
if (locator.isRole()) return locateFn(locator, true)
if (!locator.isFuzzy()) return locateFn(locator, true)

const literal = xpathLocator.literal(locator.value)
els = await locateFn(Locator.checkable.byText(literal))
if (els.length) return els

// Try ARIA selector for accessible name
try {
els = await locateFn(`aria/${locator.value}`)
els = await keepCheckable.call(this, await locateFn(`aria/${locator.value}`))
if (els.length) return els
} catch (e) {
// ARIA selector not supported or failed
}

const literal = xpathLocator.literal(locator.value)
els = await locateFn(Locator.checkable.byText(literal))
if (els.length) return els

els = await locateFn(Locator.checkable.byName(literal))
if (els.length) return els

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

async function keepCheckable(els) {
if (!els || !els.length) return []

const checkable = await this.browser.execute(function () {
return Array.prototype.slice.call(arguments).map(function (el) {
if (!el) return false
const role = el.getAttribute('role')
if (role) return ['checkbox', 'radio', 'switch'].indexOf(role) > -1
return el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')
})
}, ...els)

return els.filter((el, index) => checkable[index])
}

function withStrictLocator(locator) {
locator = new Locator(locator)
return locator.simplify()
Expand Down
58 changes: 58 additions & 0 deletions test/data/app/view/form/checkable/baseui.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Base UI Checkables</title>
<style>
body { font-family: Arial, sans-serif; }
.row { margin: 12px 0; display: flex; align-items: center; gap: 8px; }
label { font-weight: bold; }
span[role="checkbox"], span[role="radio"] { display: inline-block; width: 20px; height: 20px; border: 1px solid #666; background: #fff; border-radius: 3px; }
span[role="checkbox"][data-checked], span[role="radio"][data-checked] { background: #2a6; }
span[role="switch"] { display: inline-block; width: 42px; height: 22px; border: 1px solid #666; background: #ddd; border-radius: 11px; }
span[role="switch"][data-checked] { background: #2a6; }
</style>
<script type="importmap">
{"imports": {
"react": "https://esm.sh/react@19.2.0",
"react/jsx-runtime": "https://esm.sh/react@19.2.0/jsx-runtime",
"react-dom": "https://esm.sh/react-dom@19.2.0",
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client"
}}
</script>
</head>
<body>
<h1>Base UI Checkables</h1>
<div id="root"></div>
<script type="module">
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { Checkbox } from 'https://esm.sh/@base-ui/react@1.8.0/checkbox?external=react,react-dom'
import { Switch } from 'https://esm.sh/@base-ui/react@1.8.0/switch?external=react,react-dom'
import { Radio } from 'https://esm.sh/@base-ui/react@1.8.0/radio?external=react,react-dom'
import { RadioGroup } from 'https://esm.sh/@base-ui/react@1.8.0/radio-group?external=react,react-dom'

const h = React.createElement

function App() {
React.useEffect(() => { window.__ready = true }, [])
return h('div', null,
h('div', { className: 'row' },
h(Checkbox.Root, { id: 'terms', className: 'ctl-terms' }, h(Checkbox.Indicator, null)),
h('label', { htmlFor: 'terms' }, 'Accept terms')),
h('div', { className: 'row' },
h(Switch.Root, { id: 'airplane', className: 'ctl-airplane' }, h(Switch.Thumb, null)),
h('label', { htmlFor: 'airplane' }, 'Airplane mode')),
h(RadioGroup, { defaultValue: 'default', 'aria-label': 'Density' },
h('div', { className: 'row' },
h(Radio.Root, { value: 'default', id: 'r-default', className: 'ctl-default' }, h(Radio.Indicator, null)),
h('label', { htmlFor: 'r-default' }, 'Default')),
h('div', { className: 'row' },
h(Radio.Root, { value: 'comfortable', id: 'r-comfortable', className: 'ctl-comfortable' }, h(Radio.Indicator, null)),
h('label', { htmlFor: 'r-comfortable' }, 'Comfortable'))))
}

createRoot(document.getElementById('root')).render(h(App))
</script>
</body>
</html>
15 changes: 15 additions & 0 deletions test/data/app/view/form/checkable/collision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Checkable name collision</title>
</head>
<body>
<h2>Accept terms</h2>
<form action="/form/complex" method="POST">
<label for="terms-box">Accept terms</label>
<input type="checkbox" id="terms-box" name="terms" value="agree" />
<input type="submit" value="Submit" />
</form>
</body>
</html>
55 changes: 55 additions & 0 deletions test/data/app/view/form/checkable/radix.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Radix Checkables</title>
<style>
body { font-family: Arial, sans-serif; }
.row { margin: 12px 0; display: flex; align-items: center; gap: 8px; }
label { font-weight: bold; }
button[role="checkbox"], button[role="radio"] { width: 20px; height: 20px; border: 1px solid #666; background: #fff; border-radius: 3px; }
button[role="checkbox"][data-state="checked"], button[role="radio"][data-state="checked"] { background: #2a6; }
button[role="switch"] { width: 42px; height: 22px; border: 1px solid #666; background: #ddd; border-radius: 11px; }
button[role="switch"][data-state="checked"] { background: #2a6; }
</style>
<script type="importmap">
{"imports": {
"react": "https://esm.sh/react@19.2.0",
"react/jsx-runtime": "https://esm.sh/react@19.2.0/jsx-runtime",
"react-dom": "https://esm.sh/react-dom@19.2.0",
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client"
}}
</script>
</head>
<body>
<h1>Radix Checkables</h1>
<div id="root"></div>
<script type="module">
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { Checkbox, Switch, RadioGroup } from 'https://esm.sh/radix-ui@1.6.7?external=react,react-dom'

const h = React.createElement

function App() {
React.useEffect(() => { window.__ready = true }, [])
return h('div', null,
h('div', { className: 'row' },
h(Checkbox.Root, { id: 'terms', className: 'ctl-terms' }, h(Checkbox.Indicator, null)),
h('label', { htmlFor: 'terms' }, 'Accept terms')),
h('div', { className: 'row' },
h(Switch.Root, { id: 'airplane', className: 'ctl-airplane' }, h(Switch.Thumb, null)),
h('label', { htmlFor: 'airplane' }, 'Airplane mode')),
h(RadioGroup.Root, { defaultValue: 'default', 'aria-label': 'Density' },
h('div', { className: 'row' },
h(RadioGroup.Item, { value: 'default', id: 'r-default', className: 'ctl-default' }, h(RadioGroup.Indicator, null)),
h('label', { htmlFor: 'r-default' }, 'Default')),
h('div', { className: 'row' },
h(RadioGroup.Item, { value: 'comfortable', id: 'r-comfortable', className: 'ctl-comfortable' }, h(RadioGroup.Indicator, null)),
h('label', { htmlFor: 'r-comfortable' }, 'Comfortable'))))
}

createRoot(document.getElementById('root')).render(h(App))
</script>
</body>
</html>
85 changes: 80 additions & 5 deletions test/helper/webapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,85 @@ export function tests() {
})
})

describe('#checkOption - ARIA roles', function () {
this.timeout(60000)

async function open(page) {
await I.amOnPage(`/form/checkable/${page}`)
await I.waitForFunction(() => window.__ready === true, [], 30)
}

async function ariaChecked(css) {
return I.grabAttributeFrom(css, 'aria-checked')
}

for (const page of ['radix', 'baseui']) {
describe(page, () => {
beforeEach(function () {
// webdriverio resolves `<label for>` to input/textarea only, and a Radix
// `<button role=checkbox>` carries no accessible name of its own
if (page === 'radix' && isHelper('WebDriver')) this.skip()
})

it('checks and unchecks a checkbox by its label', async () => {
await open(page)
await I.dontSeeCheckboxIsChecked('Accept terms')

await I.checkOption('Accept terms')
expect(await ariaChecked('.ctl-terms')).to.equal('true')
await I.seeCheckboxIsChecked('Accept terms')

await I.uncheckOption('Accept terms')
expect(await ariaChecked('.ctl-terms')).to.equal('false')
await I.dontSeeCheckboxIsChecked('Accept terms')
})

it('checks a switch by its label', async () => {
await open(page)
await I.checkOption('Airplane mode')
expect(await ariaChecked('.ctl-airplane')).to.equal('true')
await I.seeCheckboxIsChecked('Airplane mode')
})

it('checks a radio by its label', async () => {
await open(page)
await I.dontSeeCheckboxIsChecked('Comfortable')

await I.checkOption('Comfortable')
expect(await ariaChecked('.ctl-comfortable')).to.equal('true')
expect(await ariaChecked('.ctl-default')).to.equal('false')
await I.seeCheckboxIsChecked('Comfortable')
})
})
}

it('resolves the visible control and not the hidden input the label points at', async () => {
await open('baseui')
// the author id sits on a 1x1 aria-hidden mirror input at x:-1,y:-1 which <label for> targets;
// [role=checkbox] can only be the visible span
expect(await I.grabAttributeFrom('#terms', 'aria-hidden')).to.equal('true')

await I.checkOption('Accept terms')
expect(await ariaChecked('[role=checkbox]')).to.equal('true')
})

it('still checks a plain input by its label', async () => {
await I.amOnPage('/form/checkbox')
await I.checkOption('I Agree')
await I.seeCheckboxIsChecked('I Agree')
await I.click('Submit')
assert.equal(formContents('terms'), 'agree')
})

it('ignores a non-control sharing the accessible name', async () => {
await I.amOnPage('/form/checkable/collision')
await I.dontSeeCheckboxIsChecked('#terms-box')

await I.checkOption('Accept terms')
await I.seeCheckboxIsChecked('#terms-box')
})
})

describe('#selectOption', () => {
it('should select option by css', async () => {
await I.amOnPage('/form/select')
Expand Down Expand Up @@ -2341,8 +2420,6 @@ export function tests() {
})

it('should check options by aria-label', async () => {
if (!isHelper('WebDriver')) return

await I.amOnPage('/form/role_elements')

await I.dontSeeCheckboxIsChecked('I agree to the terms and conditions')
Expand All @@ -2363,9 +2440,7 @@ export function tests() {
await I.fillField('your@email.com', 'bob@company.com')
await I.fillField('Enter your message', 'Test message')

if (isHelper('WebDriver')) {
await I.checkOption('Subscribe to newsletter')
}
await I.checkOption('Subscribe to newsletter')

await I.click('Submit')
await I.see('Form Submitted!')
Expand Down