fix: isEditable/toBeEditable throw for elements that cannot be editable/readonly (#33713)
This commit is contained in:
parent
b7e47dc0bd
commit
f123f7ac69
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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';
|
||||
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
|
@ -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 }) => {
|
||||
|
|
|
@ -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:');
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue