Skip to content

fix(overlay): don't close auto overlay on click inside content in a focusable ancestor - #6625

Open
Rajdeepc wants to merge 6 commits into
mainfrom
rajdeepchandra/fix-overlay-focusout-tabindex-ancestor-swc-5731
Open

fix(overlay): don't close auto overlay on click inside content in a focusable ancestor#6625
Rajdeepc wants to merge 6 commits into
mainfrom
rajdeepchandra/fix-overlay-focusout-tabindex-ancestor-swc-5731

Conversation

@Rajdeepc

@Rajdeepc Rajdeepc commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes [type="auto"] sp-overlay incorrectly closing when clicking non-focusable content inside its own popover, when the trigger and the overlay share a focusable ancestor (e.g. a <div tabindex="0"> wrapping both).

closeOnFocusOut decided whether focus left the overlay by checking if the newly-focused element (event.relatedTarget) was a descendant of the overlay. Clicking non-focusable overlay content (plain text, padding, etc.) causes the browser to resolve focus onto the nearest focusable ancestor instead of the click target; when that ancestor also wraps the overlay itself, it sits above the overlay in the DOM, so the check always concluded focus had left and closed the overlay even though the click landed inside it.

closeOnFocusOut now also tracks whether the pointerdown causing the current focus change originated inside the overlay's own composed subtree (via a new capture-phase pointerdown/pointerup pair mirroring the existing focusout listener lifecycle), and treats focus as remaining within the overlay in that case regardless of where it was ultimately resolved to.

Motivation and context

Reported in the linked issue: any consumer that needs a focusable wrapper around a trigger + sp-overlay pair (for example, a keyboard-navigable card or list row) sees the overlay dismiss itself on the very first click inside its own content, making type="auto" unusable in that composition pattern.

Related issue(s)

Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed at the Accessibility Practices for this feature, see: Aria Practices
  • I have added automated tests to cover my changes.
  • I have included a well-written changeset if my change needs to be published.
  • I have included updated documentation if my change required it.

Reviewer's checklist

  • Includes a Github Issue with appropriate flag or Jira ticket number without a link
  • Includes thoughtfully written changeset if changes suggested include patch, minor, or major features
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

Manual review test cases

  • Auto overlay stays open when clicking its own content inside a focusable wrapper

    1. Go to the Overlay Element story focusableAncestorWrapper (dev tag) in Storybook
    2. Click "Button Action required: Greenkeeper could not be activated 🚨 #1" (or "Button Andrew/card component #2") to open its popover, then click the popover's text content
    3. Expect the popover to remain open, matching "Button Make repo public #3" (the unwrapped control)
  • No regression to existing focus-out / outside-click dismissal

    1. Open any type="auto" overlay elsewhere in the library (e.g. Picker, Action Menu)
    2. Click completely outside the trigger and its overlay
    3. Expect the overlay to still close as before

Device review

  • Did it pass in Desktop?
  • Did it pass in (emulated) Mobile?
  • Did it pass in (emulated) iPad?

Accessibility testing checklist

  • Keyboard (required — document steps below)

    1. Open a type="auto" overlay via its trigger with Enter/Space
    2. Press Tab to move focus through the overlay's content and then past it
    3. Expect the overlay to still close when focus moves past its last focusable element (unchanged by this fix — covered by the existing "closes an inline overlay when tabbing past the content" test)
  • Screen reader (required — document steps below)

    1. Open a type="auto" overlay wrapped in a focusable ancestor, with a screen reader active
    2. Click inside the popover's non-focusable content
    3. Expect the popover's role/state announcements to remain unchanged; no unexpected dismissal or duplicate "closed" announcement

Verification performed

  • Added a regression test (overlay-element.test.ts) reproducing the reported scenario exactly (trigger + overlay wrapped in tabindex="0", click on non-focusable content) — passes.
  • Full overlay yarn test group: all tests pass.
  • overlay-api yarn test group (action-menu, dialog, menu, overlay, picker, tooltip — the real type="auto" consumers): all tests pass, no regressions.
  • Added a dev-tagged Storybook story (focusableAncestorWrapper) reproducing the exact three-button scenario from the issue for manual verification.
  • eslint / prettier: clean.

…ancestor

closeOnFocusOut decided whether focus left the overlay by checking if
the newly-focused element was a descendant of the overlay. Clicking
non-focusable overlay content resolves focus onto the nearest
focusable ancestor instead of the click target; when that ancestor
also wraps the overlay itself, it sits above the overlay in the DOM,
so the check always concluded focus had left and closed the overlay
even though the click landed inside it.

closeOnFocusOut now also tracks whether the pointerdown causing the
current focus change originated inside the overlay's own composed
subtree, and treats focus as remaining within the overlay in that
case regardless of where it was ultimately resolved to.

Fixes #5731

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Rajdeepc
Rajdeepc requested a review from a team as a code owner August 17, 2026 06:51
@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5057a0d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 83 packages
Name Type
@spectrum-web-components/overlay Patch
@spectrum-web-components/action-menu Patch
@spectrum-web-components/combobox Patch
@spectrum-web-components/contextual-help Patch
@spectrum-web-components/menu Patch
@spectrum-web-components/picker Patch
@spectrum-web-components/popover Patch
@spectrum-web-components/textfield Patch
@spectrum-web-components/tooltip Patch
@spectrum-web-components/story-decorator Patch
@spectrum-web-components/bundle Patch
@spectrum-web-components/truncated Patch
@spectrum-web-components/breadcrumbs Patch
@spectrum-web-components/custom-vars-viewer Patch
@spectrum-web-components/action-bar Patch
@spectrum-web-components/card Patch
@spectrum-web-components/coachmark Patch
@spectrum-web-components/color-field Patch
@spectrum-web-components/number-field Patch
@spectrum-web-components/search Patch
documentation Patch
@spectrum-web-components/vrt-compare Patch
@spectrum-web-components/slider Patch
@spectrum-web-components/accordion Patch
@spectrum-web-components/action-button Patch
@spectrum-web-components/action-group Patch
@spectrum-web-components/alert-banner Patch
@spectrum-web-components/alert-dialog Patch
@spectrum-web-components/asset Patch
@spectrum-web-components/avatar Patch
@spectrum-web-components/badge Patch
@spectrum-web-components/button-group Patch
@spectrum-web-components/button Patch
@spectrum-web-components/checkbox Patch
@spectrum-web-components/clear-button Patch
@spectrum-web-components/close-button Patch
@spectrum-web-components/color-area Patch
@spectrum-web-components/color-handle Patch
@spectrum-web-components/color-loupe Patch
@spectrum-web-components/color-slider Patch
@spectrum-web-components/color-wheel Patch
@spectrum-web-components/dialog Patch
@spectrum-web-components/divider Patch
@spectrum-web-components/dropzone Patch
@spectrum-web-components/field-group Patch
@spectrum-web-components/field-label Patch
@spectrum-web-components/help-text Patch
@spectrum-web-components/icon Patch
@spectrum-web-components/icons-ui Patch
@spectrum-web-components/icons-workflow Patch
@spectrum-web-components/icons Patch
@spectrum-web-components/iconset Patch
@spectrum-web-components/illustrated-message Patch
@spectrum-web-components/infield-button Patch
@spectrum-web-components/link Patch
@spectrum-web-components/meter Patch
@spectrum-web-components/modal Patch
@spectrum-web-components/picker-button Patch
@spectrum-web-components/progress-bar Patch
@spectrum-web-components/progress-circle Patch
@spectrum-web-components/radio Patch
@spectrum-web-components/sidenav Patch
@spectrum-web-components/split-view Patch
@spectrum-web-components/status-light Patch
@spectrum-web-components/swatch Patch
@spectrum-web-components/switch Patch
@spectrum-web-components/table Patch
@spectrum-web-components/tabs Patch
@spectrum-web-components/tags Patch
@spectrum-web-components/thumbnail Patch
@spectrum-web-components/toast Patch
@spectrum-web-components/top-nav Patch
@spectrum-web-components/tray Patch
@spectrum-web-components/underlay Patch
@spectrum-web-components/base Patch
@spectrum-web-components/grid Patch
@spectrum-web-components/opacity-checkerboard Patch
@spectrum-web-components/reactive-controllers Patch
@spectrum-web-components/shared Patch
@spectrum-web-components/styles Patch
@spectrum-web-components/theme Patch
@spectrum-web-components/eslint-plugin Patch
@spectrum-web-components/stylelint-header-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Rajdeepc Rajdeepc added Status:Ready for review PR ready for review or re-review. 1st-gen and removed ready-for-review labels Aug 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📚 Branch Preview Links

🔍 Gen1 Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-6625

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@coveralls

coveralls commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 32134464340

Coverage increased (+0.009%) to 96.25%

Details

  • Coverage increased (+0.009%) from the base build.
  • Patch coverage: 113 of 113 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 39362
Covered Lines: 38087
Line Coverage: 96.76%
Relevant Branches: 6475
Covered Branches: 6031
Branch Coverage: 93.14%
Branches in Coverage %: Yes
Coverage Strength: 457.0 hits per line

💛 - Coveralls

Rajdeep Chandra and others added 2 commits August 17, 2026 12:39
…verage

Render each popover already open (matching the other click-triggered
stories in this file) and drop the dev tag plus the VRT/Chromatic
skips, since this is a real bug-fix regression story, not a dev-only
scratch example, and should get golden-hash coverage like any other
story here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The three-overlay combined story rendered every sp-overlay open at
once, but the native popover="auto" behavior only allows a single
auto popover open at a time, so the browser itself force-closed two
of the three regardless of the closeOnFocusOut fix. Split it into
one story per trigger variant, each with a single already-open
overlay, so all three variants get real VRT coverage.

Also fixed the flex-column wrapper defaulting to align-items:
stretch, which stretched the trigger buttons to full width.

Verified in a live browser: the previously-broken (wrapped) case
stays open when clicking its own content and still closes on an
actual outside click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Rajdeepc Rajdeepc self-assigned this Aug 17, 2026
Comment thread 1st-gen/packages/overlay/src/Overlay.ts Outdated
// The interaction that caused this focus change started within the overlay,
// even though focus may have resolved onto an ancestor outside of it. Treat
// focus as still within the overlay.
if (pointerdownWasWithinOverlay) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this guard too broad? It's placed before we check event.relatedTarget (we could check if it's an ancestor of the overlay in the composed tree rather than tracking the pointer gesture origin). E.g. if we have a button or interaction inside the overlay that sends focus elsewhere, the overlay would stay open. ✨

@Rajdeepc Rajdeepc Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we check relatedTarget is an ancestor of the overlay alone isn't a safe replacement though, since document.body is trivially an ancestor of every overlay. I think that'd stop the overlay from ever closing on outside click.

I would track the actual pointerdown target, and only suppress the close when relatedTarget is an ancestor of (or equal to) that specific element.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh yeah, that makes sense!

capture: true,
});
} else {
listenerRoot.removeEventListener(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we reset lastPointerdownWasWithinOverlay here?

this.lastPointerdownWasWithinOverlay = false;

@Rajdeepc Rajdeepc Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will reset it there so a pending flag can't leak into the next open cycle if the overlay tears down before its matching pointerup fires. Good catch.

Rajdeep Chandra and others added 2 commits August 18, 2026 10:12
…rget check

Addresses review feedback on #6625: the previous boolean flag treated
any pointerdown anywhere inside the overlay as sufficient to suppress
a subsequent focusout, which would also mask a legitimate close if
something inside the overlay explicitly redirected focus to an
unrelated element on pointerdown/mousedown.

Now tracks the specific pointerdown target and only suppresses the
close when relatedTarget is an ancestor of (or equal to) that same
target, i.e. exactly the relationship the browser creates by
resolving focus to the nearest focusable ancestor of a non-focusable
click target. An explicit focus redirect to an unrelated element no
longer matches this and falls through to the normal close path.

Also:
- clears the pending pointerdown record on pointercancel in addition
  to pointerup, so an interrupted gesture (e.g. the OS cancelling a
  touch drag) can't leave a stale record behind
- resets the record when the auto-type listeners are torn down, so a
  pending record can't leak into the next time the same overlay
  instance opens
- factors the shadow-DOM-safe "is X an ancestor of Y" check (already
  used to test overlay containment) into a shared private helper, now
  reused for both the original containment check and the new one,
  with `{ once: true }` so the throwaway listener cleans itself up

Added a regression test for the redirect-to-unrelated-element case;
confirmed it fails against the previous implementation and passes
against this one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… scenario

Reverts the earlier split into three stories back into a single
focusableAncestorWrapper story covering all scenarios, per feedback
that a single story is preferred over three. Since the native
popover="auto" behavior only allows one auto popover open at a time,
none are pre-opened via the `open` attribute anymore; the story is
closed by default and driven interactively (click to open, click
content to verify it stays open, click outside to verify it closes).

Also folds in a fourth scenario for the reviewer-flagged case from
PR #6625: a button inside the popover that redirects focus to an
unrelated field on pointerdown. Clicking it should still close the
popover, demonstrating the tightened ancestor-of-click-target check.

Verified all four scenarios interactively in a live Storybook: the
wrapped cases stay open on internal clicks, and the redirect case
still closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Rajdeepc
Rajdeepc requested a review from cdransf August 18, 2026 05:06

@cdransf cdransf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! ✨

@rubencarvalho rubencarvalho self-assigned this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1st-gen Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Overlay in focus region dismissed when click into its content

4 participants