diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index c73aa88e7baa4..6b5f430a8909d 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -1041,6 +1041,8 @@ Attribute name to get the value for. ### option: Frame.getByRole.description = %%-locator-get-by-role-option-description-%% +### option: Frame.getByRole.busy = %%-locator-get-by-role-option-busy-%% + ## method: Frame.getByTestId * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index 7f19ceb465ec2..1305e716ad65e 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -144,6 +144,8 @@ in that iframe. ### option: FrameLocator.getByRole.description = %%-locator-get-by-role-option-description-%% +### option: FrameLocator.getByRole.busy = %%-locator-get-by-role-option-busy-%% + ## method: FrameLocator.getByTestId * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 59a98671aec5e..7bbff0ce3298a 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1444,6 +1444,8 @@ Attribute name to get the value for. ### option: Locator.getByRole.description = %%-locator-get-by-role-option-description-%% +### option: Locator.getByRole.busy = %%-locator-get-by-role-option-busy-%% + ## method: Locator.getByTestId * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 606bab5d35fc3..f264363967b22 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2282,6 +2282,8 @@ Attribute name to get the value for. ### option: Page.getByRole.description = %%-locator-get-by-role-option-description-%% +### option: Page.getByRole.busy = %%-locator-get-by-role-option-busy-%% + ## method: Page.getByTestId * since: v1.27 - returns: <[Locator]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 118b38b2820ab..d00e50bf2f2e7 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1376,6 +1376,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.61 +- `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]> diff --git a/packages/injected/src/roleSelectorEngine.ts b/packages/injected/src/roleSelectorEngine.ts index 53d1a7f7428dc..2691ff32bba6a 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, getElementAccessibleDescription, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaBusy, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleDescription, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; import { matchesAttributePart } from './selectorUtils'; import type { AttributeSelectorOperator, AttributeSelectorPart } from '@isomorphic/selectorParser'; @@ -37,10 +37,11 @@ type RoleEngineOptions = { expanded?: boolean; level?: number; disabled?: boolean; + busy?: boolean; includeHidden?: boolean; }; -const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'description', 'include-hidden']; +const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'busy', 'name', 'description', 'include-hidden']; kSupportedAttributes.sort(); function validateSupportedRole(attr: string, roles: string[], role: string) { @@ -106,6 +107,12 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE options.disabled = attr.op === '' ? true : attr.value; break; } + case 'busy': { + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ['', '=']); + options.busy = attr.op === '' ? true : attr.value; + break; + } case 'name': { if (attr.op === '') throw new Error(`"name" attribute must have a value`); @@ -157,6 +164,8 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo return; if (options.disabled !== undefined && getAriaDisabled(element) !== options.disabled) return; + if (options.busy !== undefined && getAriaBusy(element) !== options.busy) + return; if (!options.includeHidden) { const isHidden = isElementHiddenForAria(element); if (isHidden) diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index 5a10136eb3e0a..1c1bfb8635f29 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -1154,6 +1154,12 @@ function hasExplicitAriaDisabled(element: Element | undefined, isAncestor = fals return false; } +export function getAriaBusy(element: Element): boolean { + // https://www.w3.org/TR/wai-aria-1.2/#aria-busy + // aria-busy is a global state with a default value of "false". + return getAriaBoolean(element.getAttribute('aria-busy')) === true; +} + function getAccessibleNameFromAssociatedLabels(labels: Iterable, options: AccessibleNameOptions) { return [...labels].map(label => getTextAlternativeInternal(label, { ...options, diff --git a/packages/isomorphic/locatorUtils.ts b/packages/isomorphic/locatorUtils.ts index 77cb2d715a7ef..0708cbccde3f5 100644 --- a/packages/isomorphic/locatorUtils.ts +++ b/packages/isomorphic/locatorUtils.ts @@ -17,6 +17,7 @@ import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils'; export type ByRoleOptions = { + busy?: boolean; checked?: boolean; description?: string | RegExp; disabled?: boolean; @@ -86,5 +87,7 @@ export function getByRoleSelector(role: string, options: ByRoleOptions = {}): st props.push(['description', escapeForAttributeSelector(options.description, !!options.exact)]); if (options.pressed !== undefined) props.push(['pressed', String(options.pressed)]); + if (options.busy !== undefined) + props.push(['busy', String(options.busy)]); return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`; } diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 5636d7dc10060..80ef27f9fedb8 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -2949,6 +2949,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. * @@ -6808,6 +6815,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. * @@ -14057,6 +14071,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. * @@ -19499,6 +19520,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/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 5636d7dc10060..80ef27f9fedb8 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2949,6 +2949,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. * @@ -6808,6 +6815,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. * @@ -14057,6 +14071,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. * @@ -19499,6 +19520,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/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index fa9a863592e9c..28a96e633ba0c 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -227,6 +227,12 @@ it('reverse engineer getByRole', async ({ page }) => { java: `getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setChecked(true).setLevel(3).setPressed(false))`, csharp: `GetByRole(AriaRole.Button, new() { Checked = true, Level = 3, Pressed = false })`, }); + expect.soft(generate(page.getByRole('cell', { busy: false }))).toEqual({ + javascript: `getByRole('cell', { busy: false })`, + python: `get_by_role("cell", busy=False)`, + java: `getByRole(AriaRole.CELL, new Page.GetByRoleOptions().setBusy(false))`, + csharp: `GetByRole(AriaRole.Cell, new() { Busy = false })`, + }); expect.soft(generate(page.getByRole('alert', { name: 'Upload', description: 'doc.pdf' }))).toEqual({ javascript: `getByRole('alert', { name: 'Upload', description: 'doc.pdf' })`, python: `get_by_role("alert", name="Upload", description="doc.pdf")`, diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index f7db3bcf105c1..10b4b353a0fe1 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -208,6 +208,49 @@ test('should support expanded', async ({ page }) => { ]); }); +test('should support busy', async ({ page }) => { + await page.setContent(` +
Hi
+
Hello
+
Bye
+ + + `); + + expect(await page.locator(`role=cell`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hi
`, + `
Hello
`, + `
Bye
`, + ]); + + expect(await page.locator(`role=cell[busy]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hello
`, + ]); + expect(await page.locator(`role=cell[busy=true]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hello
`, + ]); + expect(await page.getByRole('cell', { busy: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hello
`, + ]); + + expect(await page.locator(`role=cell[busy=false]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hi
`, + `
Bye
`, + ]); + expect(await page.getByRole('cell', { busy: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
Hi
`, + `
Bye
`, + ]); + + // aria-busy is a global ARIA state and should work for any role. + expect(await page.getByRole('button', { busy: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.getByRole('button', { busy: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); +}); + test('should support disabled', async ({ page }) => { await page.setContent(` @@ -511,7 +554,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", "description", "disabled", "expanded", "include-hidden", "level", "name", "pressed", "selected"`); + expect(e1.message).toContain(`Unknown attribute "sElected", must be one of "busy", "checked", "description", "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"`);