Skip to content

fix(HoverCard): Escape and focus work in the card's own document, such as an iframe - #9592

Draft
cixzhang wants to merge 4 commits into
mainfrom
lane4/hovercard-escape-owner-document
Draft

cixzhang wants to merge 4 commits into
mainfrom
lane4/hovercard-escape-owner-document

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

User impact

An app renders a HoverCard into a second document, such as a same-origin iframe or a Document Picture-in-Picture window, and the user works there:

  • Escape does nothing. The card stays up until the pointer or focus leaves, so a keyboard user can't dismiss content that covers what they're reading (WCAG 1.4.13, dismissible).
  • Tabbing from the trigger into the card closes it. Its controls can't be reached from the keyboard.

In the page itself, nothing changes.

Problem and solution fit

  • Why does it fail? The shared dismissal stack listens for Escape in each layer's own document, which it finds through getContainer. useHoverCard passed none, so the stack listened only in the page's document. The hook also looked up its card, and the focused element, in the page's document. In a second document the card is never found, so the hook reads it as closed, and focus moving into it reads as focus leaving.
  • Why does that harm the task? Escape is the keyboard's way to dismiss hover content, and focus moving into the card is how the keyboard reaches its controls.
  • Why does it matter? Apps that pop a panel out into Picture-in-Picture, or embed one in an iframe, ship hover cards there. Popover, Dialog and the layer stack already work in a second document.

Expected behavior and authority

  • component:HoverCard FR6 (current): Escape closes a visible card through the shared dismissal stack. The record lists the other-document failure as an implementation gap under Current-state impact, and AR2 cites WCAG 1.4.13.
  • family:overlay-dismissal: the stack routes a press to the top-most layer of the document it arrived in.

Smallest restoration

  • useHoverCard registers with getContainer: () => triggerRef.current. It also reads the trigger's ownerDocument (a small ownerDocumentOf helper) in the four places that read the page's document: Escape presence, the Escape focus check, the controlled-close focus check, and the focus-into-card check.
  • No new API, and no change in the page's own document.
  • Record:
    • FR6's gap sentence becomes the behavior: the topmost layer in its trigger's document.
    • The Current-state bullet and AR2's "other-document gap" are dropped.
    • FR4–FR6's verification row names the new cases.
    • The readiness count goes from eight gaps to seven.
    • family:overlay-dismissal drops the Tooltip/HoverCard row's sentence that HoverCard reads the page's document, and its FR8/FR9 evidence row names HoverCard's second-document checks.

Evidence

  • Reproduction before the change: the three new jsdom cases in HoverCard.test.tsx ("in a second document", a real iframe document) fail on main:
    • Escape there doesn't close the card;
    • focus doesn't return to the trigger;
    • focus moving into the card closes it.
  • Result after the change: all three pass.
  • Representative unchanged path: the existing HoverCard suite (page Escape, refocus, hold) and useLayerDismissal.test.tsx pass unchanged (93 tests).
  • Regression test or other mutation-sensitive proof:
    • Reverting only the focus-into-card read fails that case alone, and reverting the hook fails all three.
    • HoverCardSecondDocument.a11y.chromium.spec.ts drives a new no-visual story in Chromium, in a same-origin iframe and in a Document Picture-in-Picture window (Pop out): Escape pressed there closes the card, and Escape from inside the card returns focus to the trigger.

Scope

  • One defect is restored: the card works in its own document.
  • Separable contradictory or unsettled tagalongs were removed or split. The other HoverCard gaps (replaced trigger, roving focus trigger, mixed text children, focus hold, held-closed) are separate PRs.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm vitest run packages/core/src/HoverCard packages/core/src/Layer/useLayerDismissal.test.tsx: 93 passed.
  • pnpm check:knowledge: aligned.
  • The Chromium spec runs in CI's scoped a11y leg.

Writing rubric results

Run over the FR6 row, AR2, Current-state impact, the FR4–FR6 verification row, and family:overlay-dismissal's Tooltip/HoverCard and FR8/FR9 rows (docs/contributing/spec-writing.md):

  • Present tense, describes the system: yes. The removed gap text is gone, not reworded as history.
  • One owner per fact: HoverCard owns where its Escape applies, and the stack's per-document routing stays in family:overlay-dismissal.
  • Testable: FR6 names an observable outcome (Escape in the card's document closes it, a layer beneath stays open), and the verification row names the cases.
  • Plain language: short sentences, active voice.

component:HoverCard FR6: Escape closes a visible card through the shared
dismissal stack. The stack listens in each layer's own document, found
through getContainer, but useHoverCard passed none and read the page's
document for presence, the focus check, and the focus-into-card check. A
card rendered into an iframe or a Picture-in-Picture window therefore never
closed on Escape there (WCAG 1.4.13), and focus moving into it closed it.

useHoverCard now registers with getContainer: () => triggerRef.current and
reads the trigger's ownerDocument wherever it read the page's. Tests: three
jsdom cases in a real second document (red before), a Chromium spec over a
new no-visual iframe story. Record: FR6 loses its gap sentence, AR2 and
Current-state impact drop the other-document gap, and the verification row
names the cases.
@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 2, 2026 9:01pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 2, 2026
@astracat-bot

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Nov 2, 2026, 9:11 PM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec cf5ab9d

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the hook change itself works (the three new iframe cases fail on main and pass here), but a record fix and a wording or test fix are needed first.

  • docs/families/overlay-dismissal.md still ends the Tooltip/HoverCard member row with "HoverCard reads its presence from the page's document, so in another document it takes no request (FR8) and returns no focus there (FR9)"; please drop that sentence so it does not contradict the updated FR6.
  • The changeset and FR6 name Picture-in-Picture windows, but every new case runs in a same-origin iframe; add a Picture-in-Picture case to HoverCardSecondDocument.a11y.chromium.spec.ts (the Popover second-document spec already has the harness) or narrow the wording to iframes.

Optional: move ownerDocumentOf so the "Focus trigger behavior" doc comment stays on HoverCardFocusTrigger.

[Automated review]

Comment thread .changeset/hovercard-second-document.md Outdated
'@astryxdesign/core': patch
---

[fix] A `HoverCard` rendered into another document, such as an iframe or a Picture-in-Picture window, now closes when Escape is pressed there, and Escape from inside the card returns focus to its trigger. Moving focus from the trigger into such a card also keeps it open. Before, the card read only the page's document, so Escape in the other document never closed it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This names Picture-in-Picture windows, but the jsdom cases and the Chromium spec only run in a same-origin iframe. Either add a Picture-in-Picture case to HoverCardSecondDocument.a11y.chromium.spec.ts (the Popover second-document spec opens one) or narrow this to "a same-origin iframe".

Comment on lines 62 to 77
/**
* Focus trigger behavior for hover cards
*/
/**
* The document the card lives in: its trigger's, which may be an iframe or a
* Picture-in-Picture window. This page's before a trigger attaches; null with
* no DOM.
*/
function ownerDocumentOf(el: HTMLElement | null): Document | null {
if (el != null) {
return el.ownerDocument;
}
return typeof document === 'undefined' ? null : document;
}

export type HoverCardFocusTrigger = 'auto' | 'always' | 'never';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: inserting the helper here leaves the existing "Focus trigger behavior for hover cards" JSDoc on ownerDocumentOf, so the exported HoverCardFocusTrigger loses its doc comment. Move the helper above line 62 or below the type. Its own comment can keep only the reason: "The card lives in its trigger's document. That can be an iframe or a Picture-in-Picture window."

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

…ent; add the Picture-in-Picture case

CodeBunny on #9592: the family record's Tooltip/HoverCard row still said
HoverCard reads its presence from the page's document; that sentence goes,
and the FR8/FR9 evidence row names HoverCard's second-document checks. The
story gains a Pop out button that portals the card into a Document
Picture-in-Picture window, and the Chromium spec checks Escape and the focus
return there too, so the changeset's Picture-in-Picture claim has a test.
The ownerDocumentOf helper moves above HoverCardFocusTrigger's doc comment,
and the readiness count drops to seven gaps.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec b3b8e9f

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the hook fix works in both an iframe and a Picture-in-Picture window, but the new Picture-in-Picture Chromium case cannot pass as written.

  • In HoverCardSecondDocument.a11y.chromium.spec.ts, the second trigger.hover() (line 97) lands where the pointer already rests, so no mouseenter fires and line 98 times out; adding await pictureInPicture.mouse.move(0, 0); before it makes all three cases pass.

Optional: the change also lets a card whose trigger sits inside a raw useFocusTrap container take Escape before that trap in the page, which deserves a line in the changeset, and ownerDocumentOf's comment can keep only its reason.

[Automated review]

await pictureInPicture.keyboard.press('Escape');
await expect(message).toBeHidden();

await trigger.hover();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The pointer already rests on the trigger from line 91, so this hover fires no mouseenter, and the card Escape just dismissed never reopens; line 98 times out on every run. Move the pointer off first:

await pictureInPicture.mouse.move(0, 0);
await trigger.hover();

With that line all three cases pass.

Comment on lines +62 to +66
/**
* The document the card lives in: its trigger's, which may be an iframe or a
* Picture-in-Picture window. This page's before a trigger attaches; null with
* no DOM.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the second sentence restates the two branches below. Keeping only the reason: "The card lives in its trigger's document. That can be an iframe or a Picture-in-Picture window."

…e hovering it again

CodeBunny on #9592: the second hover landed where the pointer already
rested, so no mouseenter fired; the case moves the pointer away first. The
changeset notes that a card inside a focus-trap container now takes Escape
before it, and ownerDocumentOf's comment keeps only its reason.
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec e53260c

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: the second-document fix works in an iframe and a Picture-in-Picture window (the new jsdom cases fail on main and pass here, and all three Chromium cases pass), but two things need fixing first.

  • A useHoverCard trigger rendered after the hook mounts, inside a second document, leaves the card registered in the page, so Escape pressed in the page closes that card and Escape in its own document does nothing; register once the trigger exists, in its document, and add a case for it.
  • The changeset says a card inside a focus-trap container now takes Escape first, but no test covers it and the description still says nothing changes in the page, so add a case and update the description.

[Automated review]

isActive: true,
// The stack listens for Escape in the trigger's own document, which may be
// an iframe or a Picture-in-Picture window rather than this page.
getContainer: () => triggerRef.current,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The stack reads this once, when the hook registers on mount, and keeps that document. A useHoverCard trigger rendered in a later commit inside an iframe leaves the card registered in the page. isPresent below now finds the card in the frame, so Escape pressed in the page closes it, and Escape pressed in the frame does nothing. Register only once the trigger is attached, for example by gating isActive on the trigger's document held in state from interactionRef, and add a case with a late trigger.

This branch was successfully deployed

1 active deployment
Preview — e53260c9 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant