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
10 changes: 9 additions & 1 deletion docs/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,15 @@ I.uncheckOption('Subscribe')
> Use `secret()` for sensitive data: `I.fillField('password', secret('123456'))` - [won't expose in logs](/secrets/).
>

> [selectOption](/web-api#iselectoption) works with native `<select>` elements as well as custom components using `role="combobox"` or `role="listbox"`.
> [selectOption](/web-api#iselectoption) works with native `<select>` elements as well as custom components using `role="combobox"`, `role="listbox"`, or `role="radiogroup"`.
>
> 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>`:
>
> ```js
> I.selectOption('Density', 'Comfortable')
> ```
>
> A radio group holds a single value, so passing an array of options raises an error.

### Assertions

Expand Down
16 changes: 16 additions & 0 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -2404,6 +2404,10 @@ class Playwright extends Helper {
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)

// Fuzzy: try radiogroup
els = await findByRole(comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)

// Fuzzy: try native select
els = await findFields.call(this, select, context)
assertElementExists(els, select, 'Selectable element')
Expand Down Expand Up @@ -4477,6 +4481,18 @@ async function proceedSelect(context, el, option) {
return this._waitForAction()
}

if (role === 'radiogroup') {
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
const [opt] = options
let optEl = el.getByRole('radio', { name: opt, exact: true }).first()
if (!(await optEl.count())) optEl = el.getByRole('radio', { name: opt }).first()
if (!(await optEl.count())) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
this.debugSection('SelectOption', `Clicking: "${opt}"`)
await highlightActiveElement.call(this, optEl)
await optEl.click()
return this._waitForAction()
}

await highlightActiveElement.call(this, el)
let optionToSelect = option
try {
Expand Down
16 changes: 16 additions & 0 deletions lib/helper/Puppeteer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1714,6 +1714,10 @@ class Puppeteer extends Helper {
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)

// Fuzzy: try radiogroup
els = await findByRole(comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)

// Fuzzy: try native select
const visibleEls = await findVisibleFields.call(this, select, context)
assertElementExists(visibleEls, select, 'Selectable field')
Expand Down Expand Up @@ -3656,6 +3660,18 @@ async function proceedSelect(context, el, option) {
return this._waitForAction()
}

if (role === 'radiogroup') {
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
const [opt] = options
let optEls = await findByRole.call(this, el, { role: 'radio', name: opt, exact: true })
if (!optEls?.length) optEls = await findByRole.call(this, el, { role: 'radio', name: opt })
if (!optEls?.length) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
this.debugSection('SelectOption', `Clicking: "${opt}"`)
highlightActiveElement.call(this, optEls[0], context)
await optEls[0].click()
return this._waitForAction()
}

// Native <select> element
const tagName = await el.evaluate(e => e.tagName)
if (tagName !== 'SELECT') {
Expand Down
21 changes: 21 additions & 0 deletions lib/helper/WebDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,10 @@ class WebDriver extends Helper {
els = await this._locateByRole({ role: 'listbox', text: matchedLocator.value })
if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)

// Fuzzy: try radiogroup
els = await this._locateByRole({ role: 'radiogroup', text: matchedLocator.value })
if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)

// Fuzzy: try native select
const res = await findFields.call(this, select, context)
assertElementExists(res, select, 'Selectable field')
Expand Down Expand Up @@ -3562,6 +3566,23 @@ async function proceedSelectOption(elem, option) {
return
}

if (role === 'radiogroup') {
if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
const [opt] = options
const radios = await this.browser.findElementsFromElement(elementId, 'xpath', `.//*[@role="radio"]`)
const names = []
for (const radio of radios) {
names.push(await getElementTextAttributes.call(this, radio))
}
let index = names.findIndex(texts => texts.some(text => text && text.trim() === opt))
if (index === -1) index = names.findIndex(texts => texts.some(text => text && text.includes(opt)))
if (index === -1) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
this.debugSection('SelectOption', `Clicking: "${opt}"`)
highlightActiveElement.call(this, radios[index])
await this.browser.elementClick(getElementId(radios[index]))
return
}

// Native <select> element
highlightActiveElement.call(this, elem)

Expand Down
16 changes: 16 additions & 0 deletions test/data/app/view/form/radiogroup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Radio groups</title>
</head>
<body>
<h1>Radio groups</h1>
<p>Test pages for selectOption against role=radiogroup widgets.</p>
<ul>
<li><a href="/form/radiogroup/plain">Plain</a> — hand-written div[role=radiogroup] with button[role=radio]</li>
<li><a href="/form/radiogroup/radix">Radix</a> — RadioGroup and ToggleGroup in single mode</li>
<li><a href="/form/radiogroup/baseui">Base UI</a> — RadioGroup with hidden mirror inputs</li>
</ul>
</body>
</html>
58 changes: 58 additions & 0 deletions test/data/app/view/form/radiogroup/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 Radio Group</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
[role="radiogroup"] { display: flex; gap: 8px; margin-bottom: 20px; }
[role="radio"] { display: inline-block; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
[role="radio"][aria-checked="true"] { background: #333; color: #fff; }
#result { font-family: monospace; }
</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 Radio Group</h1>
<div id="root"></div>
<div id="result"></div>
<script type="module">
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { RadioGroup } from 'https://esm.sh/@base-ui-components/react@1.0.0-rc.0/radio-group?external=react,react-dom'
import { Radio } from 'https://esm.sh/@base-ui-components/react@1.0.0-rc.0/radio?external=react,react-dom'

const h = React.createElement

function App() {
const [density, setDensity] = React.useState('comfortable')
const [theme, setTheme] = React.useState('light')

React.useEffect(() => {
document.getElementById('result').textContent = `density: ${density}, theme: ${theme}`
window.__ready = true
}, [density, theme])

return h(React.Fragment, null,
h(RadioGroup, { 'aria-label': 'Density', value: density, onValueChange: setDensity },
h(Radio.Root, { value: 'compact-mode' }, 'Compact mode'),
h(Radio.Root, { value: 'compact' }, 'Compact'),
h(Radio.Root, { value: 'comfortable' }, 'Comfortable')),
h('h3', { id: 'theme-label' }, 'Theme'),
h(RadioGroup, { 'aria-labelledby': 'theme-label', value: theme, onValueChange: setTheme },
h(Radio.Root, { value: 'light' }, 'Light'),
h(Radio.Root, { value: 'dark' }, 'Dark'),
h(Radio.Root, { value: 'system' }, 'System')))
}

createRoot(document.getElementById('root')).render(h(App))
</script>
</body>
</html>
61 changes: 61 additions & 0 deletions test/data/app/view/form/radiogroup/plain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plain radio group</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
[role="radiogroup"] { display: flex; gap: 8px; margin-bottom: 20px; }
[role="radio"] { padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
[role="radio"][aria-checked="true"] { background: #333; color: #fff; }
#result { font-family: monospace; }
</style>
</head>
<body>
<h1>Plain radio group</h1>

<div role="radiogroup" id="density" aria-label="Density">
<button type="button" role="radio" aria-checked="false">Compact mode</button>
<button type="button" role="radio" aria-checked="false">Compact</button>
<button type="button" role="radio" aria-checked="true">Comfortable</button>
</div>

<h3 id="theme-label">Theme</h3>
<div role="radiogroup" id="theme" aria-labelledby="theme-label">
<button type="button" role="radio" aria-checked="true">Light</button>
<button type="button" role="radio" aria-checked="false">Dark</button>
<button type="button" role="radio" aria-checked="false">System</button>
</div>

<select name="framework" id="framework" aria-label="Framework">
<option value="">Choose</option>
<option value="next">Next.js</option>
<option value="remix">Remix</option>
</select>

<div id="result">density: Comfortable, theme: Light, framework: </div>

<script>
function report() {
const value = id => {
const group = document.getElementById(id)
const checked = group.querySelector('[role="radio"][aria-checked="true"]')
return checked ? checked.textContent.trim() : ''
}
document.getElementById('result').textContent =
`density: ${value('density')}, theme: ${value('theme')}, framework: ${document.getElementById('framework').value}`
}

document.querySelectorAll('[role="radiogroup"]').forEach(group => {
group.addEventListener('click', event => {
const radio = event.target.closest('[role="radio"]')
if (!radio || !group.contains(radio)) return
group.querySelectorAll('[role="radio"]').forEach(el => el.setAttribute('aria-checked', String(el === radio)))
report()
})
})
document.getElementById('framework').addEventListener('change', report)
window.__ready = true
</script>
</body>
</html>
57 changes: 57 additions & 0 deletions test/data/app/view/form/radiogroup/radix.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Radix Radio Group</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
[role="radiogroup"] { display: flex; gap: 8px; margin-bottom: 20px; }
[role="radio"] { padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
[role="radio"][aria-checked="true"] { background: #333; color: #fff; }
#result { font-family: monospace; }
</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 Radio Group</h1>
<div id="root"></div>
<div id="result"></div>
<script type="module">
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { RadioGroup, ToggleGroup } from 'https://esm.sh/radix-ui@1.6.7?external=react,react-dom'

const h = React.createElement

function App() {
const [density, setDensity] = React.useState('comfortable')
const [align, setAlign] = React.useState('left')

React.useEffect(() => {
document.getElementById('result').textContent = `density: ${density}, align: ${align}`
window.__ready = true
}, [density, align])

return h(React.Fragment, null,
h(RadioGroup.Root, { 'aria-label': 'Density', value: density, onValueChange: setDensity },
h(RadioGroup.Item, { value: 'compact-mode' }, 'Compact mode'),
h(RadioGroup.Item, { value: 'compact' }, 'Compact'),
h(RadioGroup.Item, { value: 'comfortable' }, 'Comfortable')),
h('h3', { id: 'align-label' }, 'Text alignment'),
h(ToggleGroup.Root, { type: 'single', 'aria-labelledby': 'align-label', value: align, onValueChange: value => value && setAlign(value) },
h(ToggleGroup.Item, { value: 'left' }, 'Left'),
h(ToggleGroup.Item, { value: 'center' }, 'Center'),
h(ToggleGroup.Item, { value: 'right' }, 'Right')))
}

createRoot(document.getElementById('root')).render(h(App))
</script>
</body>
</html>
Loading