Skip to content

Make :enabled the complement of :disabled, fieldsets included - #190

Open
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:fix/disabled-complement
Open

Make :enabled the complement of :disabled, fieldsets included#190
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:fix/disabled-complement

Conversation

@jdalton

@jdalton jdalton commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

:disabled walks up the tree looking for a disabled <fieldset>, the way the HTML spec asks it to, but :enabled only reads the element's own disabled property. An <input> inside a disabled fieldset therefore matches both pseudo-classes at once, which no browser does.

This moves the rule into one isDisabled() helper that both pseudo-classes ask, so they cannot disagree, and fixes two more cases the old walk got wrong along the way.

What the two pseudo-classes answer before and after, on one document
<input id=i1 disabled>
<input id=i2>
<fieldset id=fs disabled>
  <legend id=lg><input id=li1></legend>
  <input id=fi1>
  <fieldset id=fsin><input id=fi3></fieldset>
</fieldset>
<select><optgroup id=og disabled><option id=op1>a</option></optgroup></select>

Before this change, fi1, fsin and fi3 all match :enabled and :disabled together. After it, nothing matches both, and each element lands where Chromium puts it: li1 is enabled because it sits in the fieldset's first legend, fi1 and fi3 are disabled by the fieldset above them, and op1 is disabled by its optgroup.

Why the rule is shaped this way, and where browsers implement it

Three parts of the spec rule are easy to lose, and the previous code lost two of them.

A <legend> excuses only the fieldset it belongs to, so the walk has to carry on outward rather than stop at the first disabled fieldset it finds an excuse for. Blink keeps the legend ancestor it last saw and compares it against that fieldset's own first legend before continuing, in listed_element.cc#L702.

An <option> is disabled by the <optgroup> it is a child of, whose own disabled property reflects only that optgroup's attribute, so the option has to read its parent.

And the two pseudo-classes are one predicate seen from both sides. Blink's MatchesEnabledPseudoClass() is exactly !IsDisabledFormControl(), in html_form_control_element.cc#L337, where the disabled side is IsActuallyDisabled() — the element's own attribute or the ancestor state — in listed_element.cc#L738.

The spec text is enabling and disabling form controls.

The two read-* pseudo-classes were reading the same property

:read-only and :read-write also asked for the element's own disabled, so a control inside a disabled fieldset came out read-write. They ask the new helper now, which is why the diff touches them.

On the document above, Chromium answers fieldset :read-write with li1 and nothing else from inside the disabled fieldset. Before this change the engine also returned fi1 and fi3.

References: the spec, the browser source, and what each part was reasoned from

This patch applies to master on its own. The sixteen in this series were checked by cherry-picking them onto master one after another, in this order and in reverse, and all sixteen land without a conflict.

@jdalton
jdalton force-pushed the fix/disabled-complement branch from e64c1a8 to a8ec97d Compare September 4, 2026 18:00
':disabled' walks the ancestry for a disabled fieldset, as the spec requires, while ':enabled' reads only the element's own disabled property. An input inside a disabled fieldset therefore matches both, and browsers match neither pseudo-class twice: Blink runs them off one predicate, where MatchesEnabledPseudoClass() is !IsDisabledFormControl(). https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/html/forms/html_form_control_element.cc#L337
 The ancestry rule moves into one isDisabled() helper that both ask, which also lets the rule follow the spec rather than approximate it. A disabled fieldset disables its descendants unless they sit in that fieldset's first legend child; a legend excuses only the fieldset it belongs to, so the walk carries on outward past it; and an option is disabled by the optgroup it is a child of, whose own disabled property reflects only that optgroup's attribute. Blink walks it the same way, keeping a legend ancestor and comparing it against that fieldset's own legend before continuing. https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/html/forms/listed_element.cc#L702
 ':read-only' and ':read-write' read the same own-property, so a control inside a disabled fieldset came out read-write there too; they ask the helper now.

References:

- Spec: https://html.spec.whatwg.org/#enabling-and-disabling-form-controls:-the-disabled-attribute — the fieldset, legend and optgroup rules
- Spec: https://drafts.csswg.org/selectors-4/#enableddisabled — ':enabled' and ':disabled' as complements
- Chromium: https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/html/forms/listed_element.cc#L702 — the ancestry walk, including why it continues past a legend
- Chromium: https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/html/forms/listed_element.cc#L738 — IsActuallyDisabled(): the own attribute or the ancestor state
- Chromium: https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/html/forms/html_form_control_element.cc#L337 — the two pseudo-classes as one predicate
- MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/:disabled
@jdalton
jdalton force-pushed the fix/disabled-complement branch from a8ec97d to cfa2a0e Compare September 5, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant