From e296d889694e69beb99e64ac12a0754f46d07b18 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 29 Sep 2026 12:08:44 +0200 Subject: [PATCH] Keep the component's tooltips inside its root Compound's Tooltip floats its bubble through floating-ui's FloatingPortal, into the body: outside the element the component build confines its stylesheet to, so a label tooltip shows as bare text at all times. An enclosing FloatingPortal rooted at the container makes the nested portals land inside it instead. The call goes in an isolating wrapper there, standing in for the standalone page's #root, so that its footer layer no longer paints above the modals. Co-Authored-By: Claude Fable 5.1 --- component/ElementCall.module.css | 33 +++++++++++++++--- component/PortalRoot.test.tsx | 43 ++++++++++++++++++++++++ component/PortalRoot.tsx | 44 ++++++++++++++++++++++++ component/index.tsx | 57 +++++++++++++++++--------------- package.json | 1 + pnpm-lock.yaml | 3 ++ vitest.config.ts | 1 + 7 files changed, 150 insertions(+), 32 deletions(-) create mode 100644 component/PortalRoot.test.tsx create mode 100644 component/PortalRoot.tsx 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", ], }, },