[ElementCallComponent] Use compound-web's PortalRoot in EC.

This commit is contained in:
Timo K.
2026-10-08 19:14:01 +02:00
parent 471c76c0e7
commit f322af2742
11 changed files with 109 additions and 45 deletions
+12
View File
@@ -121,4 +121,16 @@ 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.
*/
portalRoot?: HTMLElement | null;
} }
+34 -29
View File
@@ -50,7 +50,7 @@ import {
} from "react"; } from "react";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web"; import { PortalRoot, TooltipProvider } from "@vector-im/compound-web";
import { ErrorBoundary } from "@sentry/react"; import { ErrorBoundary } from "@sentry/react";
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js"; import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
@@ -154,6 +154,7 @@ export const ElementCall: FC<ElementCallProps> = ({
ref, ref,
theme, theme,
language, language,
portalRoot = null,
}): ReactNode => { }): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme); const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
@@ -240,34 +241,38 @@ 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}>
<RootElementProvider value={container}> {/* Where Compound floats the call's menus and tooltips: the
{/* Whatever goes wrong in here is shown in here. Left to host's body unless the host says otherwise (see `portalRoot`) */}
propagate, an error would unmount the host's own tree. */} <PortalRoot root={portalRoot}>
<ErrorBoundary <RootElementProvider value={container}>
fallback={(error) => <ErrorPage error={error} />} {/* Whatever goes wrong in here is shown in here. Left to
// A broken call should not hold the host on screen propagate, an error would unmount the host's own tree. */}
onError={() => void hostBridge.setAlwaysOnScreen(false)} <ErrorBoundary
> fallback={(error) => <ErrorPage error={error} />}
<Decoration> // A broken call should not hold the host on screen
<TooltipProvider> onError={() => void hostBridge.setAlwaysOnScreen(false)}
<ClientProvider client={client}> >
<MediaDevicesContext value={mediaDevices}> <Decoration>
<ProcessorProvider> <TooltipProvider>
<CallView <ClientProvider client={client}>
client={client} <MediaDevicesContext value={mediaDevices}>
rtcSession={rtcSession} <ProcessorProvider>
isPasswordlessUser={false} <CallView
confineToRoom={params.confineToRoom} client={client}
preload={params.preload} rtcSession={rtcSession}
skipLobby={params.skipLobby} isPasswordlessUser={false}
/> confineToRoom={params.confineToRoom}
</ProcessorProvider> preload={params.preload}
</MediaDevicesContext> skipLobby={params.skipLobby}
</ClientProvider> />
</TooltipProvider> </ProcessorProvider>
</Decoration> </MediaDevicesContext>
</ErrorBoundary> </ClientProvider>
</RootElementProvider> </TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
</PortalRoot>
</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": "github:element-hq/compound-web#abdd1657b4787952b19203c7ce81fda02822ac7f",
"@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",
+7 -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: github:element-hq/compound-web#abdd1657b4787952b19203c7ce81fda02822ac7f
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: https://codeload.github.com/element-hq/compound-web/tar.gz/abdd1657b4787952b19203c7ce81fda02822ac7f(@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,14 @@ packages:
react: react:
optional: true optional: true
'@vector-im/compound-web@10.1.0': '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/abdd1657b4787952b19203c7ce81fda02822ac7f':
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==} resolution: {gitHosted: true, integrity: sha512-HMN3hCJQyL7PS7ChreMN2OC2AFeA5og0vmSV86v727qWQP5He2cho8JGwoIhpe50awV6cMrp37XEIkVLqW5UaA==, tarball: https://codeload.github.com/element-hq/compound-web/tar.gz/abdd1657b4787952b19203c7ce81fda02822ac7f}
version: 10.3.1
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 +8673,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@https://codeload.github.com/element-hq/compound-web/tar.gz/abdd1657b4787952b19203c7ce81fda02822ac7f(@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)
+3
View File
@@ -14,6 +14,9 @@ allowBuilds:
"core-js": true "core-js": true
"esbuild": true "esbuild": true
"matrix-js-sdk@git+https://github.com/matrix-org/matrix-js-sdk.git": true "matrix-js-sdk@git+https://github.com/matrix-org/matrix-js-sdk.git": true
# Compound is a git dependency until PortalRoot (element-hq/compound-web#562) is released; its
# `prepare` script builds it on install. pnpm matches git-hosted packages by repository URL.
"@vector-im/compound-web@git+https://github.com/element-hq/compound-web.git": true
"protobufjs": true "protobufjs": true
overrides: overrides:
# We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but # We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but
+37
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,39 @@ 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 () => {
// The drawer test above leaves a touchscreen matchMedia behind; this is
// about the desktop dialog
window.matchMedia = (): MediaQueryList =>
({
matches: false,
addEventListener: (): void => {},
removeEventListener: (): void => {},
}) as unknown as MediaQueryList;
// 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
@@ -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