ENG-1723: Exclude disabled and aria-disabled Elements from Contrast Tests 107 & 109

Bug (ENG-1723): Access Engine color-contrast tests 107 (normal-size text, WCAG 1.4.3, requires 4.5:1) and 109 (large-scale text, WCAG 1.4.3 large-text path, requires 3:1) were flagging low-contrast text on elements that are natively disabled or carry aria-disabled="true". Per WCAG, text and visual information in an inactive (disabled) user-interface component has no contrast requirement, so these were false positives.

Impact: Greyed-out controls (which are intentionally low-contrast to signal that they are unavailable) generated noise. Real, fixable violations were buried under false positives on buttons, menu items, and tabs that the author legitimately disabled.

Test Logic:

Common Scenarios:

Fix: Before evaluating contrast in tests 107 and 109, skip any element that itself carries a native disabled attribute or aria-disabled="true". The check is element-level only — it does not walk ancestors, so it does not propagate the exemption to descendants of a disabled container.

The engine implements this by appending element-level attribute filters to the candidate selector:
:not([disabled]):not([aria-disabled="true"])

Known limitation: because this is a pure element-level CSS attribute check, descendants of a <fieldset disabled> that do not carry their own disabled attribute are not exempted — they are still evaluated (see the "Known Limitations" section below). Extending the exemption to inherited-disabled fieldset descendants would require an explicit ancestor walk and is out of scope for this ticket.

Active elements (aria-disabled="false" or no disabled state) continue through to the normal 4.5:1 / 3:1 evaluation.
N/A — not affected by fix
PASS — correct post-fix (not flagged)
FAIL — true positive, must still flag
BUG repro — pre-fix false positive
Known limitation — still flagged post-fix
Disabled (engine should skip)
Enabled low-contrast (engine should flag)

N/A Tests — Not Affected by This Fix

Hidden disabled element (display:none) — id="na-hidden-disabled"

(The disabled low-contrast button above is hidden via display:none.)

Hidden elements are excluded from contrast evaluation by visibility filtering before 107/109 even run. Should not be flagged regardless of the disabled fix.

Disabled element with visibility:hiddenid="na-vishidden-disabled"

(A disabled, low-contrast, visibility:hidden input sits here.)

Not rendered to the user; outside the scope of tests 107/109. Should not be flagged.

Enabled text that already has sufficient contrast — id="na-good-enabled"

Active paragraph text at #222 on #fff (~16:1). This is normal-size text well above the 4.5:1 threshold of test 107.

Contrast already passes; nothing for 107 or 109 to report. Not affected by the disabled exemption. Should not be flagged.

Disabled element but contrast is already sufficient — id="na-good-disabled"

Disabled AND high-contrast — neither the old behavior nor the new exemption would flag it. N/A to the fix. Should not be flagged.

PASS Tests — Correct Behavior After the Fix (Disabled = Not Flagged)

Native <button disabled> with low-contrast label — id="pass-button-disabled"

Element is natively disabled. Test 107 must skip it — disabled UI components are exempt from contrast. Should not be flagged.

Native <input disabled> with low-contrast value — id="pass-input-disabled"

Disabled input value text is exempt. Test 107 must skip it. Should not be flagged.

Element with aria-disabled="true" and low-contrast text — id="pass-ariadisabled"

ARIA-disabled custom button (low contrast)

The element itself carries aria-disabled="true", marking this component inactive, so the WCAG exemption applies to this element. Test 107 must skip it. Should not be flagged.

Large aria-disabled="true" heading-style text (test 109 path) — id="pass-ariadisabled-large"

Disabled large text (24px bold, #bbb on #fff)

Large text routes through test 109 (3:1 threshold) but the element is aria-disabled="true", so 109 must skip it too. Should not be flagged.

Enabled element with GOOD contrast next to a disabled one — id="pass-enabled-good"

The enabled button passes contrast; the disabled one is exempt. Neither should be flagged. Confirms the fix does not over-skip enabled siblings.

FAIL Tests — True Positives That Must STILL Be Flagged

ENABLED button with low-contrast label — id="fail-enabled-button"

Active control, normal-size text, ≈ 1.96:1 (< 2:1) < 4.5:1. Test 107 must still flag this. Regression guard: the fix must not suppress enabled controls.

ENABLED large/low-contrast text — id="fail-enabled-large"

Active large low-contrast heading text (24px bold)

Active large text, ≈ 1.96:1 (< 2:1) < 3:1. Test 109 must still flag this. Regression guard for the large-text path.

Element with aria-disabled="false" and low contrast — id="fail-aria-false"

aria-disabled="false" custom button (low contrast)

aria-disabled="false" means the component is active, so the exemption does NOT apply. Test 107 must still flag this. Guards against matching the attribute name rather than its value.

Enabled low-contrast text inside an aria-disabled="false" container — id="fail-aria-false-ancestor"

Low-contrast span inside an active (aria-disabled=false) container

The span carries no disabled state of its own, and the only ancestor is aria-disabled="false" (active). The element-level selector matches the span, so test 107 must still flag it. Guards against matching aria-disabled by attribute presence rather than its "true" value.

Enabled link styled to look disabled (no disabled state) — id="fail-fake-disabled"

"Greyed-out" link that is actually still clickable

Visually greyed but has neither disabled nor aria-disabled="true" — it is operable. Test 107 must still flag it. The exemption is driven by state, not by appearance.

Low-contrast child of an aria-disabled="true" ancestor — id="fail-ancestor-aria"

Nested low-contrast span inside an aria-disabled container

Only the ancestor <div> carries aria-disabled="true"; the <span> does not. Per WCAG, aria-disabled does not propagate to descendants, so the span is active, low-contrast text. The element-level selector matches it and test 107 must still flag it — this is a genuine true positive, not a missed exemption. The outer <div> itself is exempt (it carries the attribute), but the exemption stops there.

BUG Reproduction — Pre-Fix False Positives

Disabled low-contrast button (former false positive) — id="bug-disabled-button"

Pre-fix: flagged by test 107 ❌ (false positive — engine ignored the disabled state). Post-fix: not flagged ✓ (disabled UI is exempt from contrast).

aria-disabled low-contrast text (former false positive) — id="bug-ariadisabled-text"

aria-disabled low-contrast text

Pre-fix: flagged by test 107 ❌ (engine treated it as active text). Post-fix: not flagged ✓ (aria-disabled="true" exempts it).

Disabled large low-contrast text (former 109 false positive) — id="bug-disabled-large"

Disabled large low-contrast text (109 path)

Pre-fix: flagged by test 109 ❌. Post-fix: not flagged ✓ (large text in a disabled component is also exempt).

Known Limitations — Element-Level Selector Does Not Walk Ancestors

The fix exempts an element only when that element carries disabled or aria-disabled="true" (CSS :not([disabled]):not([aria-disabled="true"])); it does not walk ancestors. The cases below are therefore still flagged after the fix. For <fieldset disabled> descendants, WCAG would treat the text as exempt (native disabled inherits to form controls), so this is a documented engine limitation rather than a fixed case. Extending the exemption would require an ancestor walk (separate ticket).

<fieldset disabled> descendant controls — id="limitation-fieldset-disabled"

Disabled fieldset legend (low contrast)

The <fieldset> itself carries disabled and is exempt, but none of its descendants (<legend>, <label>, <input>, <button>) carry a disabled attribute of their own. The element-level :not([disabled]) selector still matches each one, so test 107 still flags their low-contrast text (all are normal-size, so 109 does not apply). The genuine limitation is the <input> and <button>: HTML disabled propagates only to form-associated descendants, so those two are functionally disabled and WCAG would consider their text exempt — yet the engine still flags them. The <legend> and <label> are not form controls and are not disabled, so flagging their low-contrast text is correct per WCAG. Not addressed by ENG-1723.

Single descendant of fieldset[disabled]id="limitation-fieldset-child"

The <input> has no disabled attribute of its own — only its <fieldset> ancestor does. The element-level selector matches the input, so test 107 still flags it post-fix. Same root cause as above; not fixed by ENG-1723.

Real-World Examples

Greyed-out "Submit" button awaiting form validation — id="rw-submit"



Submit becomes enabled once the form is valid.

Classic UX pattern: the Submit button is disabled and greyed out until validation passes. Test 107 must skip it. Should not be flagged.

Disabled nav menu item — id="rw-nav"

The "Billing" item is aria-disabled="true" and intentionally greyed. Active items keep good contrast; the disabled item is exempt. Should not be flagged by 107.

aria-disabled tab in a tablist — id="rw-tab"

The "Advanced" tab is aria-disabled="true" while the feature is locked. It is exempt from contrast. Active tabs keep good contrast. The disabled tab should not be flagged by 107.

Real-world counter-example: active "Save draft" button greyed by mistake — id="rw-active-grey"

This button has no disabled state — it is fully operable but styled too light.

A genuine contrast defect: an operable control with ≈ 1.96:1 text. Test 107 must still flag it. Demonstrates the fix does not give cover to enabled-but-light controls.