Files
element-call/component/build/scopeStylesToRoot.test.ts
T
fkwpandClaude Opus 5.5 b1255f0424 Size and scroll the device menus with Compound's menu
- 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>
2026-10-08 12:22:34 +02:00

121 lines
4.1 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 { describe, expect, it } from "vitest";
import postcss from "postcss";
import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot";
const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
const isRoot = `:where(${ROOT_SELECTOR})`;
async function scope(css: string, file = "base.css"): Promise<string> {
const result = await postcss([scopeStylesToRoot()]).process(css, {
from: file,
});
return result.css;
}
describe("scopeStylesToRoot", () => {
it("makes the root stand in for the document", async () => {
expect(await scope("html { line-height: 1.15 }")).toBe(
`${isRoot} { line-height: 1.15 }`,
);
expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`);
expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`);
expect(await scope("body.no-scroll-body { position: fixed }")).toBe(
`${isRoot}.no-scroll-body { position: fixed }`,
);
expect(await scope("body .x { color: red }")).toBe(
`${isRoot} .x { color: red }`,
);
});
it("collapses selectors that all became the root", async () => {
expect(await scope("html, body, input { font: inherit }")).toBe(
`${isRoot},input${inRoot} { font: inherit }`,
);
});
it("confines everything else to the root and what is inside it", async () => {
expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`);
expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe(
`.cpd-theme-dark${inRoot} { --a: 1 }`,
);
expect(await scope("* { box-sizing: border-box }")).toBe(
`*${inRoot} { box-sizing: border-box }`,
);
expect(await scope(".a > .b + .c { color: red }")).toBe(
`.a>.b+.c${inRoot} { color: red }`,
);
});
it("keeps pseudo-elements last", async () => {
expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe(
`button${inRoot}::-moz-focus-inner { border: 0 }`,
);
expect(await scope(".a .b:hover::after { content: '' }")).toBe(
`.a .b:hover${inRoot}::after { content: '' }`,
);
expect(await scope("p:first-letter { color: red }")).toBe(
`p${inRoot}:first-letter { color: red }`,
);
});
it("leaves alone what already names the root", async () => {
const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`;
expect(await scope(css)).toBe(css);
});
it("reaches into layers and media queries", async () => {
expect(
await scope(
"@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }",
),
).toBe(
`@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`,
);
});
it("does not touch keyframes or nested rules", async () => {
expect(
await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"),
).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }");
expect(
await scope(
".a { color: red; &:hover { color: blue } .b { color: green } }",
),
).toBe(
`.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`,
);
});
it("only touches the bare selectors of a CSS module", async () => {
const file = "Settings.module.css";
expect(await scope("pre { font-size: 1px }", file)).toBe(
`pre${inRoot} { font-size: 1px }`,
);
expect(await scope(".modal pre { font-size: 1px }", file)).toBe(
`.modal pre${inRoot} { font-size: 1px }`,
);
expect(await scope(".box_abc12 { border: 0 }", file)).toBe(
".box_abc12 { border: 0 }",
);
expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe(
".a .b_abc12:hover { border: 0 }",
);
});
it("leaves an excluded stylesheet as it is", async () => {
const css = "body { margin: 0 } .menu { color: red }";
const result = await postcss([
scopeStylesToRoot({ exclude: [/host\.css$/] }),
]).process(css, { from: "/app/host.css" });
expect(result.css).toBe(css);
});
});