Skip to content

fix(a11y): stop marking the dropbox aria-hidden while it is still open - #492

Open
gnbm wants to merge 2 commits into
masterfrom
gm-a11y-focus-warning
Open

fix(a11y): stop marking the dropbox aria-hidden while it is still open#492
gnbm wants to merge 2 commits into
masterfrom
gm-a11y-focus-warning

Conversation

@gnbm

@gnbm gnbm commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

No issue filed — reported from a Chrome console warning.


What is the current behavior?

1. Closing the dropdown logs a Chrome warning.

Blocked aria-hidden on an element because its descendant retained focus.

closeDropbox() marks the dropbox aria-hidden="true" immediately, but the popover keeps it on
screen for the ~200 ms hide transition. During that window the dropbox is still visible and still
isOpened() === true, so hovering the fading list still moves the highlight — and taking the
highlight takes DOM focus. Chrome then refuses the attribute, so the dropbox stays exposed to
screen readers
after the component believes it hid it. Selecting an option in a single select is
enough to reproduce it: the pointer is still over the list while it fades out.

Focus also ends up dropped to <body> instead of returning to the combobox.

2. open() during that same window is silently lost.

The popover refuses to show while it is still hiding, so the open had no visible effect at all: the
dropdown ended up shut while its combobox still reported aria-expanded="true", and no afterOpen
was dispatched.

What is the new behavior?

  • aria-hidden flips only when the dropbox actually leaves the screen, so "hidden from assistive
    technology" and "hidden on screen" can no longer disagree.
  • A closing dropbox ignores the pointer for the rest of the fade — no re-highlight, no
    aria-activedescendant written onto a combobox that just announced itself collapsed, no focus
    pulled into the closing subtree. Clicks and arrow keys are unchanged.
  • Focus still inside when the dropbox is hidden goes back to the combobox (WCAG 2.4.3) instead of
    falling to <body> — or is only blurred when the close was caused by focus moving elsewhere, so
    the component never steals focus.
  • open() during the close transition is queued and applied once the transition finishes.
    beforeOpen / afterOpen fire exactly once per call and no stale afterClose follows a
    reopen, and anything that would close the dropdown cancels the queue — a close(), a click
    outside, or another dropdown opening — without disturbing where focus goes once the cancelled
    instance's own, unrelated close finishes.

WCAG 4.1.2 Name, Role, Value (A), 1.3.1 Info and Relationships (A), 2.4.3 Focus Order (A).

Does this introduce a breaking change?

  • Yes
  • No

No API, markup, event or default-option change. Two visible corrections, both a11y-driven: the
pointer no longer drives the list during the close fade, and keyboard focus lands on the combobox
instead of being lost to the page.

Other information

Two specs, 19 cases:

  • a11y-aria-hidden-focus.cy.ts enforces the invariant Chrome checks, from both directions — focus
    entering an aria-hidden subtree, and aria-hidden applied over a subtree that still holds
    focus — by patching Element.prototype.setAttribute, so the offending call site is captured
    synchronously.
  • reopen-during-hide-transition.cy.ts covers the queued open, every way it can be cancelled, and
    the event contract (beforeOpen/afterOpen/afterClose) around it.

The mid-fade cases pin the transition window (isOpened() asserted true) and dispatch mouseover
synchronously, so they cannot pass vacuously on a slow runner.

Full suite: 440/440 across 30 specs; tsc, eslint, stylelint and the Node test suites
clean. dist/, dist-archive/ and docs/assets/ are intentionally untouched — a release build
regenerates them.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

PR Test Results — ✅ all checks passed

Check Result Time
Typecheck 3s
ESLint 2s
Stylelint 1s
CI Scripts 1s
Build 3s
a11y-aria-hidden-focus.cy.ts ✅ 9/9 12s
a11y-aria-label.cy.ts ✅ 10/10 4s
a11y-close-clears-highlight.cy.ts ✅ 7/7 4s
a11y-escape-close.cy.ts ✅ 6/6 2s
a11y-listbox-multiselectable.cy.ts ✅ 3/3 1s
a11y-live-region.cy.ts ✅ 18/18 5s
a11y-reduced-motion.cy.ts ✅ 3/3 1s
a11y-required-error.cy.ts ✅ 20/20 4s
a11y-search-arrow-navigation.cy.ts ✅ 13/13 7s
a11y-select-all.cy.ts ✅ 7/7 3s
a11y-server-search-announcements.cy.ts ✅ 6/6 4s
a11y-target-size.cy.ts ✅ 5/5 2s
build-stylesheet-integrity.cy.ts ✅ 4/4 2s
examples.cy.ts ✅ 219/219 1m41s
observer-listener-lifecycle.cy.ts ✅ 7/7 1s
perf-resize-throttle.cy.ts ✅ 3/3 1s
perf-scroll-aria.cy.ts ✅ 6/6 4s
reopen-during-hide-transition.cy.ts ✅ 11/11 14s
secure-option-values.cy.ts ✅ 2/2 1s
secure-text-warning.cy.ts ✅ 4/4 1s
security-ampersand-storage.cy.ts ✅ 16/16 3s
security-chrome-label-props.cy.ts ✅ 10/10 1s
security-classnames-xss.cy.ts ✅ 2/2 1s
security-customdata-xss.cy.ts ✅ 2/2 1s
security-global-defaults.cy.ts ✅ 11/11 1s
security-hidden-input-name.cy.ts ✅ 7/7 1s
security-proto-value.cy.ts ✅ 9/9 1s
security-quote-escaping.cy.ts ✅ 12/12 2s
tag-tooltip-overflow.cy.ts ✅ 6/6 11s
timer-cleanup.cy.ts ✅ 2/2 1s

Tested commit: df49ee5 · Run #30

@gnbm
gnbm force-pushed the gm-a11y-focus-warning branch from f90e0ee to a886a6c Compare August 11, 2026 17:40
@gnbm gnbm added the bug Something isn't working label Aug 11, 2026
closeDropbox() set aria-hidden="true" on the dropbox immediately, but the
popover keeps it on screen for the ~200ms hide transition and the `closed`
class - with the display:none that actually removes it - only lands in
afterHidePopper(). For that window the dropbox was declared absent to
assistive technology while still visible, still hit-testable and still
isOpened() === true. Chrome refuses aria-hidden over the focused element
("Blocked aria-hidden on an element because its descendant retained
focus"), so the dropbox stayed exposed after the component believed it had
hidden it - the component and the accessibility tree disagreed, and a
screen reader follows the tree. Selecting an option in a single select was
enough to trigger it: the pointer is still over the list while it fades
out, and hovering an option focuses it.

Three coordinated changes:

1. aria-hidden now flips in afterHidePopper() only, together with the rest
   of the closed state, so "hidden from assistive technology" and "hidden
   on screen" can no longer disagree. tabindex still goes to -1 at close
   time - it was never part of the conflict (it does not block a
   programmatic focus()), so when the dropbox leaves the tab order is
   unchanged.

2. A closing dropbox ignores the pointer. isOpened() stays true for the
   whole hide transition, so hover kept driving the fading list -
   re-highlighting options, re-writing aria-activedescendant onto a
   combobox that had just announced itself collapsed, and pulling DOM
   focus back into the subtree about to be hidden. isClosingTransition
   gates onOptionsMouseOver() from hide start to hide end (and is cleared
   on reopen). Clicks and arrow keys are deliberately not gated - that
   would change what mid-fade interactions do.

3. releaseFocusFromDropbox() releases whatever focus is still inside when
   the dropbox is actually hidden - the pointer gate cannot cover a host
   focusing into the dropbox mid-fade. Focus is handed back to the
   combobox (WCAG 2.4.3 Focus Order; previously it silently fell to
   <body>), or only blurred when the close was caused by focus moving
   elsewhere (another dropdown opening, an outside click), so this
   instance never steals focus from where it now belongs. It runs after
   the `closed` class is committed and before the aria-hidden writes, and
   those writes stand down when a focus handler reopened the dropdown
   meanwhile. isFocusInsideDropbox() documents why this containment test
   is narrower than closeDropbox()'s within-component check.

The stand-down guard is isOpened() rather than "is this instance back in
openInstances", deliberately: the two differ for a hide that lost its race
with an earlier reopen, and that hide has to complete. The popover has
already taken the dropbox off the screen by then, so standing down would
leave the wrapper without its `closed` class and every later toggle
closing a dropdown that is not there.

WCAG 4.1.2 Name, Role, Value (A), 1.3.1 Info and Relationships (A),
2.4.3 Focus Order (A).

test(a11y): pin that focus never sits inside an aria-hidden subtree

a11y-aria-hidden-focus.cy.ts installs the invariant Chrome enforces, from
both directions - focus moving into a hidden subtree, and aria-hidden
applied over a subtree that still holds focus - by patching
Element.prototype.setAttribute (with String() coercion: setAria() forwards
a boolean) so the offending call site is captured synchronously.

Ten cases: the reported hover-while-fading flow and its multi-select
variant, a silent close over the focused search input, a host focusing
into the dropbox mid-fade, Tab onto an option followed by an outside
click, Escape mid navigation, the open/hidden state coherence, a focusin
handler that reopens during the close, a hide that lost a race with a
reopen, and a deferred re-render after the close. The mid-fade cases pin
the transition window (isOpened() asserted true) and dispatch mouseover
synchronously, so they cannot pass vacuously on a slow runner. The guard
installs idempotently and judges only vscomp nodes.
@gnbm
gnbm force-pushed the gm-a11y-focus-warning branch 2 times, most recently from a1b9cbf to 67014be Compare August 11, 2026 22:00
@gnbm
gnbm marked this pull request as ready for review August 11, 2026 22:10
PopoverComponent.show() early-returns for as long as `pop-comp-active` is
on the element, and only its own afterHide removes that class. So
openDropbox() called during the ~200ms hide transition did all of its own
work - beforeOpen, aria-expanded="true", the instance back in
openInstances - while popper.show() quietly did nothing. afterShowPopper()
never ran, so there was no `focused` class, no focus moved into the list
and no afterOpen; the hide that was still pending then completed on top of
it.

The dropdown ended up shut with its combobox still announcing
aria-expanded="true", and focus inside it dropped to <body>. The host's
open() was silently lost - it looks like nothing happened, which is the
worst shape for a bug to have.

Reachable through the public open() API only: toggleDropbox() sees
isOpened() === true for the whole transition, so a second click closes
again rather than reopening.

openDropbox() now queues instead of running when the popover is mid-hide,
and afterHidePopper() replays the open once the popover is idle again -
immediately before the existing "stand down if the dropdown is open again"
guard, so the replay reuses the one path that already knows not to hide an
open dropdown rather than adding a second.

Both conditions are required to queue. isClosingTransition alone would
strand every later open if it were ever left set with no hide actually
pending; isShown() alone is true of a dropdown that is simply already
open, where re-opening must stay a no-op rather than a queued one.
Together they mean exactly "a hide is running and the popover will refuse
to show". Nothing reaches into dropboxPopover.popper to cancel its timer -
that would couple this component to the plugin's internals - so the cost
is one extra fade before the dropdown appears, which is inherent: the
popover cannot show while it is hiding.

A queued open is not a promise to open regardless of what happens next, so
it has to be reachable by everything that ends the lifecycle, and its
effects have to stay scoped to itself:

- The queued instance registers in openInstances. That set already means
  "open, or about to be", and it is how onDocumentClick() and the "close
  all others" loop find an instance - the close that started the hide had
  already removed it. Without this the queue outlives an outside click or
  a second dropdown opening: the dropdown springs open ~200ms after the
  user dismissed it and, through that same loop, shuts the one they had
  just opened.
- Both of those loops write `shouldFocusWrapperOnClose = false` on every
  instance they find before closing it - correct for the dropdown they are
  actually closing, wrong for a merely-queued one, whose real close is a
  separate, already-pending hide that made its own decision about where
  focus should land. closeDropbox() snapshots the flag when an open is
  queued and restores it when the queue is cancelled, so an unrelated later
  event cannot silently overwrite a decision that belongs to a different
  close.
- closeDropbox() treats a merely-queued instance as not open and only
  cancels the queue, returning before isSilentClose is assigned. That flag
  belongs to the close that started the hide, and overwriting it would
  lose that close's afterClose; returning early also avoids a second
  beforeClose for a dropdown that is already closing.
- The replay is gated on !isOpened(), because both reopen routes can
  converge on one close - a consumer focus handler reacting to the wrapper
  refocus in releaseFocusFromDropbox() may already have reopened the
  dropdown synchronously, and replaying on top of that would dispatch a
  second beforeOpen for a single open. The same reopen can leave a stale
  afterClose describing a dropdown that is, by the time the event fires,
  open again; afterClose is now gated on the identical !isOpened() check.

Queueing before any work is what keeps the event contract intact more
generally: letting openDropbox() run and re-running it from
afterHidePopper() would dispatch beforeOpen twice for one open(), which
would make a host that lazy-loads options on beforeOpen fetch twice.

test: pin the reopen against the popover's hide transition

reopen-during-hide-transition.cy.ts, eleven cases: the reopen ends up open
and on screen, the open actually finishes (focus in the search input, the
`focused` class), beforeOpen and afterOpen each fire exactly once, a close
following the queued open cancels it, an outside click cancels it, another
dropdown opening cancels it and stays open itself without corrupting where
the cancelled instance's own close sends focus, a focus handler reopening
alongside the queue still yields one beforeOpen and no stale afterClose,
the same queue works on the default non-portalled layout, and an ordinary
open with no hide running is still applied synchronously rather than
deferred.

Six of the eleven were red against the previous behaviour. Each waits out
the hide and the show that has to follow it before asserting: every
assertion also holds during the fade - openDropbox() sets aria-expanded
and clears aria-hidden synchronously, and the popover has not yet applied
display:none - so a retrying should() would otherwise latch onto that
transient and pass against the bug. One case reads document.activeElement
in a one-shot check immediately after a retrying class assertion rather
than inside the same retrying callback, so a slower retry landing after an
unrelated second dropdown's own focus change can't be mistaken for the
result under test.

a11y-aria-hidden-focus.cy.ts's mid-transition case is rewritten. Its
premise - that the reopen is lost - no longer holds, so it now pins the
accessibility half of the queued path: the queue must hold the pointer
gate up for the rest of the fade, so the list cannot take DOM focus back
while the hide is still on its way to marking the subtree hidden.

Its deferred-re-render case is removed. It could not fail: closeDropbox()
clears `.focused` synchronously, and even when forced to have something,
the `closed` class makes the subtree display:none, so focus() on an option
inside it is a no-op - while before that point aria-hidden has not been
applied yet. The invariant is unviolatable through that timer, so a
fire-time guard there would be unreachable code. The reasoning and the
condition that would make it reachable again are recorded in the spec's
docblock rather than left as a case that passes whatever the code does.
@gnbm
gnbm force-pushed the gm-a11y-focus-warning branch from 67014be to df49ee5 Compare August 11, 2026 23:07
@gnbm
gnbm marked this pull request as draft August 11, 2026 23:08
@gnbm gnbm added the a11y label Aug 11, 2026
@gnbm
gnbm marked this pull request as ready for review August 11, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant