merge: upstream Element Call v0.28.0 (cinny #201) #61
@@ -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