From e5182259b18300d4862c134eea1534bc76d2c0e6 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 31 Mar 2022 13:06:39 -0700 Subject: [PATCH] feat(role selector): docs and minor fixes (#13203) - Added docs to `selectors.md`. - `[pressed]` and `[checked]` do not match `"mixed"` states. - Disallow `[name]` shorthand without a value. - Renamed `includeHidden` to `include-hidden`. --- docs/src/selectors.md | 70 +++++++++++++++++++ .../src/server/injected/roleSelectorEngine.ts | 20 ++++-- tests/page/selectors-role.spec.ts | 24 +++++-- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/docs/src/selectors.md b/docs/src/selectors.md index bb7de8d012..db9f25a83a 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -814,6 +814,76 @@ Vue selectors, as well as [Vue DevTools](https://chrome.google.com/webstore/deta ::: +## Role selector + +:::note +Role selector is experimental, only available when running with `PLAYWRIGHT_EXPERIMENTAL_FEATURES=1` enviroment variable. +::: + +Role selector allows selecting elements by their [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles), [ARIA attributes](https://www.w3.org/TR/wai-aria-1.2/#aria-attributes) and [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). Note that role selector **does not replace** accessibility audits and conformance tests, but rather gives early feedback about the ARIA guidelines. + +The syntax is very similar to [CSS attribute selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors). For example, `role=button[name="Click me"][pressed]` selects a pressed button that has accessible name "Click me". + +Note that many html elements have an implicitly [defined role](https://w3c.github.io/html-aam/#html-element-role-mappings) that is recognized by the role selector. You can find all the [supported roles here](https://www.w3.org/TR/wai-aria-1.2/#role_definitions). ARIA guidelines **do not recommend** duplicating implicit roles and attributes by setting `role` and/or `aria-*` attributes to default values. + +Attributes supported by the role selector: +* `checked` - an attribute that is usually set by `aria-checked` or native `` controls. Available values for checked are `true`, `false` and `"mixed"`. Examples: + - `role=checkbox[checked=true]`, equivalent to `role=checkbox[checked]` + - `role=checkbox[checked=false]` + - `role=checkbox[checked="mixed"]` + + Learn more about [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked). + +* `disabled` - a boolean attribute that is usually set by `aria-disabled` or `disabled`. Examples: + - `role=button[disabled=true]`, equivalent to `role=button[disabled]` + - `role=button[disabled=false]` + + 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: + - `role=button[expanded=true]`, equivalent to `role=button[expanded]` + - `role=button[expanded=false]` + + Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). + +* `include-hidden` - a boolean attribute that controls whether hidden elements are matched. By default, only non-hidden elements, as [defined by ARIA](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), are matched by role selector. With `[include-hidden]`, both hidden and non-hidden elements are matched. Examples: + - `role=button[include-hidden=true]`, equivalent to `role=button[include-hidden]` + - `role=button[include-hidden=false]` + + Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden). + +* `level` - a number attribute that is usually present for roles `heading`, `listitem`, `row`, `treeitem`, with default values for `

-

` elements. Examples: + - `role=heading[level=1]` + + Learn more about [`aria-level`](https://www.w3.org/TR/wai-aria-1.2/#aria-level). + +* `name` - a string attribute that matches [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). Supports attribute operators like `=` and `*=`, and regular expressions. + - `role=button[name="Click me"]` + - `role=button[name*="Click"]` + - `role=button[name=/Click( me)?/]` + + Learn more about [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). + +* `pressed` - an attribute that is usually set by `aria-pressed`. Available values for pressed are `true`, `false` and `"mixed"`. Examples: + - `role=button[pressed=true]`, equivalent to `role=button[pressed]` + - `role=button[pressed=false]` + - `role=button[pressed="mixed"]` + + Learn more about [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed). + +* `selected` - a boolean attribute that is usually set by `aria-selected`. Examples: + - `role=option[selected=true]`, equivalent to `role=option[selected]` + - `role=option[selected=false]` + + Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). + +Examples: +* `role=button` matches all buttons; +* `role=button[name="Click me"]` matches buttons with "Click me" accessible name; +* `role=checkbox[checked][include-hidden]` matches checkboxes that are checked, including those that are currently hidden. + + ## id, data-testid, data-test-id, data-test selectors Playwright supports shorthand for selecting elements using certain attributes. Currently, only diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index 0d3a51712f..4ae908d897 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -18,7 +18,7 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine'; import { matchesAttribute, parseComponentSelector, ParsedComponentAttribute, ParsedAttributeOperator } from './componentUtils'; import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; -const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'includeHidden']; +const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden']; kSupportedAttributes.sort(); function validateSupportedRole(attr: string, roles: string[], role: string) { @@ -43,12 +43,22 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) { validateSupportedRole(attr.name, kAriaCheckedRoles, role); validateSupportedValues(attr, [true, false, 'mixed']); validateSupportedOp(attr, ['', '=']); + if (attr.op === '') { + // Do not match "mixed" in "option[checked]". + attr.op = '='; + attr.value = true; + } break; } case 'pressed': { validateSupportedRole(attr.name, kAriaPressedRoles, role); validateSupportedValues(attr, [true, false, 'mixed']); validateSupportedOp(attr, ['', '=']); + if (attr.op === '') { + // Do not match "mixed" in "button[pressed]". + attr.op = '='; + attr.value = true; + } break; } case 'selected': { @@ -75,11 +85,13 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) { break; } case 'name': { - if (attr.op !== '' && typeof attr.value !== 'string' && !(attr.value instanceof RegExp)) + if (attr.op === '') + throw new Error(`"name" attribute must have a value`); + if (typeof attr.value !== 'string' && !(attr.value instanceof RegExp)) throw new Error(`"name" attribute must be a string or a regular expression`); break; } - case 'includeHidden': { + case 'include-hidden': { validateSupportedValues(attr, [true, false]); validateSupportedOp(attr, ['', '=']); break; @@ -107,7 +119,7 @@ export const RoleEngine: SelectorEngine = { let includeHidden = false; // By default, hidden elements are excluded. let nameAttr: ParsedComponentAttribute | undefined; for (const attr of parsed.attributes) { - if (attr.name === 'includeHidden') { + if (attr.name === 'include-hidden') { includeHidden = attr.op === '' || !!attr.value; continue; } diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index 4dcaf3dbd2..9a41ece1b6 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -84,7 +84,6 @@ test('should support checked', async ({ page }) => { await page.$eval('[indeterminate]', input => (input as HTMLInputElement).indeterminate = true); expect(await page.$$eval(`role=checkbox[checked]`, els => els.map(e => e.outerHTML))).toEqual([ ``, - ``, `
Hi
`, ]); expect(await page.$$eval(`role=checkbox[checked=true]`, els => els.map(e => e.outerHTML))).toEqual([ @@ -96,6 +95,9 @@ test('should support checked', async ({ page }) => { `
Hello
`, `
Unknown
`, ]); + expect(await page.$$eval(`role=checkbox[checked="mixed"]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); expect(await page.$$eval(`role=checkbox`, els => els.map(e => e.outerHTML))).toEqual([ ``, ``, @@ -115,7 +117,6 @@ test('should support pressed', async ({ page }) => { `); expect(await page.$$eval(`role=button[pressed]`, els => els.map(e => e.outerHTML))).toEqual([ ``, - ``, ]); expect(await page.$$eval(`role=button[pressed=true]`, els => els.map(e => e.outerHTML))).toEqual([ ``, @@ -127,6 +128,12 @@ test('should support pressed', async ({ page }) => { expect(await page.$$eval(`role=button[pressed="mixed"]`, els => els.map(e => e.outerHTML))).toEqual([ ``, ]); + expect(await page.$$eval(`role=button`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ``, + ]); }); test('should support expanded', async ({ page }) => { @@ -223,7 +230,7 @@ test('should filter hidden, unless explicitly asked for', async ({ page }) => { ``, ``, ]); - expect(await page.$$eval(`role=button[includeHidden]`, els => els.map(e => e.outerHTML))).toEqual([ + expect(await page.$$eval(`role=button[include-hidden]`, els => els.map(e => e.outerHTML))).toEqual([ ``, ``, ``, @@ -235,7 +242,7 @@ test('should filter hidden, unless explicitly asked for', async ({ page }) => { ``, ``, ]); - expect(await page.$$eval(`role=button[includeHidden=true]`, els => els.map(e => e.outerHTML))).toEqual([ + expect(await page.$$eval(`role=button[include-hidden=true]`, els => els.map(e => e.outerHTML))).toEqual([ ``, ``, ``, @@ -247,7 +254,7 @@ test('should filter hidden, unless explicitly asked for', async ({ page }) => { ``, ``, ]); - expect(await page.$$eval(`role=button[includeHidden=false]`, els => els.map(e => e.outerHTML))).toEqual([ + expect(await page.$$eval(`role=button[include-hidden=false]`, els => els.map(e => e.outerHTML))).toEqual([ ``, ``, ``, @@ -275,7 +282,7 @@ test('should support name', async ({ page }) => { `
`, `
`, ]); - expect(await page.$$eval(`role=button[name="Hello"][includeHidden]`, els => els.map(e => e.outerHTML))).toEqual([ + expect(await page.$$eval(`role=button[name="Hello"][include-hidden]`, els => els.map(e => e.outerHTML))).toEqual([ `
`, ``, ]); @@ -286,7 +293,7 @@ test('errors', async ({ page }) => { expect(e0.message).toContain(`Role must not be empty`); const e1 = await page.$('role=foo[sElected]').catch(e => e); - expect(e1.message).toContain(`Unknown attribute "sElected", must be one of "checked", "disabled", "expanded", "includeHidden", "level", "name", "pressed", "selected"`); + expect(e1.message).toContain(`Unknown attribute "sElected", must be one of "checked", "disabled", "expanded", "include-hidden", "level", "name", "pressed", "selected"`); const e2 = await page.$('role=foo[bar . qux=true]').catch(e => e); expect(e2.message).toContain(`Unknown attribute "bar.qux"`); @@ -302,4 +309,7 @@ test('errors', async ({ page }) => { const e6 = await page.$('role=button[level=3]').catch(e => e); expect(e6.message).toContain(`"level" attribute is only supported for roles: "heading", "listitem", "row", "treeitem"`); + + const e7 = await page.$('role=button[name]').catch(e => e); + expect(e7.message).toContain(`"name" attribute must have a value`); });