diff --git a/component/CallPortalRoot.test.tsx b/component/CallPortalRoot.test.tsx new file mode 100644 index 000000000..bfa23971b --- /dev/null +++ b/component/CallPortalRoot.test.tsx @@ -0,0 +1,57 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { Tooltip, TooltipProvider } from "@vector-im/compound-web"; + +import { CallPortalRoot } from "./CallPortalRoot"; + +// A button whose accessible name comes from its tooltip, as every icon button +// in the call does: the name is lost whenever the tooltip has nowhere to go. +const LabelledButton = (): ReactNode => ( + + + + + +); + +test("floats into the host document with no root", () => { + render( + + + , + ); + expect(screen.getByRole("button", { name: "Microphone" })).toBeTruthy(); +}); + +test("floats into the root it is given", () => { + const root = document.createElement("div"); + document.body.appendChild(root); + render( + + + , + ); + const button = screen.getByRole("button", { name: "Microphone" }); + const label = document.getElementById( + button.getAttribute("aria-labelledby")!, + ); + expect(root.contains(label)).toBe(true); + root.remove(); +}); + +test("waits for a root that is not available yet", () => { + render( + + + , + ); + expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull(); +}); diff --git a/component/CallPortalRoot.tsx b/component/CallPortalRoot.tsx new file mode 100644 index 000000000..281eb28c0 --- /dev/null +++ b/component/CallPortalRoot.tsx @@ -0,0 +1,26 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type FC, type ReactNode } from "react"; +import { PortalRoot } from "@vector-im/compound-web"; + +interface Props { + root: HTMLElement | null | undefined; + children: ReactNode; +} + +/** + * Where Compound floats the call's menus and tooltips. With no `root` the call + * is not wrapped at all and they go into the host document's body; `null` is + * Compound's "not available yet", and the floating parts wait for it. + */ +export const CallPortalRoot: FC = ({ root, children }): ReactNode => + root === undefined ? ( + children + ) : ( + {children} + ); diff --git a/component/api.ts b/component/api.ts index c9e7a2fd7..fa2928eb1 100644 --- a/component/api.ts +++ b/component/api.ts @@ -121,4 +121,18 @@ export interface ElementCallProps { * the most recently set language wins for all of them. */ language?: string; + /** + * Where the parts of Element Call that float above it — its menus and + * tooltips, which Compound portals out of the call — are rendered. Left out, + * they go into the host document's body, as they do for any other component + * on the host's page. + * + * A host that moves Element Call's DOM into another document, such as a + * Document Picture-in-Picture window, passes that document's body here so + * that a menu opens in the window its button is in. Changes take effect at + * once. `null` means the root is not available yet, as from a callback ref + * that has not fired: menus and tooltips wait for it rather than falling + * back to the body. + */ + portalRoot?: HTMLElement | null; } diff --git a/component/index.tsx b/component/index.tsx index 8f901c139..b3f7b6209 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -82,6 +82,7 @@ import { useTheme } from "../src/useTheme"; import { useStableValue } from "../src/useStableValue"; import styles from "./ElementCall.module.css"; import { useComponentHostBridge } from "./host"; +import { CallPortalRoot } from "./CallPortalRoot"; import { type ElementCallProps } from "./api"; import { supportedLanguages, translationsBackend } from "./localization"; @@ -154,6 +155,7 @@ export const ElementCall: FC = ({ ref, theme, language, + portalRoot, }): ReactNode => { const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme); @@ -240,34 +242,36 @@ export const ElementCall: FC = ({ // Stands in for the standalone page's `#root`: see `.content` // in ElementCall.module.css
- - {/* Whatever goes wrong in here is shown in here. Left to - propagate, an error would unmount the host's own tree. */} - } - // A broken call should not hold the host on screen - onError={() => void hostBridge.setAlwaysOnScreen(false)} - > - - - - - - - - - - - - - + + + {/* Whatever goes wrong in here is shown in here. Left to + propagate, an error would unmount the host's own tree. */} + } + // A broken call should not hold the host on screen + onError={() => void hostBridge.setAlwaysOnScreen(false)} + > + + + + + + + + + + + + + +
)} diff --git a/component/package.json b/component/package.json index 0d45eb4d7..9929b39bb 100644 --- a/component/package.json +++ b/component/package.json @@ -39,7 +39,7 @@ }, "peerDependencies": { "@vector-im/compound-design-tokens": "^11.0.0", - "@vector-im/compound-web": "^10.0.0", + "@vector-im/compound-web": "^10.3.1", "matrix-js-sdk": "*", "react": "^19", "react-dom": "^19" diff --git a/package.json b/package.json index b30f57ce3..3c7623366 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@typescript-eslint/utils": "^8.61.0", "@use-gesture/react": "^10.2.11", "@vector-im/compound-design-tokens": "^11.0.0", - "@vector-im/compound-web": "^10.0.0", + "@vector-im/compound-web": "^10.4.0", "@vitejs/plugin-react": "^6.0.2", "@vitest/browser-playwright": "^4.1.5", "@vitest/coverage-v8": "^4.0.18", diff --git a/playwright/component/audio-menu.spec.ts b/playwright/component/audio-menu.spec.ts index f4d811598..3276c28a7 100644 --- a/playwright/component/audio-menu.spec.ts +++ b/playwright/component/audio-menu.spec.ts @@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise { .getByRole("button", { name: "Microphone" }) .click({ timeout: 60_000 }); await expect(page.getByRole("menu")).toBeVisible(); - const list = page.locator("[role='menu'] div[role='none']").first(); + const list = page.getByRole("menu").getByTestId("device-list"); await expect(list).toBeVisible(); return list; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69805cfcd..da411cd4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,8 +134,8 @@ importers: specifier: ^11.0.0 version: 11.1.0(@types/react@19.2.18)(react@19.2.8) '@vector-im/compound-web': - specifier: ^10.0.0 - version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^10.4.0 + version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) @@ -3240,13 +3240,13 @@ packages: react: optional: true - '@vector-im/compound-web@10.1.0': - resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==} + '@vector-im/compound-web@10.4.0': + resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==} peerDependencies: '@fontsource/inconsolata': ^5 '@fontsource/inter': ^5 '@types/react': '*' - '@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0' + '@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0' react: ^18 || ^19.0.0 peerDependenciesMeta: '@types/react': @@ -8672,7 +8672,7 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) diff --git a/src/Modal.test.tsx b/src/Modal.test.tsx index 74670b6db..223bbbc8d 100644 --- a/src/Modal.test.tsx +++ b/src/Modal.test.tsx @@ -11,6 +11,7 @@ import { type ReactNode, useState } from "react"; import userEvent from "@testing-library/user-event"; import { Modal } from "./Modal"; +import { RootElementProvider } from "./RootElementContext"; const originalMatchMedia = window.matchMedia; afterEach(() => { @@ -70,3 +71,31 @@ test("the modal renders as a drawer in mobile viewports", () => { ); expect(queryByRole("dialog")).toMatchSnapshot(); }); + +test("the modal can be closed by clicking the backdrop, in another window too", async () => { + // A dialog moved into another window, as a host does for a Document + // Picture-in-Picture window: its nodes are not `Node`s of this window + const frame = document.createElement("iframe"); + document.body.appendChild(frame); + const otherBody = frame.contentDocument!.body; + + function ModalFn(): ReactNode { + const [isOpen, setOpen] = useState(true); + return ( + + setOpen(false)}> +

This is the content.

+
+
+ ); + } + render(); + const dialog = otherBody.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + const backdrop = dialog!.previousElementSibling as HTMLElement; + + const user = userEvent.setup({ document: frame.contentDocument! }); + await user.click(backdrop); + expect(otherBody.querySelector('[role="dialog"]')).toBeNull(); + frame.remove(); +}); diff --git a/src/Modal.tsx b/src/Modal.tsx index 46316d370..01b2c76bc 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -160,6 +160,12 @@ export const Modal: FC = ({ onDismiss?.()} /> renders 1`] = ` tabindex="0" >
renders with title and subtitle 1`] = ` tabindex="0" >
= ({ // Keeps the items the menu's own children for assistive tech. role="none" className={styles.deviceList} + data-testid="device-list" style={ { "--device-list-max-height": diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index b7f8b3fe3..fd0ff4661 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -181,7 +181,7 @@ exports[`InCallView > rendering > renders 1`] = ` tabindex="0" >
renders with AppBar android 1`] = ` tabindex="0" >
renders with AppBar android 1`] = ` tabindex="0" >
renders with AppBar ios 1`] = ` tabindex="0" >
renders with AppBar ios 1`] = ` tabindex="0" >
renders with header and participant count 1`] = ` tabindex="0" >