diff --git a/.changeset/hovercard-second-document.md b/.changeset/hovercard-second-document.md
new file mode 100644
index 0000000000000..c8be5013b5223
--- /dev/null
+++ b/.changeset/hovercard-second-document.md
@@ -0,0 +1,7 @@
+---
+'@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. The card now also ranks on the shared dismissal stack by its trigger's place, so a card whose trigger sits inside a focus-trap container takes Escape before that container, as the topmost layer.
+
+@cixzhang @duytbp
diff --git a/apps/storybook/stories/HoverCardSecondDocumentA11y.stories.tsx b/apps/storybook/stories/HoverCardSecondDocumentA11y.stories.tsx
new file mode 100644
index 0000000000000..818b1bcffcc91
--- /dev/null
+++ b/apps/storybook/stories/HoverCardSecondDocumentA11y.stories.tsx
@@ -0,0 +1,117 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file HoverCardSecondDocumentA11y.stories.tsx
+ * @input HoverCard portalled into a same-origin iframe and into a Document
+ * Picture-in-Picture window
+ * @output A fixture for the Chromium check that Escape and focus work in the
+ * card's own document; excluded from the visual matrix
+ * @position Browser fixture for component:HoverCard FR6 in a second document
+ */
+
+import {useCallback, useState, type SyntheticEvent} from 'react';
+import {createPortal} from 'react-dom';
+import type {Meta, StoryObj} from '@storybook/react';
+import {Button} from '@astryxdesign/core/Button';
+import {HoverCard} from '@astryxdesign/core/HoverCard';
+
+const meta: Meta = {
+ title: 'a11y/HoverCard in a second document',
+ tags: ['no-visual'],
+};
+
+export default meta;
+
+const FRAME_DOCUMENT = '
';
+
+/** Copy the page's style sheets into the frame, as an app does. */
+function adoptPageStyles(target: Document) {
+ for (const sheet of Array.from(document.styleSheets)) {
+ if (
+ sheet.href != null &&
+ new URL(sheet.href).origin !== window.location.origin
+ ) {
+ continue;
+ }
+ const style = target.createElement('style');
+ style.textContent = Array.from(sheet.cssRules, rule => rule.cssText).join(
+ '\n',
+ );
+ target.head.append(style);
+ }
+}
+
+function Card() {
+ return (
+ }
+ delay={0}
+ hideDelay={0}>
+
+
+ );
+}
+
+interface DocumentPictureInPicture {
+ requestWindow(options?: {width?: number; height?: number}): Promise;
+}
+
+/** The card again, portalled into a Document Picture-in-Picture window. */
+function PictureInPictureCard() {
+ const [body, setBody] = useState(null);
+ const pictureInPicture = (
+ window as Window & {documentPictureInPicture?: DocumentPictureInPicture}
+ ).documentPictureInPicture;
+ const popOut = useCallback(async () => {
+ if (pictureInPicture == null) {
+ return;
+ }
+ // Opening the window needs the user gesture of this click.
+ const pipWindow = await pictureInPicture.requestWindow({
+ width: 420,
+ height: 260,
+ });
+ adoptPageStyles(pipWindow.document);
+ pipWindow.addEventListener('pagehide', () => setBody(null));
+ setBody(pipWindow.document.body);
+ }, [pictureInPicture]);
+ return (
+ <>
+
+ {body != null && createPortal(, body)}
+ >
+ );
+}
+
+export const SecondDocument: StoryObj = {
+ render: function SecondDocumentStory() {
+ const [body, setBody] = useState(null);
+ const handleLoad = useCallback(
+ (event: SyntheticEvent) => {
+ const frameDocument = event.currentTarget.contentDocument;
+ if (frameDocument != null) {
+ adoptPageStyles(frameDocument);
+ setBody(frameDocument.body);
+ }
+ },
+ [],
+ );
+ return (
+ <>
+
+ {body != null && createPortal(, body)}
+
+ >
+ );
+ },
+};
diff --git a/docs/families/overlay-dismissal.md b/docs/families/overlay-dismissal.md
index 6711a65a287cf..b4fac7cc9e9de 100644
--- a/docs/families/overlay-dismissal.md
+++ b/docs/families/overlay-dismissal.md
@@ -252,14 +252,14 @@ this record's current membership snapshot must be updated with it.
## Adoption and exceptions
-| Components or surface | Adoption | Current deviation or limitation |
-| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Dialog, AlertDialog, Popover, MobileNav, Drawer, CommandPalette | shared owner; CommandPalette composes Dialog | none |
-| Tooltip, HoverCard | shared owner with DOM presence reporting | Neither provides nesting depth to descendant layers. Implementation gap: a Tooltip consumes Escape when it is the topmost layer, so the layer beneath it stays open for that press, and a Tooltip in another document is never closed by Escape (`component:Tooltip` FR6); FR8 gives that press to the layer beneath and closes a lone Tooltip in its own document. A Tooltip or HoverCard pinned open by a press takes Escape first (`spec:AST-003` FR4, DEC-6); implementation gap: the pin is not built (`component:Tooltip` FR9, `component:HoverCard` FR9). HoverCard reads its presence from the page's document, so in another document it takes no request (FR8) and returns no focus there (FR9) |
-| Focus traps with `onEscape` | shared owner through `useFocusTrap` | They provide DOM containment, not descendant depth |
-| BreadcrumbItem, ChatComposerInput, ComplexSelector, DateInput, DateRangeInput, DateTimeInput, Selector, MultiSelector, PowerSearch, BaseTypeahead, Typeahead, Tokenizer, SideNavHeading, SideNavItem, TabMenu, TopNavHeading, TopNavMenu, TopNavMegaMenu, Table, Lab TourStep, Lab ChatEmojiPicker | shared owner through `usePopover` or a composed Popover owner | Table filtering owns controlled Popover state and discards its draft on close; TourStep routes Popover close to the Tour; ChatEmojiPicker owns controlled Popover state |
-| DropdownMenu root, MoreMenu, ContextMenu, Selector, MultiSelector, TopNavHeading, TopNavMenu, TopNavMegaMenu, SideNavHeading, SideNavItem, BottomSheet, BottomSheetSwitcher, Lightbox | shared owner | Implementation gap: in another document (for BottomSheet, BottomSheetSwitcher and Lightbox, a Picture-in-Picture window), each decides focus return from the page's document and last input, so focus returns to the frame element or the page, or without the ring the last input in its document calls for (FR9) |
-| DropdownMenuSubMenu, PowerSearchEditPopover | local only | Must migrate from component-specific listeners or registries to the shared owner |
+| Components or surface | Adoption | Current deviation or limitation |
+| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Dialog, AlertDialog, Popover, MobileNav, Drawer, CommandPalette | shared owner; CommandPalette composes Dialog | none |
+| Tooltip, HoverCard | shared owner with DOM presence reporting | Neither provides nesting depth to descendant layers. Implementation gap: a Tooltip consumes Escape when it is the topmost layer, so the layer beneath it stays open for that press, and a Tooltip in another document is never closed by Escape (`component:Tooltip` FR6); FR8 gives that press to the layer beneath and closes a lone Tooltip in its own document. A Tooltip or HoverCard pinned open by a press takes Escape first (`spec:AST-003` FR4, DEC-6); implementation gap: the pin is not built (`component:Tooltip` FR9, `component:HoverCard` FR9) |
+| Focus traps with `onEscape` | shared owner through `useFocusTrap` | They provide DOM containment, not descendant depth |
+| BreadcrumbItem, ChatComposerInput, ComplexSelector, DateInput, DateRangeInput, DateTimeInput, Selector, MultiSelector, PowerSearch, BaseTypeahead, Typeahead, Tokenizer, SideNavHeading, SideNavItem, TabMenu, TopNavHeading, TopNavMenu, TopNavMegaMenu, Table, Lab TourStep, Lab ChatEmojiPicker | shared owner through `usePopover` or a composed Popover owner | Table filtering owns controlled Popover state and discards its draft on close; TourStep routes Popover close to the Tour; ChatEmojiPicker owns controlled Popover state |
+| DropdownMenu root, MoreMenu, ContextMenu, Selector, MultiSelector, TopNavHeading, TopNavMenu, TopNavMegaMenu, SideNavHeading, SideNavItem, BottomSheet, BottomSheetSwitcher, Lightbox | shared owner | Implementation gap: in another document (for BottomSheet, BottomSheetSwitcher and Lightbox, a Picture-in-Picture window), each decides focus return from the page's document and last input, so focus returns to the frame element or the page, or without the ring the last input in its document calls for (FR9) |
+| DropdownMenuSubMenu, PowerSearchEditPopover | local only | Must migrate from component-specific listeners or registries to the shared owner |
The rows marked partial or local are current adoption gaps against FR1, and the
rows naming an implementation gap are gaps against FR8 or FR9. They are not
@@ -279,7 +279,7 @@ modality, positioning, outside-dismissal, and open-state contracts.
| FR8 | `useLayerDismissal.test.tsx`; `Popover.test.tsx` | members in the page and a same-origin iframe; a Popover alone and inside a Dialog, in an iframe | A press in one document dismisses a member of another, or a host closes over its nested layer |
| FR8 | `PopoverSecondDocument.a11y.chromium.spec.ts` | a Popover in an iframe and in a Picture-in-Picture window, alone and with one open in the page | A press in the second document misses its own layer or dismisses the page's |
| FR9 | `DialogSecondDocument.a11y.browser.spec.ts` | a Dialog in an iframe and in a Picture-in-Picture window, closed by Escape or a control inside it, and a Dialog in an iframe opened from the page | Focus lands outside the opener's document, or its ring does not follow the closing input |
-| FR8, FR9 | Real-browser second-document checks; implementation gap: only Dialog and Popover have them | Dialog, Popover, DropdownMenu, MoreMenu, ContextMenu, Selector, MultiSelector, TopNavHeading, TopNavMenu, TopNavMegaMenu, SideNavHeading, SideNavItem, Tooltip and HoverCard, each in a same-origin iframe and in a Picture-in-Picture window: opens and takes Escape in the document it renders in (an unpinned Tooltip only when no other member is present; a pinned Tooltip or HoverCard before the member beneath it), and returns focus in the opener's document | The member opens or ranks in another document, a pinned Tooltip lets Escape reach the member beneath it, or focus lands on the frame element, the page, or the body |
+| FR8, FR9 | Real-browser second-document checks; implementation gap: only Dialog, Popover and HoverCard have them | Dialog, Popover, DropdownMenu, MoreMenu, ContextMenu, Selector, MultiSelector, TopNavHeading, TopNavMenu, TopNavMegaMenu, SideNavHeading, SideNavItem, Tooltip and HoverCard, each in a same-origin iframe and in a Picture-in-Picture window: opens and takes Escape in the document it renders in (an unpinned Tooltip only when no other member is present; a pinned Tooltip or HoverCard before the member beneath it), and returns focus in the opener's document | The member opens or ranks in another document, a pinned Tooltip lets Escape reach the member beneath it, or focus lands on the frame element, the page, or the body |
| FR8, FR9 | Real-browser second-document checks; implementation gap: none has them | BottomSheet, BottomSheetSwitcher, and Lightbox in a Picture-in-Picture window: opens, takes Escape, and returns focus to its return target there | The surface ranks in the page, or focus lands on the page or the body |
| FR8 | Source check over Drawer and MobileNav | Drawer and MobileNav rendered from a control in a second document | A page-level surface moves into the second document |
diff --git a/docs/specs/components/HoverCard/HoverCard.spec.md b/docs/specs/components/HoverCard/HoverCard.spec.md
index 0a1a18347905e..040ac7888f61c 100644
--- a/docs/specs/components/HoverCard/HoverCard.spec.md
+++ b/docs/specs/components/HoverCard/HoverCard.spec.md
@@ -45,7 +45,7 @@ system_specs: [spec:AST-003, spec:AST-017]
| Behavior | A floating card of rich, possibly interactive content anchored to a trigger. It opens on hover after `delay`, on focus, and on a qualifying tap, stays open while the pointer is on the trigger or card, and closes on leave, Escape, or a tap outside. With `label` it is a named dialog the trigger advertises; without, a group that describes the trigger. With `triggerBehavior="hover"`, touch never opens it. With `triggerBehavior="toggle"`, hover and focus preview it, and a click, tap, Enter, or Space on a button trigger pins it open until Escape, a second press, a press outside, or focus leaving. |
| End-user impact | Mouse, keyboard, and touch users can open the preview, move into it to use its actions, and dismiss it; screen-reader users hear it as a named popup dialog or as the trigger's description. With the toggle, a pinned card stays open while people read or use it, whatever the pointer does. |
| Builder impact | A trigger whose information lives only in the card passes `triggerBehavior="toggle"` and a button trigger (DEC-1). `touchTrigger="tap"` callers migrate by trigger kind: the deprecating release's codemod, an opt-in early migration when run explicitly, rewrites icon and span triggers and reports the rest (`component:Tooltip` DEP-0016), and `component:Tooltip` CLN-0024 names each remaining kind's replacement in 0.8.0, whose upgrade runs the codemod again. |
-| Compatibility/readiness | Additive `triggerBehavior`, default `adaptive`, the released behavior; `touchTrigger="tap"` deprecated and `touchTrigger` removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (`component:Tooltip` DEP-0016, CLN-0024). FR9, FR10, and AR3 are not yet built; eight further implementation gaps (see Current-state impact). |
+| Compatibility/readiness | Additive `triggerBehavior`, default `adaptive`, the released behavior; `touchTrigger="tap"` deprecated and `touchTrigger` removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (`component:Tooltip` DEP-0016, CLN-0024). FR9, FR10, and AR3 are not yet built; seven further implementation gaps (see Current-state impact). |
| Review checks | Reject an unnamed `dialog`, a card that cannot be hovered or dismissed (WCAG 1.4.13), Escape in the page that also closes a layer underneath, or `aria-expanded` set on a trigger whose role does not support it, a pinned card that closes on pointer leave, or a non-button toggle trigger without the development warning. |
| Governing rules | WCAG 1.4.13, 2.1.1; `family:overlay-dismissal`; `spec:AST-003` FR4; `spec:AST-017` (deprecation); `architecture:public-component-api` INV9, INV21; `architecture:layer-runtime`; `architecture:interaction-modality`; `architecture:component-theming-surface`. |
@@ -119,10 +119,6 @@ Consumer migration instructions belong in consumer docs and release notes.
re-attach. A trigger element replaced after mount gets no popup attributes,
description, or hover and focus opening until one of those happens (FR2,
FR3).
-- **Escape cannot close a card in another document.** HoverCard listens for
- Escape only in the page's document, so a card rendered into an iframe or a
- Picture-in-Picture window with no layer beneath it is never closed by
- Escape. WCAG 1.4.13 requires such content to be dismissible (FR6).
- **The hold follows only the pointer.** The card stays open only while the
pointer is on the trigger or the card. A card opened by keyboard focus closes
once the pointer crosses the trigger and leaves, while the trigger keeps
@@ -170,7 +166,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR3 | With `label`, the card is `role="dialog"` named by `label`; the trigger gets `aria-haspopup="dialog"` and, only when its role supports it, `aria-expanded`, each replacing the trigger's own value while the card is wired, plus `aria-controls` naming the card while open, merged with the trigger's own; the trigger's own values return on cleanup. Without `label`, the card is `role="group"` and the trigger's `aria-describedby` gains the card's id. | Current source and tests |
| FR4 | On a hover-capable pointer, entering the trigger opens the card after `delay` ms; leaving the trigger or the card closes it after `hideDelay` ms, and moving between them keeps it open. Any focus on the trigger, keyboard, pointer, or programmatic, opens it per `focusTrigger`, except the focus a tap leaves behind, the refocus after an Escape dismiss (FR6), and the refocus after a controlled close (FR7). Implementation gap: under `auto` the tab order is read only when the trigger's listeners attach, on mount and on a re-render that re-attaches them, so a trigger rendered with `tabindex="-1"` does not open on focus until such a re-render follows its move into the tab order (Current-state impact). | WCAG 1.4.13; tests |
| FR5 | On touch, a completed tap opens it per `triggerBehavior` when the finger lifts; a touch that becomes a scroll does not. Under `adaptive`, a tap that already acts leaves it shut, decided as `component:Tooltip` DEC-1 decides it: the innermost control from the tapped element outward, the trigger included, decides, and an `aria-disabled` control, or a label for a disabled control, is not an action (DEC-1). Until `component:Tooltip` CLN-0024 removes `touchTrigger`, an explicit `touchTrigger` decides instead under `adaptive`: `tap` opens on every tap and `none` on no tap. Implementation gap: the walk ships with `component:Tooltip` CLN-0024 in the next minor; until then `adaptive`, like the shipped `touchTrigger="auto"`, asks only whether the trigger itself performs an action, apart from a disabled control (Compatibility and migration). A tap on the card's own content keeps it open; a second tap on the trigger, a tap outside, or a scroll starting outside closes it. Under `hover`, no tap opens it. Under `toggle`, FR9 decides what a tap does. `hover` and `toggle` ignore `touchTrigger`. | Current source and tests |
-| FR6 | Escape closes a visible card through the shared dismissal stack when it is the topmost layer in the page, so a layer underneath stays open. Implementation gap: a card rendered into another document, such as an iframe or a Picture-in-Picture window, with no layer beneath it is never closed by Escape (Current-state impact). When focus was inside the card, it returns to the trigger, and that refocus does not reopen the card. | WCAG 1.4.13; tests |
+| FR6 | Escape closes a visible card through the shared dismissal stack when it is the topmost layer in its trigger's document, so a layer underneath stays open; a card rendered into another document, such as an iframe or a Picture-in-Picture window, takes the Escape pressed there. When focus was inside the card, it returns to the trigger, and that refocus does not reopen the card. | WCAG 1.4.13; tests |
| FR7 | `isOpen` forces the card shown or hidden, and `isOpen={true}` suppresses hover and focus hiding; a controlled card answers Escape by calling `onOpenChange(false)` without hiding, and setting `isOpen={false}` while focus is inside the card returns focus to the trigger. `isDefaultOpen` opens it on mount, still dismissible. Implementation gap: with `isOpen={false}`, focus on the trigger still opens the card, and Escape then only reports `onOpenChange(false)` (Current-state impact). `isEnabled={false}` disables the triggers. `onOpenChange` reports each show and hide. | Current source and tests |
| FR8 | Content renders only while open; server markup and the first client render match, including a default-open card. `hasHoverIndication` (`auto`: a single string or number child) adds a hover cue to the inline trigger. | Current source and tests |
| FR9 | With `triggerBehavior="toggle"` and a trigger whose role is `button` (a `}
+ onOpenChange={onOpenChange}
+ delay={0}
+ hideDelay={0}>
+ Trigger
+ ,
+ frameDocument.body,
+ ),
+ );
+ const trigger = button('Trigger')!;
+ fireEvent.mouseEnter(trigger);
+ await waitFor(() =>
+ expect(onOpenChange).toHaveBeenLastCalledWith(true),
+ );
+ trigger.focus();
+ button('Inside')!.focus();
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 50));
+ });
+ expect(onOpenChange).toHaveBeenLastCalledWith(true);
+ });
+
+ it('returns focus to the trigger when Escape closes the card from inside it', async () => {
+ const frameDocument = frame.contentDocument!;
+ // Testing Library's role queries cannot read the frame's popover, so
+ // find buttons by text.
+ const button = (text: string) =>
+ Array.from(frameDocument.querySelectorAll('button')).find(
+ candidate => candidate.textContent === text,
+ );
+ render(
+ createPortal(
+ Inside}
+ delay={0}
+ hideDelay={0}>
+ Trigger
+ ,
+ frameDocument.body,
+ ),
+ );
+ const trigger = button('Trigger')!;
+ fireEvent.mouseEnter(trigger);
+ await waitFor(() => expect(button('Inside')).toBeDefined());
+ const inside = button('Inside')!;
+ inside.focus();
+ // Compared as booleans: printing a frame's element fails.
+ expect(frameDocument.activeElement === inside).toBe(true);
+
+ fireEvent.keyDown(inside, {key: 'Escape'});
+ await waitFor(() =>
+ expect(frameDocument.activeElement === trigger).toBe(true),
+ );
+ });
+ });
+
it('hides hover card when Escape is pressed on trigger', async () => {
const onOpenChange = vi.fn();
// Reset the mock before this test
diff --git a/packages/core/src/HoverCard/__tests__/HoverCardSecondDocument.a11y.chromium.spec.ts b/packages/core/src/HoverCard/__tests__/HoverCardSecondDocument.a11y.chromium.spec.ts
new file mode 100644
index 0000000000000..75450ddf61c83
--- /dev/null
+++ b/packages/core/src/HoverCard/__tests__/HoverCardSecondDocument.a11y.chromium.spec.ts
@@ -0,0 +1,106 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+// @pr-a11y scope core/HoverCard ^apps/storybook/stories/HoverCardSecondDocumentA11y
+
+/**
+ * @file HoverCardSecondDocument.a11y.chromium.spec.ts
+ * @input The built a11y/HoverCard in a second document story; real Chromium
+ * keyboard and mouse input inside a same-origin iframe and a Document
+ * Picture-in-Picture window
+ * @output Browser evidence that a card in either document closes on Escape
+ * pressed there, and that Escape from inside the card returns focus to its trigger
+ * @position Chromium proof for component:HoverCard FR6 in a second document.
+ * The jsdom cases in HoverCard.test.tsx check the same paths against
+ * stand-in popovers.
+ */
+
+import {expect, test, type Page} from '@playwright/test';
+import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
+import {
+ DEFAULT_STORYBOOK_DIR,
+ serveStorybook,
+ type StaticServer,
+} from '@astryxdesign/a11y-spec/storybook';
+
+let storybook: StaticServer;
+test.beforeAll(async () => {
+ storybook = await serveStorybook(
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
+ );
+});
+test.afterAll(async () => {
+ await storybook?.close();
+});
+
+async function openFrame(page: Page) {
+ await page.goto(
+ `${storybook.origin}/iframe.html?id=a11y-hovercard-in-a-second-document--second-document&viewMode=story`,
+ );
+ await holdMotionStill(page);
+ const frame = page.frameLocator('iframe[title="Second document"]');
+ await expect(frame.getByRole('button', {name: 'Rowan'})).toBeVisible();
+ // Keys go to the focused document: put it in the frame, where a user who
+ // works there has it.
+ const frameHandle = page.frame({url: /^about:srcdoc/});
+ if (frameHandle == null) {
+ throw new Error('the story has no srcdoc frame');
+ }
+ await frameHandle.evaluate(() => window.focus());
+ return frame;
+}
+
+test('Escape pressed in the frame closes the card there', async ({page}) => {
+ const frame = await openFrame(page);
+ await frame.getByRole('button', {name: 'Rowan'}).hover();
+ const message = frame.getByRole('button', {name: 'Message'});
+ await expect(message).toBeVisible();
+
+ await page.keyboard.press('Escape');
+ await expect(message).toBeHidden();
+});
+
+test('Escape from inside the card returns focus to its trigger', async ({
+ page,
+}) => {
+ const frame = await openFrame(page);
+ const trigger = frame.getByRole('button', {name: 'Rowan'});
+ await trigger.hover();
+ const message = frame.getByRole('button', {name: 'Message'});
+ await expect(message).toBeVisible();
+ await message.focus();
+
+ await page.keyboard.press('Escape');
+ await expect(message).toBeHidden();
+ await expect(trigger).toBeFocused();
+});
+
+test('Escape pressed in a Picture-in-Picture window closes the card there, and focus returns to its trigger', async ({
+ page,
+}) => {
+ await page.goto(
+ `${storybook.origin}/iframe.html?id=a11y-hovercard-in-a-second-document--second-document&viewMode=story`,
+ );
+ await holdMotionStill(page);
+ // Opening the window needs a user gesture; the click is one.
+ const [pictureInPicture] = await Promise.all([
+ page.context().waitForEvent('page'),
+ page.getByRole('button', {name: 'Pop out'}).click(),
+ ]);
+ const trigger = pictureInPicture.getByRole('button', {name: 'Rowan'});
+ await expect(trigger).toBeVisible();
+
+ await trigger.hover();
+ const message = pictureInPicture.getByRole('button', {name: 'Message'});
+ await expect(message).toBeVisible();
+ await pictureInPicture.keyboard.press('Escape');
+ await expect(message).toBeHidden();
+
+ // Leave the trigger first: a hover where the pointer already rests fires no
+ // mouseenter.
+ await pictureInPicture.mouse.move(0, 0);
+ await trigger.hover();
+ await expect(message).toBeVisible();
+ await message.focus();
+ await pictureInPicture.keyboard.press('Escape');
+ await expect(message).toBeHidden();
+ await expect(trigger).toBeFocused();
+});
diff --git a/packages/core/src/HoverCard/useHoverCard.tsx b/packages/core/src/HoverCard/useHoverCard.tsx
index bce45cf4d2116..917d4e93064ec 100644
--- a/packages/core/src/HoverCard/useHoverCard.tsx
+++ b/packages/core/src/HoverCard/useHoverCard.tsx
@@ -59,6 +59,17 @@ const styles = stylex.create({
},
});
+/**
+ * The card lives in its trigger's document, which can be an iframe or a
+ * Picture-in-Picture window rather than this page.
+ */
+function ownerDocumentOf(el: HTMLElement | null): Document | null {
+ if (el != null) {
+ return el.ownerDocument;
+ }
+ return typeof document === 'undefined' ? null : document;
+}
+
/**
* Focus trigger behavior for hover cards
*/
@@ -449,7 +460,9 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
(e: FocusEvent) => {
// Check if focus is moving to the hover card content
const relatedTarget = e.relatedTarget as HTMLElement | null;
- const popoverElement = document.getElementById(layerId);
+ const popoverElement = ownerDocumentOf(
+ triggerRef.current,
+ )?.getElementById(layerId);
if (popoverElement?.contains(relatedTarget)) {
// Focus moving into hover card, keep it open
@@ -481,11 +494,11 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
// — it would silently eat Escapes meant for the dialog underneath — so
// presence is answered from the DOM at press time instead of from state.
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,
isPresent: () => {
- const el =
- typeof document === 'undefined'
- ? null
- : document.getElementById(layerId);
+ const el = ownerDocumentOf(triggerRef.current)?.getElementById(layerId);
if (el == null) {
return false;
}
@@ -514,11 +527,9 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
// guard unconditionally would swallow their next focus on the trigger,
// because a focus() on the already-focused trigger fires no focusin to
// clear it.
- const card =
- typeof document === 'undefined'
- ? null
- : document.getElementById(layerId);
- const hadFocus = card?.contains(document.activeElement) ?? false;
+ const doc = ownerDocumentOf(triggerRef.current);
+ const card = doc?.getElementById(layerId);
+ const hadFocus = card?.contains(doc?.activeElement ?? null) ?? false;
hide();
if (hadFocus) {
isEscapeDismissingRef.current = true;
@@ -619,11 +630,9 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
// A consumer closing the card while it holds focus would strand focus on
// . Arming the re-show guard is what keeps the refocus from
// reopening the card through `handleFocusIn`, which shows on any focusin.
- const card =
- typeof document === 'undefined'
- ? null
- : document.getElementById(layerId);
- const hadFocus = card?.contains(document.activeElement) ?? false;
+ const doc = ownerDocumentOf(triggerRef.current);
+ const card = doc?.getElementById(layerId);
+ const hadFocus = card?.contains(doc?.activeElement ?? null) ?? false;
hideLayer();
if (hadFocus) {
isEscapeDismissingRef.current = true;