mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-13 23:35:51 +00:00
Stop bundling Compound, let the host supply it
This commit is contained in:
@@ -34,21 +34,14 @@ component rather than a page makes. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 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. 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.
|
||||
|
||||
/* 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. */
|
||||
What Compound floats — tooltips, menus — is not in here at all: it goes into
|
||||
the host's body, as it would for any other component on the host's page, and is
|
||||
styled and layered by the host's copy of Compound. */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -56,11 +49,3 @@ and below the tooltips. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
@@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* Element Call as a component.
|
||||
*
|
||||
* As a page of its own, Element Call can style the document: normalize.css and
|
||||
* Compound speak of `html`, `body` and bare elements, and the design tokens are
|
||||
* declared on `:root`. As a component, all of that would land on the host's
|
||||
* document too. This rewrites every selector so that it matches only the root
|
||||
* or its descendants:
|
||||
* its own base styles speak of `html`, `body` and bare elements, and its custom
|
||||
* properties are declared on `:root`. As a component, all of that would land on
|
||||
* the host's document too. This rewrites every selector so that it matches only
|
||||
* the root or its descendants:
|
||||
*
|
||||
* - `html`, `body` and `:root` become the root element, which is what stands in
|
||||
* for the document inside a host.
|
||||
@@ -55,9 +55,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* CSS modules are scoped by their class names already, so only their selectors
|
||||
* that would match by element alone — `pre` rather than `.pre` — are touched.
|
||||
*
|
||||
* The root's fonts and design tokens are still inherited by everything inside
|
||||
* it, the way they were from `body` and `:root`, and `@font-face` declarations
|
||||
* stay global, which they are by nature.
|
||||
* The root's custom properties are still inherited by everything inside it,
|
||||
* the way they were from `:root`. Compound's own stylesheet — the design
|
||||
* tokens, the component styles, the fonts — is not in this build at all: the
|
||||
* host supplies it, unscoped, along with its copy of Compound.
|
||||
*/
|
||||
export function scopeStylesToRoot(): Plugin {
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,16 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
// What a host provides along with its copy of Compound: its stylesheet, the
|
||||
// design tokens and the fonts they name. The component does not bundle them.
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "@fontsource/inconsolata/400.css";
|
||||
import "@fontsource/inconsolata/700.css";
|
||||
import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
|
||||
import "@vector-im/compound-web/dist/style.css";
|
||||
import { type ConfigOptions, initializeElementCall } from "../index";
|
||||
import { Harness } from "./Harness";
|
||||
// After Element Call's, so that the host has the last word on its own page
|
||||
|
||||
+13
-6
@@ -17,19 +17,25 @@ Please see LICENSE in the repository root for full details.
|
||||
* host instead, or is confined to the container it is mounted in.
|
||||
*/
|
||||
|
||||
// The design tokens, fonts and element defaults every Element Call stylesheet
|
||||
// The element defaults and custom properties every Element Call stylesheet
|
||||
// builds on. Written for a page, they speak of `html`, `body` and bare
|
||||
// elements; the component build confines them, and every other stylesheet in
|
||||
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
|
||||
// that the host's document is left as it was.
|
||||
//
|
||||
// Compound is not in here. The component uses the host's copy of it (a peer
|
||||
// dependency, left external by the build), and the host loads Compound's
|
||||
// stylesheet, design tokens and fonts along with it: what Compound floats into
|
||||
// the host's body — tooltips, menus — is then styled by the same stylesheet as
|
||||
// everything else there, and Element Call's root takes the tokens and fonts of
|
||||
// the theme class it carries, as any element on the host's page would.
|
||||
//
|
||||
// Where these land relative to the component stylesheets is the bundler's
|
||||
// choice — the standalone app puts them first, this build puts them in the
|
||||
// middle — so nothing in base.css may depend on winning or losing against a
|
||||
// component's own rules at equal specificity. It currently does not: what it
|
||||
// declares unlayered is custom properties on Element Call's root, which
|
||||
// components inherit rather than compete with, and everything from Compound
|
||||
// sits in a `@layer`, which loses to unlayered rules either way.
|
||||
// components inherit rather than compete with.
|
||||
import "../src/base.css";
|
||||
|
||||
import {
|
||||
@@ -57,7 +63,6 @@ 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,
|
||||
@@ -232,7 +237,9 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
<UrlParamsProvider value={params}>
|
||||
<div ref={setContainer} className={styles.root}>
|
||||
{ready && (
|
||||
<PortalRoot root={container}>
|
||||
// Stands in for the standalone page's `#root`: see `.content`
|
||||
// in ElementCall.module.css
|
||||
<div className={styles.content}>
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
propagate, an error would unmount the host's own tree. */}
|
||||
@@ -261,7 +268,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
</PortalRoot>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</UrlParamsProvider>
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"livekit-client": "^2.18.1",
|
||||
"matrix-js-sdk": "*",
|
||||
"react": "^19",
|
||||
|
||||
Reference in New Issue
Block a user