Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/hovercard-second-document.md
Original file line number Diff line number Diff line change
@@ -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
117 changes: 117 additions & 0 deletions apps/storybook/stories/HoverCardSecondDocumentA11y.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = '<!doctype html><html><head></head><body></body></html>';

/** 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 (
<HoverCard
label="Profile"
content={<Button label="Message" />}
delay={0}
hideDelay={0}>
<Button label="Rowan" />
</HoverCard>
);
}

interface DocumentPictureInPicture {
requestWindow(options?: {width?: number; height?: number}): Promise<Window>;
}

/** The card again, portalled into a Document Picture-in-Picture window. */
function PictureInPictureCard() {
const [body, setBody] = useState<HTMLElement | null>(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 (
<>
<Button
label="Pop out"
isDisabled={pictureInPicture == null}
onClick={popOut}
/>
{body != null && createPortal(<Card />, body)}
</>
);
}

export const SecondDocument: StoryObj = {
render: function SecondDocumentStory() {
const [body, setBody] = useState<HTMLElement | null>(null);
const handleLoad = useCallback(
(event: SyntheticEvent<HTMLIFrameElement>) => {
const frameDocument = event.currentTarget.contentDocument;
if (frameDocument != null) {
adoptPageStyles(frameDocument);
setBody(frameDocument.body);
}
},
[],
);
return (
<>
<iframe
title="Second document"
srcDoc={FRAME_DOCUMENT}
onLoad={handleLoad}
style={{width: 420, height: 260, border: '1px solid'}}
/>
{body != null && createPortal(<Card />, body)}
<PictureInPictureCard />
</>
);
},
};
Loading