fix(a11y): stop marking the dropbox aria-hidden while it is still open - #492
Open
gnbm wants to merge 2 commits into
Open
fix(a11y): stop marking the dropbox aria-hidden while it is still open#492gnbm wants to merge 2 commits into
gnbm wants to merge 2 commits into
Conversation
PR Test Results — ✅ all checks passed
Tested commit: |
gnbm
force-pushed
the
gm-a11y-focus-warning
branch
from
August 11, 2026 17:40
f90e0ee to
a886a6c
Compare
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
force-pushed
the
gm-a11y-focus-warning
branch
2 times, most recently
from
August 11, 2026 22:00
a1b9cbf to
67014be
Compare
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
force-pushed
the
gm-a11y-focus-warning
branch
from
August 11, 2026 23:07
67014be to
df49ee5
Compare
gnbm
marked this pull request as draft
August 11, 2026 23:08
gnbm
marked this pull request as ready for review
August 11, 2026 23:21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No issue filed — reported from a Chrome console warning.
What is the current behavior?
1. Closing the dropdown logs a Chrome warning.
closeDropbox()marks the dropboxaria-hidden="true"immediately, but the popover keeps it onscreen 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 thehighlight 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 noafterOpenwas dispatched.
What is the new behavior?
aria-hiddenflips only when the dropbox actually leaves the screen, so "hidden from assistivetechnology" and "hidden on screen" can no longer disagree.
aria-activedescendantwritten onto a combobox that just announced itself collapsed, no focuspulled into the closing subtree. Clicks and arrow keys are unchanged.
falling to
<body>— or is only blurred when the close was caused by focus moving elsewhere, sothe component never steals focus.
open()during the close transition is queued and applied once the transition finishes.beforeOpen/afterOpenfire exactly once per call and no staleafterClosefollows areopen, and anything that would close the dropdown cancels the queue — a
close(), a clickoutside, 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?
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.tsenforces the invariant Chrome checks, from both directions — focusentering an
aria-hiddensubtree, andaria-hiddenapplied over a subtree that still holdsfocus — by patching
Element.prototype.setAttribute, so the offending call site is capturedsynchronously.
reopen-during-hide-transition.cy.tscovers the queued open, every way it can be cancelled, andthe event contract (
beforeOpen/afterOpen/afterClose) around it.The mid-fade cases pin the transition window (
isOpened()asserted true) and dispatchmouseoversynchronously, so they cannot pass vacuously on a slow runner.
Full suite: 440/440 across 30 specs;
tsc,eslint,stylelintand the Node test suitesclean.
dist/,dist-archive/anddocs/assets/are intentionally untouched — a release buildregenerates them.