diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md index 851de7a730a74..41e6aecd24f21 100644 --- a/docs/src/aria-snapshots.md +++ b/docs/src/aria-snapshots.md @@ -564,7 +564,7 @@ Groups capture nested elements, such as `
` 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 @@ -586,3 +586,13 @@ control states. ```yaml title="aria snapshot" - button "Toggle" [pressed=true] ``` + +#### Input with `aria-invalid` attribute + +```html + +``` + +```yaml title="aria snapshot" +- textbox "Email" [invalid]: not-an-email +``` diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index 17b438dc13616..ca22c175fdcb3 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -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); @@ -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) @@ -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') diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index 12f79efef26b0..5a10136eb3e0a 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -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. diff --git a/packages/isomorphic/ariaSnapshot.ts b/packages/isomorphic/ariaSnapshot.ts index a051692bd7b56..2be51b1f98930 100644 --- a/packages/isomorphic/ariaSnapshot.ts +++ b/packages/isomorphic/ariaSnapshot.ts @@ -30,6 +30,7 @@ export type AriaProps = { disabled?: boolean; expanded?: boolean; active?: boolean; + invalid?: boolean; level?: number; pressed?: boolean | 'mixed'; selected?: boolean; @@ -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. @@ -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); diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 0236178bba053..468c266fabdaf 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -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(` + + + `); + + 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(``); + await expect(page).toMatchAriaSnapshot(` + - textbox "Bio" [invalid] + `); +});