mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-13 23:35:51 +00:00
Merge pull request #4338 from element-hq/toger5/compound-portal-root
[ElementCallComponent] Use compound-web's PortalRoot in EC.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+32
-28
@@ -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<ElementCallProps> = ({
|
||||
ref,
|
||||
theme,
|
||||
language,
|
||||
portalRoot,
|
||||
}): ReactNode => {
|
||||
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
|
||||
|
||||
@@ -240,34 +242,36 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
// Stands in for the standalone page's `#root`: see `.content`
|
||||
// in ElementCall.module.css
|
||||
<div className={styles.content}>
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
propagate, an error would unmount the host's own tree. */}
|
||||
<ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
// A broken call should not hold the host on screen
|
||||
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
<CallPortalRoot root={portalRoot}>
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
propagate, an error would unmount the host's own tree. */}
|
||||
<ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
// A broken call should not hold the host on screen
|
||||
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
</CallPortalRoot>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
|
||||
.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;
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<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();
|
||||
});
|
||||
|
||||
@@ -160,6 +160,12 @@ export const Modal: FC<Props> = ({
|
||||
<DialogPortal container={rootElement}>
|
||||
<DialogOverlay
|
||||
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
|
||||
asChild
|
||||
|
||||
@@ -15,7 +15,7 @@ exports[`AppBar > renders 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
@@ -58,7 +58,7 @@ exports[`AppBar > renders with title and subtitle 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -345,6 +345,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
// 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":
|
||||
|
||||
@@ -181,7 +181,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -15,7 +15,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
@@ -111,7 +111,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
@@ -247,7 +247,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
@@ -343,7 +343,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
@@ -621,7 +621,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
class="_indicator-icon_j097f_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user