diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css index 91213e422..ee792a731 100644 --- a/component/ElementCall.module.css +++ b/component/ElementCall.module.css @@ -34,10 +34,33 @@ component rather than a page makes. */ -webkit-tap-highlight-color: transparent; } -/* Compound's overlay container, which holds tooltips and popovers, has to fill -the container for the elements inside it to be positioned against it. The -standalone page does the same for the container under `#root`. */ -.root > [data-overlay-container] { - position: relative; +/* floating-ui's portal node, which `PortalRoot` puts here to hold the call +along with the tooltips Compound floats over it. It fills the container, and +lays out its children as the container would. */ +.root > [data-floating-ui-portal] { + display: flex; + flex-direction: column; block-size: 100%; + position: relative; +} + +/* The call itself, in the wrapper `PortalRoot` gives it. It is a stacking +context of its own, as the standalone page's `#root` is, so that the layers +the call view uses for its header, footer and overlays stay below the modals +and toasts, which are portalled into the root and carry no layer of their own, +and below the tooltips. */ +.content { + display: flex; + flex-direction: column; + block-size: 100%; + position: relative; + isolation: isolate; +} + +/* The tooltips sit in portal nodes of their own, nested in ours. Raised, so +that a tooltip opened inside a modal shows above it: the modal is portalled +straight into the root and so comes later in the document than our node. */ +.root > [data-floating-ui-portal] > [data-floating-ui-portal] { + position: relative; + z-index: 1; } diff --git a/component/PortalRoot.test.tsx b/component/PortalRoot.test.tsx new file mode 100644 index 000000000..f5d133d83 --- /dev/null +++ b/component/PortalRoot.test.tsx @@ -0,0 +1,43 @@ +/* +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 { render, screen } from "@testing-library/react"; +import { Tooltip, TooltipProvider } from "@vector-im/compound-web"; +import { afterEach, describe, expect, test } from "vitest"; + +import { PortalRoot } from "./PortalRoot"; + +describe("PortalRoot", () => { + const root = document.createElement("div"); + document.body.appendChild(root); + afterEach(() => root.replaceChildren()); + + // A label tooltip is in the DOM from the start, hidden by the stylesheet until + // hovered, which is what makes it show as bare text when it lands outside the + // root + const tree = ( + + + + + + ); + + test("Compound's tooltips float into the root", () => { + render({tree}); + expect(root).toContainElement( + screen.getByRole("button", { name: "Mute microphone" }), + ); + expect(root).toContainElement(screen.getByText("Mute microphone")); + }); + + test("without it they float into the body", () => { + render(tree); + expect(root).not.toContainElement(screen.getByText("Mute microphone")); + expect(document.body).toContainElement(screen.getByText("Mute microphone")); + }); +}); diff --git a/component/PortalRoot.tsx b/component/PortalRoot.tsx new file mode 100644 index 000000000..20eeecb07 --- /dev/null +++ b/component/PortalRoot.tsx @@ -0,0 +1,44 @@ +/* +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 { FloatingPortal } from "@floating-ui/react"; +import { type FC, type ReactNode } from "react"; + +import styles from "./ElementCall.module.css"; + +/** + * Keeps what Compound floats over Element Call, its tooltips, inside Element + * Call's root. + * + * Compound's `Tooltip` renders its bubble through floating-ui's + * `FloatingPortal`, which appends to `document.body` unless it sits inside + * another `FloatingPortal`, whose node it then shares. Compound offers no way + * to name a container, so this is that enclosing portal: it puts its node + * inside `root` and renders the children into it, and every tooltip they open + * lands in the same node, inside the element the component's stylesheet is + * confined to. Outside it, a tooltip matches none of the component's rules and + * shows as bare text, permanently, since the rule that hides a label tooltip + * until it is hovered is one of them. + * + * The children go in a wrapper of their own, which stands in for the `#root` + * of the standalone page: it fills the node and is a stacking context, so + * that the call's own layers (its header and footer, its overlays) stay below + * the modals and toasts portalled into the root, and below the tooltips beside + * it. `ElementCall.module.css` lays both out. + * + * Nothing renders until `root` exists, just as nothing renders before Element + * Call's container does. The portal's tab order guards are for a focus + * manager, which nothing in Element Call puts here, so they are off. + */ +export const PortalRoot: FC<{ + root: HTMLElement | null; + children: ReactNode; +}> = ({ root, children }) => ( + +
{children}
+
+); diff --git a/component/index.tsx b/component/index.tsx index b3f04e00c..7ca7de306 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -59,6 +59,7 @@ import { ErrorPage } from "../src/FullScreenView"; import { ClientProvider } from "../src/ClientContext"; import { HostBridgeProvider } from "../src/HostBridge"; import { RootElementProvider, useRootElement } from "../src/RootElementContext"; +import { PortalRoot } from "./PortalRoot"; import { configurationForIntent, componentProperties, @@ -321,34 +322,36 @@ export const ElementCall: FC = ({
{ready && ( - - {/* 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. */} - } - // A broken call should not hold the host on screen - onError={() => void hostBridge.setAlwaysOnScreen(false)} - > - - - - - - - - - - - - - + } + // A broken call should not hold the host on screen + onError={() => void hostBridge.setAlwaysOnScreen(false)} + > + + + + + + + + + + + + + + )}
diff --git a/package.json b/package.json index 5e9137b80..dba83cead 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@codecov/vite-plugin": "^1.3.0", + "@floating-ui/react": "^0.27.0", "@fontsource/inconsolata": "^5.1.0", "@fontsource/inter": "^5.1.0", "@formatjs/intl-durationformat": "^0.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be07cd290..b34a0983d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@codecov/vite-plugin': specifier: ^1.3.0 version: 1.9.1(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)) + '@floating-ui/react': + specifier: ^0.27.0 + version: 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fontsource/inconsolata': specifier: ^5.1.0 version: 5.3.0 diff --git a/vitest.config.ts b/vitest.config.ts index d6f67f89a..0373c4af7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig((configEnv) => "src/**/*.test.ts", "src/**/*.test.tsx", "component/**/*.test.ts", + "component/**/*.test.tsx", ], }, },