Merge pull request #4306 from element-hq/toger5/fix-component-tooltips

[ElementCall Component] Make compound a peer dependency with EW. (fix tooltips in embedded mode)
This commit is contained in:
Timo
2026-10-01 13:57:58 +02:00
committed by GitHub
12 changed files with 133 additions and 72 deletions
+6 -4
View File
@@ -297,8 +297,8 @@ The package is not published yet. A host installs it as a git dependency on the
``` ```
whose `prepare` script runs the build on install. That build needs pnpm (via whose `prepare` script runs the build on install. That build needs pnpm (via
Corepack) on the host's machine, runs a full `pnpm install` of this repository Corepack) on the host's machine, runs a full `pnpm install` of this repository,
and is memory-hungry, since it inherits the `--max-old-space-size` setting of since it inherits the `--max-old-space-size` setting of
the app build; the host's pnpm also has to allow it to run at all the app build; the host's pnpm also has to allow it to run at all
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm (`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
project of its own for this reason, so pnpm commands run from inside that project of its own for this reason, so pnpm commands run from inside that
@@ -306,8 +306,10 @@ directory target it rather than the repository; run them from the repository
root. The host imports the component from root. The host imports the component from
`@element-hq/element-call-component` and the stylesheet from `@element-hq/element-call-component` and the stylesheet from
`@element-hq/element-call-component/style.css`, and has to provide `react`, `@element-hq/element-call-component/style.css`, and has to provide `react`,
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves `react-dom`, `matrix-js-sdk`, `livekit-client`, `@vector-im/compound-web` and
them external. `@vector-im/compound-design-tokens` itself, since the bundle leaves them
external. Compound's stylesheet are the host's to load too: the component shares
the host's copy of Compound, (required for example for tooltips)
The component is large, and a host will usually load it lazily, only once a The component is large, and a host will usually load it lazily, only once a
call is shown. Everything a host needs in order to talk about a call before call is shown. Everything a host needs in order to talk about a call before
+13 -5
View File
@@ -34,10 +34,18 @@ component rather than a page makes. */
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
/* Compound's overlay container, which holds tooltips and popovers, has to fill /* The call itself. It is a stacking context of its own, as the standalone
the container for the elements inside it to be positioned against it. The page's `#root` is, so that the layers the call view uses for its header, footer
standalone page does the same for the container under `#root`. */ and overlays stay below the modals and toasts, which are portalled into the
.root > [data-overlay-container] { root and carry no layer of their own.
position: relative;
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;
block-size: 100%; block-size: 100%;
position: relative;
isolation: isolate;
} }
+8 -7
View File
@@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
* Element Call as a component. * Element Call as a component.
* *
* As a page of its own, Element Call can style the document: normalize.css and * 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 * its own base styles speak of `html`, `body` and bare elements, and its custom
* declared on `:root`. As a component, all of that would land on the host's * properties are declared on `:root`. As a component, all of that would land on
* document too. This rewrites every selector so that it matches only the root * the host's document too. This rewrites every selector so that it matches only
* or its descendants: * the root or its descendants:
* *
* - `html`, `body` and `:root` become the root element, which is what stands in * - `html`, `body` and `:root` become the root element, which is what stands in
* for the document inside a host. * 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 * 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. * 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 * The root's custom properties are still inherited by everything inside it,
* it, the way they were from `body` and `:root`, and `@font-face` declarations * the way they were from `:root`. Compound's own stylesheet — the design
* stay global, which they are by nature. * 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 { export function scopeStylesToRoot(): Plugin {
return { return {
+10
View File
@@ -9,6 +9,16 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { logger } from "matrix-js-sdk/lib/logger"; 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 { type ConfigOptions, initializeElementCall } from "../index";
import { Harness } from "./Harness"; import { Harness } from "./Harness";
// After Element Call's, so that the host has the last word on its own page // After Element Call's, so that the host has the last word on its own page
+40 -30
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. * 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 // builds on. Written for a page, they speak of `html`, `body` and bare
// elements; the component build confines them, and every other stylesheet in // elements; the component build confines them, and every other stylesheet in
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so // this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
// that the host's document is left as it was. // 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 // 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 // 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 // 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 // component's own rules at equal specificity. It currently does not: what it
// declares unlayered is custom properties on Element Call's root, which // declares unlayered is custom properties on Element Call's root, which
// components inherit rather than compete with, and everything from Compound // components inherit rather than compete with.
// sits in a `@layer`, which loses to unlayered rules either way.
import "../src/base.css"; import "../src/base.css";
import { import {
@@ -231,34 +237,38 @@ export const ElementCall: FC<ElementCallProps> = ({
<UrlParamsProvider value={params}> <UrlParamsProvider value={params}>
<div ref={setContainer} className={styles.root}> <div ref={setContainer} className={styles.root}>
{ready && ( {ready && (
<RootElementProvider value={container}> // Stands in for the standalone page's `#root`: see `.content`
{/* Whatever goes wrong in here is shown in here. Left to // 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. */} propagate, an error would unmount the host's own tree. */}
<ErrorBoundary <ErrorBoundary
fallback={(error) => <ErrorPage error={error} />} fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen // A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)} onError={() => void hostBridge.setAlwaysOnScreen(false)}
> >
<Decoration> <Decoration>
<TooltipProvider> <TooltipProvider>
<ClientProvider client={client}> <ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}> <MediaDevicesContext value={mediaDevices}>
<ProcessorProvider> <ProcessorProvider>
<CallView <CallView
client={client} client={client}
rtcSession={rtcSession} rtcSession={rtcSession}
isPasswordlessUser={false} isPasswordlessUser={false}
confineToRoom={params.confineToRoom} confineToRoom={params.confineToRoom}
preload={params.preload} preload={params.preload}
skipLobby={params.skipLobby} skipLobby={params.skipLobby}
/> />
</ProcessorProvider> </ProcessorProvider>
</MediaDevicesContext> </MediaDevicesContext>
</ClientProvider> </ClientProvider>
</TooltipProvider> </TooltipProvider>
</Decoration> </Decoration>
</ErrorBoundary> </ErrorBoundary>
</RootElementProvider> </RootElementProvider>
</div>
)} )}
</div> </div>
</UrlParamsProvider> </UrlParamsProvider>
+2
View File
@@ -38,6 +38,8 @@
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component" "prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
}, },
"peerDependencies": { "peerDependencies": {
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-web": "^10.0.0",
"livekit-client": "^2.18.1", "livekit-client": "^2.18.1",
"matrix-js-sdk": "*", "matrix-js-sdk": "*",
"react": "^19", "react": "^19",
+4 -1
View File
@@ -76,7 +76,10 @@ Code that builds in only one is a bug.
- `build:component` — `@element-hq/element-call-component`, sources in `component/` - `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README; (its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` / `pnpm lint:externals` rejects an import of a `react` / `react-dom` /
`matrix-js-sdk` / `livekit-client` subpath the externals list omits. Two entry `matrix-js-sdk` / `livekit-client` / `@vector-im/compound-web` /
`@vector-im/compound-design-tokens` subpath the externals list omits.
Compound's stylesheets are loaded by `src/index.css` for the page and by the
host for the component, never by `src/base.css`. Two entry
points: `index.tsx` (the component) and `api.ts` (types, enums and points: `index.tsx` (the component) and `api.ts` (types, enums and
`configurationForIntent`, for a host that must not load the component). `api.ts` `configurationForIntent`, for a host that must not load the component). `api.ts`
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`. may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
+11 -3
View File
@@ -9,9 +9,11 @@ Please see LICENSE in the repository root for full details.
* Checks that the component build leaves the packages a host must supply to * Checks that the component build leaves the packages a host must supply to
* the host. * the host.
* *
* A host application already has React, the Matrix SDK and LiveKit, and a * A host application already has React, the Matrix SDK, LiveKit and Compound,
* second copy of any of them is worse than dead weight: React would hold two * and a second copy of any of them is worse than dead weight: React would hold
* sets of hooks, and the Matrix client would run two sync loops. So the * two sets of hooks, the Matrix client would run two sync loops, and a second
* Compound would style the tooltips it floats into the host's body with class
* names the host's stylesheet does not know. So the
* component build lists them as external — but that list has to name every * component build lists them as external — but that list has to name every
* subpath, since the bundler silently ignores the pattern and callback forms * subpath, since the bundler silently ignores the pattern and callback forms
* of the option, and an import it does not cover is bundled with no warning at * of the option, and an import it does not cover is bundled with no warning at
@@ -33,6 +35,9 @@ import { loadConfigFromFile } from "vite";
const CONFIG = "vite-component.config.ts"; const CONFIG = "vite-component.config.ts";
const SOURCES = ["src", "component"]; const SOURCES = ["src", "component"];
// The development harness is a host, not part of the component: it is the
// one that imports what a host supplies (Compound's stylesheets, say).
const EXCLUDED = ["component/dev"];
/** The packages whose duplication would break a host, rather than merely enlarge it. */ /** The packages whose duplication would break a host, rather than merely enlarge it. */
const MUST_BE_EXTERNAL = [ const MUST_BE_EXTERNAL = [
@@ -40,6 +45,8 @@ const MUST_BE_EXTERNAL = [
"react-dom", "react-dom",
"matrix-js-sdk", "matrix-js-sdk",
"livekit-client", "livekit-client",
"@vector-im/compound-web",
"@vector-im/compound-design-tokens",
]; ];
const isTestFile = (name) => const isTestFile = (name) =>
@@ -49,6 +56,7 @@ const isTestFile = (name) =>
async function* sourceFiles(dir) { async function* sourceFiles(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) { for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name); const path = join(dir, entry.name);
if (EXCLUDED.includes(path)) continue;
if (entry.isDirectory()) yield* sourceFiles(path); if (entry.isDirectory()) yield* sourceFiles(path);
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name)) else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
yield path; yield path;
+15 -19
View File
@@ -5,19 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
/* The styles Element Call needs wherever it is shown: the design tokens, fonts /* The styles Element Call needs wherever it is shown: the element defaults and
and element defaults its own stylesheets build on top of. custom properties its own stylesheets build on top of.
Split out from index.css so that Element Call as a component can have these Split out from index.css so that Element Call as a component can have these
without also being given the standalone page's layout, which would style the without also being given the standalone page's layout, which would style the
host's own document. What remains here still speaks of the host's own document. Compound's stylesheets — the design tokens, the component
document — normalize.css and the typography below use bare element selectors, styles and the fonts they use — are not here either: as a component, Element
and the custom properties are declared on `:root` — which is right for the Call shares the host's copy of Compound (it is a peer dependency, so that what
page, and is why the component build rewrites it: there every selector is Compound floats into the host's body, tooltips and menus, is styled by the
confined to Element Call's root element (see component/build/scopeStylesToRoot.ts), host's stylesheet), and index.css loads it for the page.
with `html`, `body` and `:root` becoming that element. Nothing needs to be
written differently here for that to work, but nothing here may rely on What remains here still speaks of the document — normalize.css and the
reaching the host's document either. typography below use bare element selectors, and the custom properties are
declared on `:root` — which is right for the page, and is why the component
build rewrites it: there every selector is confined to Element Call's root
element (see component/build/scopeStylesToRoot.ts), with `html`, `body` and
`:root` becoming that element. Nothing needs to be written differently here for
that to work, but nothing here may rely on reaching the host's document either.
Nothing here should depend on where it lands relative to Element Call's Nothing here should depend on where it lands relative to Element Call's
component stylesheets: the bundler decides that, and it decides differently for component stylesheets: the bundler decides that, and it decides differently for
@@ -25,16 +30,7 @@ the app and for the component build. */
@layer normalize, compound-legacy, compound; @layer normalize, compound-legacy, compound;
@import url("@fontsource/inter/400.css");
@import url("@fontsource/inter/500.css");
@import url("@fontsource/inter/600.css");
@import url("@fontsource/inter/700.css");
@import url("@fontsource/inconsolata/400.css");
@import url("@fontsource/inconsolata/700.css");
@import url("normalize.css/normalize.css") layer(normalize); @import url("normalize.css/normalize.css") layer(normalize);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
:root { :root {
--font-scale: 1; --font-scale: 1;
+15 -1
View File
@@ -7,10 +7,24 @@ Please see LICENSE in the repository root for full details.
/* Styles for Element Call as a page of its own. The parts that apply wherever /* Styles for Element Call as a page of its own. The parts that apply wherever
Element Call is shown live in base.css; these are about owning the document, Element Call is shown live in base.css; these are about owning the document,
and are not loaded when a host embeds Element Call as a component. */ and are not loaded when a host embeds Element Call as a component.
That includes Compound: the page brings the design tokens, the component styles
and their fonts, where a host embedding the component supplies its own. The
`compound` layers are ordered by base.css. */
@import url("./base.css"); @import url("./base.css");
@import url("@fontsource/inter/400.css");
@import url("@fontsource/inter/500.css");
@import url("@fontsource/inter/600.css");
@import url("@fontsource/inter/700.css");
@import url("@fontsource/inconsolata/400.css");
@import url("@fontsource/inconsolata/700.css");
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
body { body {
background-color: var(--cpd-color-bg-canvas-default); background-color: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-text-primary); color: var(--cpd-color-text-primary);
+8 -2
View File
@@ -65,8 +65,12 @@ export default defineConfig(({ mode }) => {
}, },
rollupOptions: { rollupOptions: {
// The host already has these, and a second copy of any of them does not // The host already has these, and a second copy of any of them does not
// merely bloat the bundle: React would hold two sets of hooks, and the // merely bloat the bundle: React would hold two sets of hooks, the
// Matrix client would run two sync loops. // Matrix client would run two sync loops, and a second Compound would
// bring a second stylesheet with class names of its own — so that what
// it floats into the host's body, tooltips and menus, would match the
// host's Compound stylesheet only when the two happened to be the same
// version.
// //
// Every subpath has to be named. Element Call reaches most of the Matrix // Every subpath has to be named. Element Call reaches most of the Matrix
// SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not
@@ -103,6 +107,8 @@ export default defineConfig(({ mode }) => {
"matrix-js-sdk/lib/sync", "matrix-js-sdk/lib/sync",
"matrix-js-sdk/lib/types", "matrix-js-sdk/lib/types",
"matrix-js-sdk/lib/utils", "matrix-js-sdk/lib/utils",
"@vector-im/compound-web",
"@vector-im/compound-design-tokens/assets/web/icons",
], ],
}, },
}, },
+1
View File
@@ -25,6 +25,7 @@ export default defineConfig((configEnv) =>
"src/**/*.test.ts", "src/**/*.test.ts",
"src/**/*.test.tsx", "src/**/*.test.tsx",
"component/**/*.test.ts", "component/**/*.test.ts",
"component/**/*.test.tsx",
], ],
}, },
}, },