fix(web): a control inherits disabled from a fieldset and an aria-disabled ancestor

Merged
OK
1mo ago
+226-8
  • Repro: <fieldset disabled><button>Pay</button></fieldset>. button.disabled is false, button.matches(':disabled') is true. The reader read the property, so toBeEnabled passed, toBeDisabled failed, isDisabled() answered false, and the agent's tree listed a dead button as a live one. Playwright's toBeDisabled passes on the same button.
  • Same for a widget under <div aria-disabled="true">: Playwright inherits the state down the tree, e2e read the element's own attribute only.
  • disabled is now the effective state (packages/web/src/in-page/read-semantics.ts, isDisabled):
flowchart LR
  A[el.matches ':disabled'] -- true --> D[disabled]
  A -- false --> B[own aria-disabled=true]
  B -- true --> D
  B -- false --> C{role takes aria-disabled?}
  C -- no --> E[enabled]
  C -- yes --> W[nearest aria-disabled up the chain, across shadow hosts]
  W -- true --> D
  W -- false or none --> E
  • :disabled is the browser's own answer for a form control: own attribute, a disabled fieldset with the first-legend exception, nested fieldsets, a disabled optgroup. No hand-written fieldset walk.
  • The aria chain uses Playwright's role list (kAriaDisabledRoles) and stop rule (aria-disabled="false" cuts it), memoized per read so a subtree under one disabled ancestor costs one walk.
  • Own aria-disabled="true" still counts on any element, as before. Playwright limits that to the role list too; kept the wider reading so no existing toBeDisabled on a div flips.
  • packages/web/tests/integration/read-node-states.test.ts checks fieldset, first legend, second legend, nested fieldsets, aria ancestor, false cut, shadow root, and compares each case with Playwright's isDisabled() on the same page. The runner's kitchen-sink test gets a fenced button and a legend button so isDisabled(), toBeDisabled, and toBeEnabled agree through the CLI.
  • docs/reference/expect.mdx says what disabled means on the web.

Devin Review