Skip to content

Commit 6a98cae

Browse files
DavertMikDavertMikclaude
authored
feat: selectOption works with role=radiogroup widgets (#5702)
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. 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 b7b6f5d commit 6a98cae

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')
@@ -4476,6 +4480,18 @@ async function proceedSelect(context, el, option) {
44764480
return this._waitForAction()
44774481
}
44784482

4483+
if (role === 'radiogroup') {
4484+
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
4485+
const [opt] = options
4486+
let optEl = el.getByRole('radio', { name: opt, exact: true }).first()
4487+
if (!(await optEl.count())) optEl = el.getByRole('radio', { name: opt }).first()
4488+
if (!(await optEl.count())) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
4489+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
4490+
await highlightActiveElement.call(this, optEl)
4491+
await optEl.click()
4492+
return this._waitForAction()
4493+
}
4494+
44794495
await highlightActiveElement.call(this, el)
44804496
let optionToSelect = option
44814497
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')
@@ -3663,6 +3667,18 @@ async function proceedSelect(context, el, option) {
36633667
return this._waitForAction()
36643668
}
36653669

3670+
if (role === 'radiogroup') {
3671+
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
3672+
const [opt] = options
3673+
let optEls = await findByRole.call(this, el, { role: 'radio', name: opt, exact: true })
3674+
if (!optEls?.length) optEls = await findByRole.call(this, el, { role: 'radio', name: opt })
3675+
if (!optEls?.length) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
3676+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
3677+
highlightActiveElement.call(this, optEls[0], context)
3678+
await optEls[0].click()
3679+
return this._waitForAction()
3680+
}
3681+
36663682
// Native <select> element
36673683
const tagName = await el.evaluate(e => e.tagName)
36683684
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')
@@ -3561,6 +3565,23 @@ async function proceedSelectOption(elem, option) {
35613565
return
35623566
}
35633567

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

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)