Skip to content
Open
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
12 changes: 11 additions & 1 deletion docs/src/aria-snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ Groups capture nested elements, such as `<details>` elements with summary conten

### Attributes and states

Commonly used ARIA attributes, like `checked`, `disabled`, `expanded`, `level`, `pressed`, and `selected`, represent
Commonly used ARIA attributes, like `checked`, `disabled`, `expanded`, `invalid`, `level`, `pressed`, and `selected`, represent
control states.

#### Checkbox with `checked` attribute
Expand All @@ -586,3 +586,13 @@ control states.
```yaml title="aria snapshot"
- button "Toggle" [pressed=true]
```

#### Input with `aria-invalid` attribute

```html
<input type="text" aria-label="Email" aria-invalid="true" value="not-an-email">
```

```yaml title="aria snapshot"
- textbox "Email" [invalid]: not-an-email
```
7 changes: 7 additions & 0 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode |
if (roleUtils.kAriaExpandedRoles.includes(role))
result.expanded = roleUtils.getAriaExpanded(element);

if (roleUtils.kAriaInvalidRoles.includes(role) && roleUtils.getAriaInvalid(element) !== 'false')
result.invalid = true;

if (roleUtils.kAriaLevelRoles.includes(role))
result.level = roleUtils.getAriaLevel(element);

Expand Down Expand Up @@ -423,6 +426,8 @@ function matchesNode(node: aria.AriaNode | string, template: aria.AriaTemplateNo
return false;
if (template.expanded !== undefined && template.expanded !== node.expanded)
return false;
if (template.invalid !== undefined && template.invalid !== node.invalid)
return false;
if (template.level !== undefined && template.level !== node.level)
return false;
if (template.pressed !== undefined && template.pressed !== node.pressed)
Expand Down Expand Up @@ -614,6 +619,8 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
key += ` [expanded]`;
if (ariaNode.active && options.renderActive)
key += ` [active]`;
if (ariaNode.invalid)
key += ` [invalid]`;
if (ariaNode.level)
key += ` [level=${ariaNode.level}]`;
if (ariaNode.pressed === 'mixed')
Expand Down
21 changes: 20 additions & 1 deletion packages/injected/src/roleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,26 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
return accessibleDescription;
}

function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
// Roles where `aria-invalid` is conceptually meaningful per WAI-ARIA 1.2.
// See https://www.w3.org/TR/wai-aria-1.2/#aria-invalid for the supported list.
export const kAriaInvalidRoles = [
'application',
'checkbox',
'columnheader',
'combobox',
'gridcell',
'listbox',
'radiogroup',
'rowheader',
'searchbox',
'slider',
'spinbutton',
'switch',
'textbox',
'tree',
];

export function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
// This state is being deprecated as a global state in ARIA 1.2.
// In future versions it will only be allowed on roles where it is specifically supported.
Expand Down
8 changes: 7 additions & 1 deletion packages/isomorphic/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type AriaProps = {
disabled?: boolean;
expanded?: boolean;
active?: boolean;
invalid?: boolean;
level?: number;
pressed?: boolean | 'mixed';
selected?: boolean;
Expand Down Expand Up @@ -67,7 +68,7 @@ export function hasPointerCursor(ariaNode: AriaNode): boolean {
}

function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean {
return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed;
return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.invalid === b.invalid && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed;
}

// We pass parsed template between worlds using JSON, make it easy.
Expand Down Expand Up @@ -496,6 +497,11 @@ export class KeyParser {
node.active = value === 'true';
return;
}
if (key === 'invalid') {
this._assert(value === 'true' || value === 'false', 'Value of "invalid" attribute must be a boolean', errorPos);
node.invalid = value === 'true';
return;
}
if (key === 'level') {
this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos);
node.level = Number(value);
Expand Down
23 changes: 23 additions & 0 deletions tests/page/to-match-aria-snapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,3 +934,26 @@ test('treat bad regex as a string', async ({ page }) => {
expect(stripAnsi(error.message)).toContain('- - /url: /[a/');
expect(stripAnsi(error.message)).toContain('+ - /url: /foo');
});

test('invalid attribute', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34839' } }, async ({ page }) => {
await page.setContent(`
<input type="text" aria-label="Email" aria-invalid="true" value="not-an-email">
<input type="text" aria-label="Name" value="Alice">
`);

await expect(page).toMatchAriaSnapshot(`
- textbox "Email" [invalid]: not-an-email
- textbox "Name": Alice
`);

await expect(page).toMatchAriaSnapshot(`
- textbox "Email" [invalid=true]: not-an-email
- textbox "Name" [invalid=false]: Alice
`);

// `aria-invalid="grammar"` and `aria-invalid="spelling"` map to the boolean flag too.
await page.setContent(`<input type="text" aria-label="Bio" aria-invalid="spelling">`);
await expect(page).toMatchAriaSnapshot(`
- textbox "Bio" [invalid]
`);
});