mirror of
https://github.com/element-hq/element-call.git
synced 2026-09-30 22:08:02 +00:00
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:
co-authored by
Claude Fable 5.1
parent
fbce73ae55
commit
e296d88969
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ export default defineConfig((configEnv) =>
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"component/**/*.test.ts",
|
||||
"component/**/*.test.tsx",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user