diff --git a/docs/src/selectors.md b/docs/src/selectors.md index f0dba4fdd9559..ffd16b77ce24a 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -765,9 +765,10 @@ Attributes supported by the role selector: Note that unlike most other attributes, `disabled` is inherited through the DOM hierarchy. Learn more about [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled). -* `expanded` - a boolean attribute that is usually set by `aria-expanded`. Examples: +* `expanded` - an attribute that is usually set by `aria-expanded`. Available values for expanded are `true`, `false` and `"none"`. Examples: - `role=button[expanded=true]`, equivalent to `role=button[expanded]` - `role=button[expanded=false]` + - `role=button[expanded="none"]`, meaning that `aria-expanded` is not present Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index 3e4d1ac5e9c51..170dc5cc2f131 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -70,8 +70,13 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string) { } case 'expanded': { validateSupportedRole(attr.name, kAriaExpandedRoles, role); - validateSupportedValues(attr, [true, false]); + validateSupportedValues(attr, [true, false, 'none']); validateSupportedOp(attr, ['', '=']); + if (attr.op === '') { + // Do not match "none" in "treeitem[expanded]". + attr.op = '='; + attr.value = true; + } break; } case 'level': { diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 7c4ea0a2450b9..9d9c1be95c73e 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -670,14 +670,20 @@ export function getAriaPressed(element: Element): boolean | 'mixed' { } export const kAriaExpandedRoles = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem', 'columnheader', 'menuitemcheckbox', 'menuitemradio', 'rowheader', 'switch']; -export function getAriaExpanded(element: Element): boolean { +export function getAriaExpanded(element: Element): boolean | 'none' { // https://www.w3.org/TR/wai-aria-1.2/#aria-expanded // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings if (element.tagName === 'DETAILS') return (element as HTMLDetailsElement).open; - if (kAriaExpandedRoles.includes(getAriaRole(element) || '')) - return getAriaBoolean(element.getAttribute('aria-expanded')) === true; - return false; + if (kAriaExpandedRoles.includes(getAriaRole(element) || '')) { + const expanded = element.getAttribute('aria-expanded'); + if (expanded === null) + return 'none'; + if (expanded === 'true') + return true; + return false; + } + return 'none'; } export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem']; diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index 0ea07f17c56d3..7813102b53a03 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -169,26 +169,41 @@ test('should support pressed', async ({ page }) => { test('should support expanded', async ({ page }) => { await page.setContent(` - - - +
Hi
+
Hello
+ `); - expect(await page.locator(`role=button[expanded]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - ``, + + expect(await page.locator('role=treeitem').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hi
`, + `
Hello
`, + ``, ]); - expect(await page.locator(`role=button[expanded=true]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - ``, + expect(await page.getByRole('treeitem').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hi
`, + `
Hello
`, + ``, ]); - expect(await page.getByRole('button', { expanded: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - ``, + + expect(await page.locator(`role=treeitem[expanded]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hello
`, ]); - expect(await page.locator(`role=button[expanded=false]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - ``, - ``, + expect(await page.locator(`role=treeitem[expanded=true]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hello
`, ]); - expect(await page.getByRole('button', { expanded: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - ``, - ``, + expect(await page.getByRole('treeitem', { expanded: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hello
`, + ]); + + expect(await page.locator(`role=treeitem[expanded=false]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.getByRole('treeitem', { expanded: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + + expect(await page.locator(`role=treeitem[expanded="none"]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hi
`, ]); }); @@ -403,4 +418,7 @@ test('errors', async ({ page }) => { const e7 = await page.$('role=button[name]').catch(e => e); expect(e7.message).toContain(`"name" attribute must have a value`); + + const e8 = await page.$('role=treeitem[expanded="bar"]').catch(e => e); + expect(e8.message).toContain(`"expanded" must be one of true, false, "none"`); });