diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 88438c2830..a363533780 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -492,7 +492,7 @@ export class InjectedScript { if (state === 'hidden') return !this.isVisible(element); - const disabled = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(element.nodeName) && element.hasAttribute('disabled'); + const disabled = isElementDisabled(element); if (state === 'disabled') return disabled; if (state === 'enabled') @@ -1182,4 +1182,35 @@ function deepEquals(a: any, b: any): boolean { return false; } +function isElementDisabled(element: Element): boolean { + const isRealFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(element.nodeName); + if (isRealFormControl && element.hasAttribute('disabled')) + return true; + if (isRealFormControl && hasDisabledFieldSet(element)) + return true; + if (hasAriaDisabled(element)) + return true; + return false; +} + +function hasDisabledFieldSet(element: Element|null): boolean { + if (!element) + return false; + if (element.tagName === 'FIELDSET' && element.hasAttribute('disabled')) + return true; + // fieldset does not work across shadow boundaries + return hasDisabledFieldSet(element.parentElement); +} +function hasAriaDisabled(element: Element|undefined): boolean { + if (!element) + return false; + const attribute = (element.getAttribute('aria-disabled') || '').toLowerCase(); + if (attribute === 'true') + return true; + if (attribute === 'false') + return false; + return hasAriaDisabled(parentElementOrShadowHost(element)); +} + + export default InjectedScript; diff --git a/tests/page/elementhandle-wait-for-element-state.spec.ts b/tests/page/elementhandle-wait-for-element-state.spec.ts index 8c049823b7..0d6985c7d9 100644 --- a/tests/page/elementhandle-wait-for-element-state.spec.ts +++ b/tests/page/elementhandle-wait-for-element-state.spec.ts @@ -103,6 +103,39 @@ it('should throw waiting for enabled when detached', async ({ page }) => { expect(error.message).toContain('Element is not attached to the DOM'); }); +it('should wait for button with a disabled fieldset', async ({ page }) => { + await page.setContent('
'); + const span = await page.$('text=Target'); + let done = false; + const promise = span.waitForElementState('enabled').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await span.evaluate(span => (span.parentElement.parentElement as HTMLFieldSetElement).disabled = false); + await promise; +}); + +it('should wait for aria enabled button', async ({ page }) => { + await page.setContent(''); + const span = await page.$('text=Target'); + let done = false; + const promise = span.waitForElementState('enabled').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await span.evaluate(span => span.parentElement.setAttribute('aria-disabled', 'false')); + await promise; +}); + +it('should wait for button with an aria-disabled parent', async ({ page }) => { + await page.setContent('
'); + const span = await page.$('text=Target'); + let done = false; + const promise = span.waitForElementState('enabled').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await span.evaluate(span => span.parentElement.parentElement.setAttribute('aria-disabled', 'false')); + await promise; +}); + it('should wait for disabled button', async ({ page }) => { await page.setContent(''); const span = await page.$('text=Target');