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 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-09-29 12:08:44 +02:00
co-authored by Claude Fable 5.1
parent fbce73ae55
commit e296d88969
7 changed files with 150 additions and 32 deletions
+28 -5
View File
@@ -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;
}
+43
View File
@@ -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 = (
<TooltipProvider>
<Tooltip label="Mute microphone">
<button>mic</button>
</Tooltip>
</TooltipProvider>
);
test("Compound's tooltips float into the root", () => {
render(<PortalRoot root={root}>{tree}</PortalRoot>);
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"));
});
});
+44
View File
@@ -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 }) => (
<FloatingPortal root={root} preserveTabOrder={false}>
<div className={styles.content}>{children}</div>
</FloatingPortal>
);
+30 -27
View File
@@ -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<ElementCallProps> = ({
<UrlParamsProvider value={params}>
<div ref={setContainer} className={styles.root}>
{ready && (
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
<PortalRoot root={container}>
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
</PortalRoot>
)}
</div>
</UrlParamsProvider>
+1
View File
@@ -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",
+3
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@ export default defineConfig((configEnv) =>
"src/**/*.test.ts",
"src/**/*.test.tsx",
"component/**/*.test.ts",
"component/**/*.test.tsx",
],
},
},