Skip to content

Commit 3642e97

Browse files
DavertMikclaude
andcommitted
feat: selectOption works with role=radiogroup widgets
A radio group is a "pick one of these" control, so the natural way to write it is `I.selectOption('Density', 'Comfortable')`. Until now that failed with "Element is not a <select> element", because selectOption understood only `role=combobox` and `role=listbox` before falling back to the native `<select>` path. `role=radiogroup` is now a third rung of the fuzzy ladder in all three helpers, and a third branch of proceedSelect: the descendant `role=radio` whose accessible name matches the option is clicked. Radix Toggle Group in single mode renders the same shape, so it is covered too. The option name is matched exactly first and only then by substring — Playwright's `name` option defaults to case-insensitive substring, which would let 'Compact' be answered by a sibling named 'Compact mode'. An unknown option raises ElementNotFound rather than timing out on a click, and an array of two or more options is refused, since a radio group holds a single value and clicking each in turn would silently keep the last. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcwzSXPnfaig8nBZD2Vxfi
1 parent c780f9a commit 3642e97

9 files changed

Lines changed: 340 additions & 1 deletion

File tree

‎docs/basics.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,15 @@ I.uncheckOption('Subscribe')
239239
> Use `secret()` for sensitive data: `I.fillField('password', secret('123456'))` - [won't expose in logs](/secrets/).
240240
>
241241
242-
> [selectOption](/web-api#iselectoption) works with native `<select>` elements as well as custom components using `role="combobox"` or `role="listbox"`.
242+
> [selectOption](/web-api#iselectoption) works with native `<select>` elements as well as custom components using `role="combobox"`, `role="listbox"`, or `role="radiogroup"`.
243+
>
244+
> For a radio group the option is matched against the accessible name of a `role="radio"` item, so a group of buttons reads the same way as a `<select>`:
245+
>
246+
> ```js
247+
> I.selectOption('Density', 'Comfortable')
248+
> ```
249+
>
250+
> A radio group holds a single value, so passing an array of options raises an error.
243251
244252
### Assertions
245253

‎lib/helper/Playwright.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2404,6 +2404,10 @@ class Playwright extends Helper {
24042404
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
24052405
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
24062406

2407+
// Fuzzy: try radiogroup
2408+
els = await findByRole(comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
2409+
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2410+
24072411
// Fuzzy: try native select
24082412
els = await findFields.call(this, select, context)
24092413
assertElementExists(els, select, 'Selectable element')
@@ -4477,6 +4481,18 @@ async function proceedSelect(context, el, option) {
44774481
return this._waitForAction()
44784482
}
44794483

4484+
if (role === 'radiogroup') {
4485+
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
4486+
const [opt] = options
4487+
let optEl = el.getByRole('radio', { name: opt, exact: true }).first()
4488+
if (!(await optEl.count())) optEl = el.getByRole('radio', { name: opt }).first()
4489+
if (!(await optEl.count())) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
4490+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
4491+
await highlightActiveElement.call(this, optEl)
4492+
await optEl.click()
4493+
return this._waitForAction()
4494+
}
4495+
44804496
await highlightActiveElement.call(this, el)
44814497
let optionToSelect = option
44824498
try {

‎lib/helper/Puppeteer.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1714,6 +1714,10 @@ class Puppeteer extends Helper {
17141714
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
17151715
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
17161716

1717+
// Fuzzy: try radiogroup
1718+
els = await findByRole(comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
1719+
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
1720+
17171721
// Fuzzy: try native select
17181722
const visibleEls = await findVisibleFields.call(this, select, context)
17191723
assertElementExists(visibleEls, select, 'Selectable field')
@@ -3656,6 +3660,18 @@ async function proceedSelect(context, el, option) {
36563660
return this._waitForAction()
36573661
}
36583662

3663+
if (role === 'radiogroup') {
3664+
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
3665+
const [opt] = options
3666+
let optEls = await findByRole.call(this, el, { role: 'radio', name: opt, exact: true })
3667+
if (!optEls?.length) optEls = await findByRole.call(this, el, { role: 'radio', name: opt })
3668+
if (!optEls?.length) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
3669+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3670+
highlightActiveElement.call(this, optEls[0], context)
3671+
await optEls[0].click()
3672+
return this._waitForAction()
3673+
}
3674+
36593675
// Native <select> element
36603676
const tagName = await el.evaluate(e => e.tagName)
36613677
if (tagName !== 'SELECT') {

‎lib/helper/WebDriver.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,10 @@ class WebDriver extends Helper {
13291329
els = await this._locateByRole({ role: 'listbox', text: matchedLocator.value })
13301330
if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)
13311331

1332+
// Fuzzy: try radiogroup
1333+
els = await this._locateByRole({ role: 'radiogroup', text: matchedLocator.value })
1334+
if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)
1335+
13321336
// Fuzzy: try native select
13331337
const res = await findFields.call(this, select, context)
13341338
assertElementExists(res, select, 'Selectable field')
@@ -3562,6 +3566,23 @@ async function proceedSelectOption(elem, option) {
35623566
return
35633567
}
35643568

3569+
if (role === 'radiogroup') {
3570+
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
3571+
const [opt] = options
3572+
const radios = await this.browser.findElementsFromElement(elementId, 'xpath', `.//*[@role="radio"]`)
3573+
const names = []
3574+
for (const radio of radios) {
3575+
names.push(await getElementTextAttributes.call(this, radio))
3576+
}
3577+
let index = names.findIndex(texts => texts.some(text => text && text.trim() === opt))
3578+
if (index === -1) index = names.findIndex(texts => texts.some(text => text && text.includes(opt)))
3579+
if (index === -1) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
3580+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3581+
highlightActiveElement.call(this, radios[index])
3582+
await this.browser.elementClick(getElementId(radios[index]))
3583+
return
3584+
}
3585+
35653586
// Native <select> element
35663587
highlightActiveElement.call(this, elem)
35673588

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Radio groups</title>
6+
</head>
7+
<body>
8+
<h1>Radio groups</h1>
9+
<p>Test pages for selectOption against role=radiogroup widgets.</p>
10+
<ul>
11+
<li><a href="/form/radiogroup/plain">Plain</a> — hand-written div[role=radiogroup] with button[role=radio]</li>
12+
<li><a href="/form/radiogroup/radix">Radix</a> — RadioGroup and ToggleGroup in single mode</li>
13+
<li><a href="/form/radiogroup/baseui">Base UI</a> — RadioGroup with hidden mirror inputs</li>
14+
</ul>
15+
</body>
16+
</html>
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 Radio Group</title>
6+
<style>
7+
body { font-family: Arial, sans-serif; padding: 20px; }
8+
[role="radiogroup"] { display: flex; gap: 8px; margin-bottom: 20px; }
9+
[role="radio"] { display: inline-block; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
10+
[role="radio"][aria-checked="true"] { background: #333; color: #fff; }
11+
#result { font-family: monospace; }
12+
</style>
13+
<script type="importmap">
14+
{"imports": {
15+
"react": "https://esm.sh/react@19.2.0",
16+
"react/jsx-runtime": "https://esm.sh/react@19.2.0/jsx-runtime",
17+
"react-dom": "https://esm.sh/react-dom@19.2.0",
18+
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client"
19+
}}
20+
</script>
21+
</head>
22+
<body>
23+
<h1>Base UI Radio Group</h1>
24+
<div id="root"></div>
25+
<div id="result"></div>
26+
<script type="module">
27+
import * as React from 'react'
28+
import { createRoot } from 'react-dom/client'
29+
import { RadioGroup } from 'https://esm.sh/@base-ui-components/react@1.0.0-rc.0/radio-group?external=react,react-dom'
30+
import { Radio } from 'https://esm.sh/@base-ui-components/react@1.0.0-rc.0/radio?external=react,react-dom'
31+
32+
const h = React.createElement
33+
34+
function App() {
35+
const [density, setDensity] = React.useState('comfortable')
36+
const [theme, setTheme] = React.useState('light')
37+
38+
React.useEffect(() => {
39+
document.getElementById('result').textContent = `density: ${density}, theme: ${theme}`
40+
window.__ready = true
41+
}, [density, theme])
42+
43+
return h(React.Fragment, null,
44+
h(RadioGroup, { 'aria-label': 'Density', value: density, onValueChange: setDensity },
45+
h(Radio.Root, { value: 'compact-mode' }, 'Compact mode'),
46+
h(Radio.Root, { value: 'compact' }, 'Compact'),
47+
h(Radio.Root, { value: 'comfortable' }, 'Comfortable')),
48+
h('h3', { id: 'theme-label' }, 'Theme'),
49+
h(RadioGroup, { 'aria-labelledby': 'theme-label', value: theme, onValueChange: setTheme },
50+
h(Radio.Root, { value: 'light' }, 'Light'),
51+
h(Radio.Root, { value: 'dark' }, 'Dark'),
52+
h(Radio.Root, { value: 'system' }, 'System')))
53+
}
54+
55+
createRoot(document.getElementById('root')).render(h(App))
56+
</script>
57+
</body>
58+
</html>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Plain radio group</title>
6+
<style>
7+
body { font-family: Arial, sans-serif; padding: 20px; }
8+
[role="radiogroup"] { display: flex; gap: 8px; margin-bottom: 20px; }
9+
[role="radio"] { padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
10+
[role="radio"][aria-checked="true"] { background: #333; color: #fff; }
11+
#result { font-family: monospace; }
12+
</style>
13+
</head>
14+
<body>
15+
<h1>Plain radio group</h1>
16+
17+
<div role="radiogroup" id="density" aria-label="Density">
18+
<button type="button" role="radio" aria-checked="false">Compact mode</button>
19+
<button type="button" role="radio" aria-checked="false">Compact</button>
20+
<button type="button" role="radio" aria-checked="true">Comfortable</button>
21+
</div>
22+
23+
<h3 id="theme-label">Theme</h3>
24+
<div role="radiogroup" id="theme" aria-labelledby="theme-label">
25+
<button type="button" role="radio" aria-checked="true">Light</button>
26+
<button type="button" role="radio" aria-checked="false">Dark</button>
27+
<button type="button" role="radio" aria-checked="false">System</button>
28+
</div>
29+
30+
<select name="framework" id="framework" aria-label="Framework">
31+
<option value="">Choose</option>
32+
<option value="next">Next.js</option>
33+
<option value="remix">Remix</option>
34+
</select>
35+
36+
<div id="result">density: Comfortable, theme: Light, framework: </div>
37+
38+
<script>
39+
function report() {
40+
const value = id => {
41+
const group = document.getElementById(id)
42+
const checked = group.querySelector('[role="radio"][aria-checked="true"]')
43+
return checked ? checked.textContent.trim() : ''
44+
}
45+
document.getElementById('result').textContent =
46+
`density: ${value('density')}, theme: ${value('theme')}, framework: ${document.getElementById('framework').value}`
47+
}
48+
49+
document.querySelectorAll('[role="radiogroup"]').forEach(group => {
50+
group.addEventListener('click', event => {
51+
const radio = event.target.closest('[role="radio"]')
52+
if (!radio || !group.contains(radio)) return
53+
group.querySelectorAll('[role="radio"]').forEach(el => el.setAttribute('aria-checked', String(el === radio)))
54+
report()
55+
})
56+
})
57+
document.getElementById('framework').addEventListener('change', report)
58+
window.__ready = true
59+
</script>
60+
</body>
61+
</html>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Radix Radio Group</title>
6+
<style>
7+
body { font-family: Arial, sans-serif; padding: 20px; }
8+
[role="radiogroup"] { display: flex; gap: 8px; margin-bottom: 20px; }
9+
[role="radio"] { padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
10+
[role="radio"][aria-checked="true"] { background: #333; color: #fff; }
11+
#result { font-family: monospace; }
12+
</style>
13+
<script type="importmap">
14+
{"imports": {
15+
"react": "https://esm.sh/react@19.2.0",
16+
"react/jsx-runtime": "https://esm.sh/react@19.2.0/jsx-runtime",
17+
"react-dom": "https://esm.sh/react-dom@19.2.0",
18+
"react-dom/client": "https://esm.sh/react-dom@19.2.0/client"
19+
}}
20+
</script>
21+
</head>
22+
<body>
23+
<h1>Radix Radio Group</h1>
24+
<div id="root"></div>
25+
<div id="result"></div>
26+
<script type="module">
27+
import * as React from 'react'
28+
import { createRoot } from 'react-dom/client'
29+
import { RadioGroup, ToggleGroup } from 'https://esm.sh/radix-ui@1.6.7?external=react,react-dom'
30+
31+
const h = React.createElement
32+
33+
function App() {
34+
const [density, setDensity] = React.useState('comfortable')
35+
const [align, setAlign] = React.useState('left')
36+
37+
React.useEffect(() => {
38+
document.getElementById('result').textContent = `density: ${density}, align: ${align}`
39+
window.__ready = true
40+
}, [density, align])
41+
42+
return h(React.Fragment, null,
43+
h(RadioGroup.Root, { 'aria-label': 'Density', value: density, onValueChange: setDensity },
44+
h(RadioGroup.Item, { value: 'compact-mode' }, 'Compact mode'),
45+
h(RadioGroup.Item, { value: 'compact' }, 'Compact'),
46+
h(RadioGroup.Item, { value: 'comfortable' }, 'Comfortable')),
47+
h('h3', { id: 'align-label' }, 'Text alignment'),
48+
h(ToggleGroup.Root, { type: 'single', 'aria-labelledby': 'align-label', value: align, onValueChange: value => value && setAlign(value) },
49+
h(ToggleGroup.Item, { value: 'left' }, 'Left'),
50+
h(ToggleGroup.Item, { value: 'center' }, 'Center'),
51+
h(ToggleGroup.Item, { value: 'right' }, 'Right')))
52+
}
53+
54+
createRoot(document.getElementById('root')).render(h(App))
55+
</script>
56+
</body>
57+
</html>

0 commit comments

Comments
 (0)