diff --git a/component/api.ts b/component/api.ts index c9e7a2fd7..726eb1f9c 100644 --- a/component/api.ts +++ b/component/api.ts @@ -121,4 +121,16 @@ 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. + */ + portalRoot?: HTMLElement | null; } diff --git a/component/index.tsx b/component/index.tsx index 8f901c139..9f7d7d49a 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -50,7 +50,7 @@ import { } from "react"; import { logger } from "matrix-js-sdk/lib/logger"; 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 { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js"; @@ -154,6 +154,7 @@ export const ElementCall: FC = ({ ref, theme, language, + portalRoot = null, }): ReactNode => { const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme); @@ -240,34 +241,38 @@ 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)} - > - - - - - - - - - - - - - + {/* Where Compound floats the call's menus and tooltips: the + host's body unless the host says otherwise (see `portalRoot`) */} + + + {/* 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..a74dca7fa 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": "github:element-hq/compound-web#abdd1657b4787952b19203c7ce81fda02822ac7f", "@vitejs/plugin-react": "^6.0.2", "@vitest/browser-playwright": "^4.1.5", "@vitest/coverage-v8": "^4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69805cfcd..cd18698ec 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: github:element-hq/compound-web#abdd1657b4787952b19203c7ce81fda02822ac7f + 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': 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,14 @@ packages: react: optional: true - '@vector-im/compound-web@10.1.0': - resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==} + '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/abdd1657b4787952b19203c7ce81fda02822ac7f': + resolution: {gitHosted: true, integrity: sha512-HMN3hCJQyL7PS7ChreMN2OC2AFeA5og0vmSV86v727qWQP5He2cho8JGwoIhpe50awV6cMrp37XEIkVLqW5UaA==, tarball: https://codeload.github.com/element-hq/compound-web/tar.gz/abdd1657b4787952b19203c7ce81fda02822ac7f} + version: 10.3.1 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 +8673,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@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: '@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/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e4f0fd6f..6728743cd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,9 @@ allowBuilds: "core-js": true "esbuild": 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 overrides: # We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but diff --git a/src/Modal.test.tsx b/src/Modal.test.tsx index 74670b6db..1c67bd4da 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,39 @@ 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 () => { + // 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 ( + + 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" >
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" >