Skip to content

Commit

Permalink
fix: isEditable/toBeEditable throw for elements that cannot be editab…
Browse files Browse the repository at this point in the history
…le/readonly (#33713)
  • Loading branch information
dgozman authored Nov 22, 2024
1 parent b7e47dc commit f123f7a
Show file tree
Hide file tree
Showing 8 changed files with 60 additions and 11 deletions.
13 changes: 11 additions & 2 deletions docs/src/actionability.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,20 @@ Element is considered stable when it has maintained the same bounding box for at

## Enabled

Element is considered enabled unless it is a `<button>`, `<select>`, `<input>` or `<textarea>` with a `disabled` property.
Element is considered enabled when it is **not disabled**.

Element is **disabled** when:
- it is a `<button>`, `<select>`, `<input>`, `<textarea>`, `<option>` or `<optgroup>` with a `[disabled]` attribute;
- it is a `<button>`, `<select>`, `<input>`, `<textarea>`, `<option>` or `<optgroup>` that is a part of a `<fieldset>` with a `[disabled]` attribute;
- it is a descendant of an element with `[aria-disabled=true]` attribute.

## Editable

Element is considered editable when it is [enabled] and does not have `readonly` property set.
Element is considered editable when it is [enabled] and is **not readonly**.

Element is **readonly** when:
- it is a `<select>`, `<input>` or `<textarea>` with a `[readonly]` attribute;
- it has an `[aria-readonly=true]` attribute and an aria role that [supports it](https://w3c.github.io/aria/#aria-readonly).

## Receives Events

Expand Down
2 changes: 1 addition & 1 deletion docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -1483,7 +1483,7 @@ Boolean disabled = await page.GetByRole(AriaRole.Button).IsDisabledAsync();
* since: v1.14
- returns: <[boolean]>

Returns whether the element is [editable](../actionability.md#editable).
Returns whether the element is [editable](../actionability.md#editable). If the target element is not an `<input>`, `<textarea>`, `<select>`, `[contenteditable]` and does not have a role allowing `[aria-readonly]`, this method throws an error.

:::warning[Asserting editable state]
If you need to assert that an element is editable, prefer [`method: LocatorAssertions.toBeEditable`] to avoid flakiness. See [assertions guide](../test-assertions.md) for more details.
Expand Down
11 changes: 7 additions & 4 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels';
import { Highlight } from './highlight';
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription } from './roleUtils';
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly } from './roleUtils';
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
Expand Down Expand Up @@ -626,9 +626,12 @@ export class InjectedScript {
if (state === 'enabled')
return !disabled;

const editable = !(['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && element.hasAttribute('readonly'));
if (state === 'editable')
return !disabled && editable;
if (state === 'editable') {
const readonly = getReadonly(element);
if (readonly === 'error')
throw this.createStacklessError('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
return !disabled && !readonly;
}

if (state === 'checked' || state === 'unchecked') {
const need = state === 'checked';
Expand Down
15 changes: 15 additions & 0 deletions packages/playwright-core/src/server/injected/roleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,21 @@ export function getChecked(element: Element, allowMixed: boolean): boolean | 'mi
return 'error';
}

// https://w3c.github.io/aria/#aria-readonly
const kAriaReadonlyRoles = ['checkbox', 'combobox', 'grid', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];
export function getReadonly(element: Element): boolean | 'error' {
const tagName = elementSafeTagName(element);
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName))
return element.hasAttribute('readonly');
if (kAriaReadonlyRoles.includes(getAriaRole(element) || ''))
return element.getAttribute('aria-readonly') === 'true';
if ((element as HTMLElement).isContentEditable)
return false;
return 'error';
}

export const kAriaPressedRoles = ['button'];
export function getAriaPressed(element: Element): boolean | 'mixed' {
// https://www.w3.org/TR/wai-aria-1.2/#aria-pressed
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13680,7 +13680,9 @@ export interface Locator {
}): Promise<boolean>;

/**
* Returns whether the element is [editable](https://playwright.dev/docs/actionability#editable).
* Returns whether the element is [editable](https://playwright.dev/docs/actionability#editable). If the target element is not an `<input>`,
* `<textarea>`, `<select>`, `[contenteditable]` and does not have a role allowing `[aria-readonly]`, this method
* throws an error.
*
* **NOTE** If you need to assert that an element is editable, prefer
* [expect(locator).toBeEditable([options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-editable)
Expand Down
7 changes: 7 additions & 0 deletions tests/page/expect-boolean.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ test.describe('toBeEditable', () => {
const locator = page.locator('input');
await expect(locator).not.toBeEditable({ editable: false });
});

test('throws', async ({ page }) => {
await page.setContent('<button>');
const locator = page.locator('button');
const error = await expect(locator).toBeEditable().catch(e => e);
expect(error.message).toContain('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
});
});

test.describe('toBeEnabled', () => {
Expand Down
15 changes: 14 additions & 1 deletion tests/page/locator-convenience.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,15 @@ it('isEnabled and isDisabled should work', async ({ page }) => {
});

it('isEditable should work', async ({ page }) => {
await page.setContent(`<input id=input1 disabled><textarea></textarea><input id=input2>`);
await page.setContent(`
<input id=input1 disabled>
<textarea></textarea>
<input id=input2>
<div contenteditable="true"></div>
<span id=span1 role=textbox aria-readonly=true></span>
<span id=span2 role=textbox></span>
<button>button</button>
`);
await page.$eval('textarea', t => t.readOnly = true);
const input1 = page.locator('#input1');
expect(await input1.isEditable()).toBe(false);
Expand All @@ -130,6 +138,11 @@ it('isEditable should work', async ({ page }) => {
const textarea = page.locator('textarea');
expect(await textarea.isEditable()).toBe(false);
expect(await page.isEditable('textarea')).toBe(false);
expect(await page.locator('div').isEditable()).toBe(true);
expect(await page.locator('#span1').isEditable()).toBe(false);
expect(await page.locator('#span2').isEditable()).toBe(true);
const error = await page.locator('button').isEditable().catch(e => e);
expect(error.message).toContain('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
});

it('isChecked should work', async ({ page }) => {
Expand Down
4 changes: 2 additions & 2 deletions tests/page/page-fill.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ it('should fill elements with existing value and selection', async ({ page, serv

it('should throw nice error without injected script stack when element is not an <input>', async ({ page, server }) => {
let error = null;
await page.goto(server.PREFIX + '/input/textarea.html');
await page.fill('body', '').catch(e => error = e);
await page.setContent(`<select><option>value1</option></select>`);
await page.fill('select', '').catch(e => error = e);
expect(error.message).toContain('page.fill: Error: Element is not an <input>, <textarea> or [contenteditable] element\nCall log:');
});

Expand Down

0 comments on commit f123f7a

Please sign in to comment.