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`.
This commit is contained in:
Dmitry Gozman 2022-03-31 13:06:39 -07:00 committed by GitHub
parent b2c863f6a3
commit e5182259b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 103 additions and 11 deletions

View File

@ -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 `<input type=checkbox>` 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 `<h1>-<h6>` 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

View File

@ -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, ['<truthy>', '=']);
if (attr.op === '<truthy>') {
// 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, ['<truthy>', '=']);
if (attr.op === '<truthy>') {
// 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 !== '<truthy>' && typeof attr.value !== 'string' && !(attr.value instanceof RegExp))
if (attr.op === '<truthy>')
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, ['<truthy>', '=']);
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 === '<truthy>' || !!attr.value;
continue;
}

View File

@ -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([
`<input type="checkbox" checked="">`,
`<input type="checkbox" indeterminate="">`,
`<div role="checkbox" aria-checked="true">Hi</div>`,
]);
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 }) => {
`<div role="checkbox" aria-checked="false">Hello</div>`,
`<div role="checkbox">Unknown</div>`,
]);
expect(await page.$$eval(`role=checkbox[checked="mixed"]`, els => els.map(e => e.outerHTML))).toEqual([
`<input type="checkbox" indeterminate="">`,
]);
expect(await page.$$eval(`role=checkbox`, els => els.map(e => e.outerHTML))).toEqual([
`<input type="checkbox">`,
`<input type="checkbox" checked="">`,
@ -115,7 +117,6 @@ test('should support pressed', async ({ page }) => {
`);
expect(await page.$$eval(`role=button[pressed]`, els => els.map(e => e.outerHTML))).toEqual([
`<button aria-pressed="true">Hello</button>`,
`<button aria-pressed="mixed">Mixed</button>`,
]);
expect(await page.$$eval(`role=button[pressed=true]`, els => els.map(e => e.outerHTML))).toEqual([
`<button aria-pressed="true">Hello</button>`,
@ -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([
`<button aria-pressed="mixed">Mixed</button>`,
]);
expect(await page.$$eval(`role=button`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button aria-pressed="true">Hello</button>`,
`<button aria-pressed="false">Bye</button>`,
`<button aria-pressed="mixed">Mixed</button>`,
]);
});
test('should support expanded', async ({ page }) => {
@ -223,7 +230,7 @@ test('should filter hidden, unless explicitly asked for', async ({ page }) => {
`<button style="visibility:visible">Still here</button>`,
`<button>Shadow1</button>`,
]);
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([
`<button>Hi</button>`,
`<button hidden="">Hello</button>`,
`<button aria-hidden="true">Yay</button>`,
@ -235,7 +242,7 @@ test('should filter hidden, unless explicitly asked for', async ({ page }) => {
`<button>Shadow1</button>`,
`<button>Shadow2</button>`,
]);
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([
`<button>Hi</button>`,
`<button hidden="">Hello</button>`,
`<button aria-hidden="true">Yay</button>`,
@ -247,7 +254,7 @@ test('should filter hidden, unless explicitly asked for', async ({ page }) => {
`<button>Shadow1</button>`,
`<button>Shadow2</button>`,
]);
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([
`<button>Hi</button>`,
`<button aria-hidden="false">Nay</button>`,
`<button style="visibility:visible">Still here</button>`,
@ -275,7 +282,7 @@ test('should support name', async ({ page }) => {
`<div role="button" aria-label="Hello"></div>`,
`<div role="button" aria-label="Hallo"></div>`,
]);
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([
`<div role="button" aria-label="Hello"></div>`,
`<div role="button" aria-label="Hello" aria-hidden="true"></div>`,
]);
@ -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`);
});