Stop bundling Compound, let the host supply it

This commit is contained in:
Timo K.
2026-09-30 17:10:17 +02:00
parent 9140365251
commit 363a8feab2
15 changed files with 101 additions and 154 deletions
+7 -22
View File
@@ -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;
}
-43
View File
@@ -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"));
});
});
-44
View File
@@ -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>
);
+8 -7
View File
@@ -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 {
+10
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -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",