mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-10 13:57:18 +00:00
- Compound 10.3.0 bounds a menu by the space it has and scrolls what doesn't fit, within a boundary the app passes. The menus pass the call area, 16px in from its sides, rather than measuring its width. - The 296px width is the menu's own style. Compound gives that style only to the dropdown, so menuIsDrawer, which copied its phone rule, goes. - Compound draws the menu's frame over its content, so nothing that scrolls can hide it in Safari: the list, headings and meter no longer keep a border width clear of it, and the checks that they did go. - The device list is Compound's scroll area, so it fades out at the foot with Compound's chevron while there is more below. - The microphone menu's meter sticks at that foot, with rows scrolling under it as before, so the list has no fade above it: Compound's is a mask fixed to the list's edge, while a bounce at the top carries the meter with the rows. The band under the meter is filled, or rows would show there. The edge fades from where the chevron starts, so a bounce fades the meter out before it reaches the chevron. A row reached by keyboard stays above the meter. - The list's height is still its share of the call. - The component harness leaves the host's stylesheets unscoped, Compound's among them, as a host does: scoped, they never reached the menu, which is portalled out of Element Call's root. - Its specs check only Element Call's own document styles on the host page, and find the device list within Compound's scroll area. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
/*
|
|
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 {
|
|
type AtRule,
|
|
type Container,
|
|
type Document,
|
|
type Plugin,
|
|
type Rule,
|
|
} from "postcss";
|
|
import selectorParser, {
|
|
type Node,
|
|
type Pseudo,
|
|
type Selector,
|
|
} from "postcss-selector-parser";
|
|
|
|
/**
|
|
* How the stylesheets find Element Call's root element. The attribute is put
|
|
* there by `useTheme`, on the container the host gives the component.
|
|
*/
|
|
export const ROOT_SELECTOR = "[data-element-call-root]";
|
|
|
|
// Both are `:where()`, which has no specificity of its own, so the rules keep
|
|
// exactly the weight they had before being scoped and nothing in Element Call's
|
|
// cascade changes — only where it applies.
|
|
//
|
|
// The root, or anything inside it. Appended to the element a rule is about,
|
|
// rather than prepended to the whole selector, so that a rule about the root
|
|
// itself (its theme class, say) still matches.
|
|
const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
|
|
// The root itself, standing in for the document.
|
|
const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
|
|
|
/**
|
|
* Confines a stylesheet to Element Call's root element, for the build of
|
|
* Element Call as a component.
|
|
*
|
|
* As a page of its own, Element Call can style the document: normalize.css and
|
|
* 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.
|
|
* - Everything else keeps its selector and gains `:where([data-element-call-root],
|
|
* [data-element-call-root] *)` on the element it styles.
|
|
* - Selectors that already name the root are left alone, as are keyframe
|
|
* selectors and rules nested inside another rule, which are relative to it.
|
|
*
|
|
* 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 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.
|
|
*/
|
|
interface Options {
|
|
/** Stylesheets left as they are: a host's own, which it doesn't scope. */
|
|
exclude?: RegExp[];
|
|
}
|
|
|
|
export function scopeStylesToRoot({ exclude = [] }: Options = {}): Plugin {
|
|
return {
|
|
postcssPlugin: "element-call-scope-styles-to-root",
|
|
Once(root) {
|
|
const file = root.source?.input.file ?? "";
|
|
if (exclude.some((pattern) => pattern.test(file))) return;
|
|
const isModule = file.endsWith(".module.css");
|
|
root.walkRules((rule) => {
|
|
if (isRelative(rule)) return;
|
|
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
|
rule.selector,
|
|
{ lossless: false },
|
|
);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Whether a rule's selectors are relative to something other than the document. */
|
|
function isRelative(rule: Rule): boolean {
|
|
let parent: Container | Document | undefined = rule.parent;
|
|
while (parent !== undefined) {
|
|
if (parent.type === "rule") return true;
|
|
if (parent.type === "atrule") {
|
|
const { name } = parent as AtRule;
|
|
if (name.endsWith("keyframes") || name === "page") return true;
|
|
}
|
|
parent = parent.parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const processor = (isModule: boolean): ReturnType<typeof selectorParser> =>
|
|
selectorParser((selectors) => {
|
|
selectors.each((selector) => {
|
|
scopeSelector(selector, isModule);
|
|
});
|
|
// Mapping `html, body` onto the root leaves the same selector twice
|
|
const seen = new Set<string>();
|
|
selectors.each((selector) => {
|
|
const text = String(selector).trim();
|
|
if (seen.has(text)) selector.remove();
|
|
else seen.add(text);
|
|
});
|
|
});
|
|
|
|
// Everything, for stylesheets that speak of the document; only what a class
|
|
// does not already confine, for CSS modules
|
|
const scopeAll = processor(false);
|
|
const scopeBare = processor(true);
|
|
|
|
function scopeSelector(selector: Selector, isModule: boolean): void {
|
|
if (String(selector).includes(ROOT_SELECTOR)) return;
|
|
|
|
const compounds = splitCompounds(selector);
|
|
if (compounds.length === 0) return;
|
|
|
|
// Something said of the document is said of the root instead
|
|
const document = compounds[0].find(isDocumentSelector);
|
|
if (document !== undefined) {
|
|
document.replaceWith(pseudo(IS_ROOT));
|
|
return;
|
|
}
|
|
|
|
const subject = compounds.at(-1)!;
|
|
if (isModule && subject.some((node) => node.type === "class")) return;
|
|
|
|
// Pseudo-elements have to come last in a compound selector
|
|
const pseudoElement = subject.find(isPseudoElement);
|
|
if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT));
|
|
else selector.insertBefore(pseudoElement, pseudo(IN_ROOT));
|
|
}
|
|
|
|
/** The compound selectors making up a complex selector, in order. */
|
|
function splitCompounds(selector: Selector): Node[][] {
|
|
const compounds: Node[][] = [[]];
|
|
for (const node of selector.nodes) {
|
|
if (node.type === "combinator") compounds.push([]);
|
|
else if (node.type !== "comment") compounds.at(-1)!.push(node);
|
|
}
|
|
return compounds.filter((compound) => compound.length > 0);
|
|
}
|
|
|
|
function isDocumentSelector(node: Node): boolean {
|
|
return (
|
|
(node.type === "tag" && (node.value === "html" || node.value === "body")) ||
|
|
(node.type === "pseudo" && node.value === ":root")
|
|
);
|
|
}
|
|
|
|
function isPseudoElement(node: Node): node is Pseudo {
|
|
if (node.type !== "pseudo") return false;
|
|
return (
|
|
node.value.startsWith("::") ||
|
|
[":before", ":after", ":first-line", ":first-letter"].includes(node.value)
|
|
);
|
|
}
|
|
|
|
function pseudo(text: string): Pseudo {
|
|
return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo;
|
|
}
|