Merge pull request #4338 from element-hq/toger5/compound-portal-root

[ElementCallComponent] Use compound-web's PortalRoot in EC.
This commit is contained in:
Timo
2026-10-12 12:59:28 +02:00
committed by GitHub
14 changed files with 182 additions and 45 deletions
+57
View File
@@ -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 => (
<TooltipProvider>
<Tooltip label="Microphone">
<button>🎤</button>
</Tooltip>
</TooltipProvider>
);
test("floats into the host document with no root", () => {
render(
<CallPortalRoot root={undefined}>
<LabelledButton />
</CallPortalRoot>,
);
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(
<CallPortalRoot root={root}>
<LabelledButton />
</CallPortalRoot>,
);
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(
<CallPortalRoot root={null}>
<LabelledButton />
</CallPortalRoot>,
);
expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
});
+26
View File
@@ -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<Props> = ({ root, children }): ReactNode =>
root === undefined ? (
children
) : (
<PortalRoot root={root}>{children}</PortalRoot>
);
+14
View File
@@ -121,4 +121,18 @@ export interface ElementCallProps {
* the most recently set language wins for all of them. * the most recently set language wins for all of them.
*/ */
language?: string; 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;
} }
+4
View File
@@ -82,6 +82,7 @@ import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue"; import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css"; import styles from "./ElementCall.module.css";
import { useComponentHostBridge } from "./host"; import { useComponentHostBridge } from "./host";
import { CallPortalRoot } from "./CallPortalRoot";
import { type ElementCallProps } from "./api"; import { type ElementCallProps } from "./api";
import { supportedLanguages, translationsBackend } from "./localization"; import { supportedLanguages, translationsBackend } from "./localization";
@@ -154,6 +155,7 @@ export const ElementCall: FC<ElementCallProps> = ({
ref, ref,
theme, theme,
language, language,
portalRoot,
}): ReactNode => { }): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme); const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
@@ -240,6 +242,7 @@ export const ElementCall: FC<ElementCallProps> = ({
// Stands in for the standalone page's `#root`: see `.content` // Stands in for the standalone page's `#root`: see `.content`
// in ElementCall.module.css // in ElementCall.module.css
<div className={styles.content}> <div className={styles.content}>
<CallPortalRoot root={portalRoot}>
<RootElementProvider value={container}> <RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to {/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */} propagate, an error would unmount the host's own tree. */}
@@ -268,6 +271,7 @@ export const ElementCall: FC<ElementCallProps> = ({
</Decoration> </Decoration>
</ErrorBoundary> </ErrorBoundary>
</RootElementProvider> </RootElementProvider>
</CallPortalRoot>
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -39,7 +39,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@vector-im/compound-design-tokens": "^11.0.0", "@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": "*", "matrix-js-sdk": "*",
"react": "^19", "react": "^19",
"react-dom": "^19" "react-dom": "^19"
+1 -1
View File
@@ -84,7 +84,7 @@
"@typescript-eslint/utils": "^8.61.0", "@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^11.0.0", "@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", "@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5", "@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
+1 -1
View File
@@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
.getByRole("button", { name: "Microphone" }) .getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 }); .click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible(); 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(); await expect(list).toBeVisible();
return list; return list;
} }
+6 -6
View File
@@ -134,8 +134,8 @@ importers:
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.1.0(@types/react@19.2.18)(react@19.2.8) version: 11.1.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web': '@vector-im/compound-web':
specifier: ^10.0.0 specifier: ^10.4.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) 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': '@vitejs/plugin-react':
specifier: ^6.0.2 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)) 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: react:
optional: true optional: true
'@vector-im/compound-web@10.1.0': '@vector-im/compound-web@10.4.0':
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==} resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
peerDependencies: peerDependencies:
'@fontsource/inconsolata': ^5 '@fontsource/inconsolata': ^5
'@fontsource/inter': ^5 '@fontsource/inter': ^5
'@types/react': '*' '@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 react: ^18 || ^19.0.0
peerDependenciesMeta: peerDependenciesMeta:
'@types/react': '@types/react':
@@ -8672,7 +8672,7 @@ snapshots:
'@types/react': 19.2.18 '@types/react': 19.2.18
react: 19.2.8 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: dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@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) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+29
View File
@@ -11,6 +11,7 @@ import { type ReactNode, useState } from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { RootElementProvider } from "./RootElementContext";
const originalMatchMedia = window.matchMedia; const originalMatchMedia = window.matchMedia;
afterEach(() => { afterEach(() => {
@@ -70,3 +71,31 @@ test("the modal renders as a drawer in mobile viewports", () => {
); );
expect(queryByRole("dialog")).toMatchSnapshot(); 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 (
<RootElementProvider value={otherBody}>
<Modal title="My modal" open={isOpen} onDismiss={() => setOpen(false)}>
<p>This is the content.</p>
</Modal>
</RootElementProvider>
);
}
render(<ModalFn />);
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();
});
+6
View File
@@ -160,6 +160,12 @@ export const Modal: FC<Props> = ({
<DialogPortal container={rootElement}> <DialogPortal container={rootElement}>
<DialogOverlay <DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)} className={classNames(overlayStyles.bg, overlayStyles.animate)}
// Radix dismisses on a pointer down outside the dialog, but only
// for event targets that are a `Node` of the window it was loaded
// in. A host can move the dialog into another window (a Document
// Picture-in-Picture window), whose nodes fail that check, so the
// backdrop closes the dialog itself.
onClick={() => onDismiss?.()}
/> />
<DialogContent <DialogContent
asChild asChild
+2 -2
View File
@@ -15,7 +15,7 @@ exports[`AppBar > renders 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg
@@ -58,7 +58,7 @@ exports[`AppBar > renders with title and subtitle 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg
@@ -345,6 +345,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// Keeps the items the menu's own children for assistive tech. // Keeps the items the menu's own children for assistive tech.
role="none" role="none"
className={styles.deviceList} className={styles.deviceList}
data-testid="device-list"
style={ style={
{ {
"--device-list-max-height": "--device-list-max-height":
@@ -181,7 +181,7 @@ exports[`InCallView > rendering > renders 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg
@@ -15,7 +15,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg
@@ -111,7 +111,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg
@@ -247,7 +247,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg
@@ -343,7 +343,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg
@@ -621,7 +621,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
tabindex="0" tabindex="0"
> >
<div <div
class="_indicator-icon_147l5_17" class="_indicator-icon_j097f_17"
style="--cpd-icon-button-size: 100%;" style="--cpd-icon-button-size: 100%;"
> >
<svg <svg