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 726eb1f9c..fa2928eb1 100644 --- a/component/api.ts +++ b/component/api.ts @@ -130,7 +130,9 @@ export interface ElementCallProps { * 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. + * 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 9f7d7d49a..b3f7b6209 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 { PortalRoot, TooltipProvider } from "@vector-im/compound-web"; +import { 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"; @@ -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,7 +155,7 @@ export const ElementCall: FC = ({ ref, theme, language, - portalRoot = null, + portalRoot, }): ReactNode => { const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme); @@ -241,9 +242,7 @@ export const ElementCall: FC = ({ // Stands in for the standalone page's `#root`: see `.content` // in ElementCall.module.css
- {/* 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. */} @@ -272,7 +271,7 @@ export const ElementCall: FC = ({ - +
)} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6728743cd..9e4f0fd6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,9 +14,6 @@ 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