diff --git a/docs/src/api/params.md b/docs/src/api/params.md index d8278abd6a19c..c0ebc1a3f7fe7 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1302,6 +1302,14 @@ Whether to find an exact match: case-sensitive and whole-string. Default to fals Required aria role. +## locator-get-by-role-option-busy +* since: v1.27 +- `busy` <[boolean]> + +An attribute that is usually set by `aria-busy`. + +Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + ## locator-get-by-role-option-checked * since: v1.27 - `checked` <[boolean]> @@ -1376,6 +1384,7 @@ An attribute that is usually set by `aria-selected`. Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). ## locator-get-by-role-option-list-v1.27 +- %%-locator-get-by-role-option-busy-%% - %%-locator-get-by-role-option-checked-%% - %%-locator-get-by-role-option-disabled-%% - %%-locator-get-by-role-option-expanded-%% diff --git a/packages/injected/src/roleSelectorEngine.ts b/packages/injected/src/roleSelectorEngine.ts index cbef4ed747774..5144898617481 100644 --- a/packages/injected/src/roleSelectorEngine.ts +++ b/packages/injected/src/roleSelectorEngine.ts @@ -17,7 +17,7 @@ import { parseAttributeSelector } from '@isomorphic/selectorParser'; import { normalizeWhiteSpace } from '@isomorphic/stringUtils'; -import { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaBusy, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; import { matchesAttributePart } from './selectorUtils'; import type { AttributeSelectorOperator, AttributeSelectorPart } from '@isomorphic/selectorParser'; @@ -28,6 +28,7 @@ type RoleEngineOptions = { name?: string | RegExp; nameOp?: '='|'*='|'|='|'^='|'$='|'~='; exact?: boolean; + busy?: boolean; checked?: boolean | 'mixed'; pressed?: boolean | 'mixed'; selected?: boolean; @@ -37,7 +38,7 @@ type RoleEngineOptions = { includeHidden?: boolean; }; -const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden']; +const kSupportedAttributes = ['busy', 'selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden']; kSupportedAttributes.sort(); function validateSupportedRole(attr: string, roles: string[], role: string) { @@ -59,6 +60,12 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE const options: RoleEngineOptions = { role }; for (const attr of attrs) { switch (attr.name) { + case 'busy': { + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ['', '=']); + options.busy = attr.op === '' ? true : attr.value; + break; + } case 'checked': { validateSupportedRole(attr.name, kAriaCheckedRoles, role); validateSupportedValues(attr, [true, false, 'mixed']); @@ -132,6 +139,8 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo const match = (element: Element) => { if (getAriaRole(element) !== options.role) return; + if (options.busy !== undefined && getAriaBusy(element) !== options.busy) + return; if (options.selected !== undefined && getAriaSelected(element) !== options.selected) return; if (options.checked !== undefined && getAriaChecked(element) !== options.checked) diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index b9bdd333a01c8..eb6d83ac35643 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -1018,6 +1018,11 @@ export function getAriaPressed(element: Element): boolean | 'mixed' { return false; } +export function getAriaBusy(element: Element): boolean { + // https://www.w3.org/TR/wai-aria-1.2/#aria-busy + return getAriaBoolean(element.getAttribute('aria-busy')) === true; +} + 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 | undefined { // https://www.w3.org/TR/wai-aria-1.2/#aria-expanded diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index dbd022dcc3388..bad7f237c46ae 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -2920,6 +2920,13 @@ export interface Page { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * @@ -6679,6 +6686,13 @@ export interface Frame { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * @@ -13356,6 +13370,13 @@ export interface Locator { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * @@ -19657,6 +19678,13 @@ export interface FrameLocator { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * diff --git a/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts index 67701d1cbca77..181ae75874029 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts @@ -17,6 +17,7 @@ import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils'; export type ByRoleOptions = { + busy?: boolean; checked?: boolean; disabled?: boolean; exact?: boolean; @@ -58,6 +59,8 @@ export function getByTextSelector(text: string | RegExp, options?: { exact?: boo export function getByRoleSelector(role: string, options: ByRoleOptions = {}): string { const props: string[][] = []; + if (options.busy !== undefined) + props.push(['busy', String(options.busy)]); if (options.checked !== undefined) props.push(['checked', String(options.checked)]); if (options.disabled !== undefined) diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index dbd022dcc3388..bad7f237c46ae 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2920,6 +2920,13 @@ export interface Page { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * @@ -6679,6 +6686,13 @@ export interface Frame { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * @@ -13356,6 +13370,13 @@ export interface Locator { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * @@ -19657,6 +19678,13 @@ export interface FrameLocator { * @param options */ getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * An attribute that is usually set by `aria-busy`. + * + * Learn more about [`aria-busy`](https://www.w3.org/TR/wai-aria-1.2/#aria-busy). + */ + busy?: boolean; + /** * An attribute that is usually set by `aria-checked` or native `` controls. * diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index 6a36619f46fea..96986b070dd57 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -481,7 +481,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", "include-hidden", "level", "name", "pressed", "selected"`); + expect(e1.message).toContain(`Unknown attribute "sElected", must be one of "busy", "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"`); @@ -559,3 +559,60 @@ test('should not match scope by default', async ({ page }) => { await expect(children).toHaveCount(2); await expect(children).toHaveText(['child 1', 'child 2']); }); + +test('should support busy', async ({ page }) => { + await page.setContent(` +
Normal button
+
Busy button
+
Not busy button
+
Normal status
+
Loading status
+
Ready status
+ `); + + expect(await page.locator('role=button').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Normal button
`, + `
Busy button
`, + `
Not busy button
`, + ]); + expect(await page.getByRole('button').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Normal button
`, + `
Busy button
`, + `
Not busy button
`, + ]); + + expect(await page.locator(`role=button[busy]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Busy button
`, + ]); + expect(await page.locator(`role=button[busy=true]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Busy button
`, + ]); + expect(await page.getByRole('button', { busy: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Busy button
`, + ]); + + expect(await page.locator(`role=button[busy=false]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Normal button
`, + `
Not busy button
`, + ]); + expect(await page.getByRole('button', { busy: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Normal button
`, + `
Not busy button
`, + ]); + + expect(await page.locator(`role=status[busy=true]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Loading status
`, + ]); + expect(await page.getByRole('status', { busy: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Loading status
`, + ]); + + expect(await page.locator(`role=status[busy=false]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Normal status
`, + `
Ready status
`, + ]); + expect(await page.getByRole('status', { busy: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Normal status
`, + `
Ready status
`, + ]); +});