diff --git a/component/build/scopeStylesToRoot.test.ts b/component/build/scopeStylesToRoot.test.ts index ae42062c3..adb483226 100644 --- a/component/build/scopeStylesToRoot.test.ts +++ b/component/build/scopeStylesToRoot.test.ts @@ -109,4 +109,12 @@ describe("scopeStylesToRoot", () => { ".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); + }); }); diff --git a/component/build/scopeStylesToRoot.ts b/component/build/scopeStylesToRoot.ts index e7d9e1d62..9f35e85b3 100644 --- a/component/build/scopeStylesToRoot.ts +++ b/component/build/scopeStylesToRoot.ts @@ -60,12 +60,18 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`; * 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 { +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 isModule = - root.source?.input.file?.endsWith(".module.css") ?? false; + 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( diff --git a/package.json b/package.json index b30f57ce3..041c91c04 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@typescript-eslint/utils": "^8.61.0", "@use-gesture/react": "^10.2.11", "@vector-im/compound-design-tokens": "^11.0.0", - "@vector-im/compound-web": "^10.0.0", + "@vector-im/compound-web": "^10.3.0", "@vitejs/plugin-react": "^6.0.2", "@vitest/browser-playwright": "^4.1.5", "@vitest/coverage-v8": "^4.0.18", diff --git a/playwright/component/audio-menu.spec.ts b/playwright/component/audio-menu.spec.ts index f4d811598..77579af40 100644 --- a/playwright/component/audio-menu.spec.ts +++ b/playwright/component/audio-menu.spec.ts @@ -129,7 +129,10 @@ async function openDeviceList(page: Page, pane: Locator): Promise { .getByRole("button", { name: "Microphone" }) .click({ timeout: 60_000 }); await expect(page.getByRole("menu")).toBeVisible(); - const list = page.locator("[role='menu'] div[role='none']").first(); + // The area of the menu holding the device groups, not the menu's own. + const list = page.locator( + "[role='menu'] div[role='none']:has(> [role='group'])", + ); await expect(list).toBeVisible(); return list; } diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index e4376ddb2..3577f7a86 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -125,25 +125,19 @@ test("leaves the host's own page unstyled", async ({ page }) => { timeout: 60_000, }); - // Element Call's stylesheet is written for a page of its own: normalize.css - // gives `html` a line height, Compound gives `body` its font and feature - // settings, and the design tokens live on `:root`. None of that may reach the - // host's document — the harness sets none of these itself, so anything other - // than the browser's defaults here came from us. + // Compound's document styles reach the host, but it loads them itself. const host = await page.evaluate(() => { - const html = getComputedStyle(document.documentElement); - const body = getComputedStyle(document.body); + const sub = document.body.appendChild(document.createElement("sub")); + const verticalAlign = getComputedStyle(sub).verticalAlign; + sub.remove(); return { - lineHeight: html.lineHeight, - fontFeatureSettings: body.fontFeatureSettings, - token: html.getPropertyValue("--cpd-color-text-primary"), + verticalAlign, + token: getComputedStyle(document.documentElement).getPropertyValue( + "--font-scale", + ), }; }); - expect(host).toEqual({ - lineHeight: "normal", - fontFeatureSettings: "normal", - token: "", - }); + expect(host).toEqual({ verticalAlign: "sub", token: "" }); // While inside the container, the same rules do apply const root = panes.first().locator("[data-element-call-root]"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23e2eaa3a..bbd1e2260 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,8 +134,8 @@ importers: specifier: ^11.0.0 version: 11.1.0(@types/react@19.2.18)(react@19.2.8) '@vector-im/compound-web': - specifier: ^10.0.0 - version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^10.3.0 + version: 10.3.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) @@ -3240,13 +3240,13 @@ packages: react: optional: true - '@vector-im/compound-web@10.1.0': - resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==} + '@vector-im/compound-web@10.3.0': + resolution: {integrity: sha512-tce5hsNpdvYXfB5nYQgTK7WjTDqjdxpV1BeGsw67gYAWSFKJr9p6X5gzE79o+Tp/q5uykLuO4ozTBdjLsmUWMw==} peerDependencies: '@fontsource/inconsolata': ^5 '@fontsource/inter': ^5 '@types/react': '*' - '@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0' + '@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0' react: ^18 || ^19.0.0 peerDependenciesMeta: '@types/react': @@ -8672,7 +8672,7 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@vector-im/compound-web@10.3.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) diff --git a/src/__snapshots__/AppBar.test.tsx.snap b/src/__snapshots__/AppBar.test.tsx.snap index 99b322ac3..93f11e96c 100644 --- a/src/__snapshots__/AppBar.test.tsx.snap +++ b/src/__snapshots__/AppBar.test.tsx.snap @@ -15,7 +15,7 @@ exports[`AppBar > renders 1`] = ` tabindex="0" >
renders with title and subtitle 1`] = ` tabindex="0" >
vi.fn(() => "desktop")); -vi.mock("../Platform", () => ({ - get platform(): string { - return platformMock(); - }, - isFirefox: (): boolean => false, -})); - interface RenderOptions { requestDeviceNames: () => void; } @@ -157,28 +149,6 @@ describe("MediaMuteAndSwitchButton", () => { expect(onMute).not.toHaveBeenCalled(); }); - // On a phone the menu is a drawer, not a dropdown. - test("holds the menu's width only where it is a dropdown", async () => { - const width = async (platform: string): Promise => { - platformMock.mockReturnValue(platform); - const user = userEvent.setup(); - const { unmount } = renderComponent( - , - ); - await user.click(screen.getByRole("button", { name: "Microphone" })); - const list = document.body.querySelector( - "[style*='--device-list-max-height']", - )!; - const value = list.style.getPropertyValue("--device-list-inline-size"); - unmount(); - return value; - }; - expect(await width("desktop")).toBe("296px"); - expect(await width("ios")).toBe(""); - expect(await width("android")).toBe(""); - platformMock.mockReturnValue("desktop"); - }); - test("requests device names when opened", async () => { const user = userEvent.setup(); const requestDeviceNames = vi.fn(); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 78da0e12f..0381099da 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -17,6 +17,7 @@ import { Button, Menu, MenuItem, + MenuScrollArea, MenuTitle, RadioInput, ToggleMenuItem, @@ -40,7 +41,6 @@ import { useMediaDevices } from "../MediaDevicesContext"; import { useRootElement } from "../RootElementContext"; import { observeElementSize$ } from "../utils/elementSize"; import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter"; -import { menuIsDrawer } from "./menuIsDrawer"; export interface MenuOptions { label: DeviceLabel | AudioOutputDeviceLabel; @@ -85,9 +85,6 @@ const LIST_SHARE_OF_CALL = 0.6; /** Smallest device list height in px, so a short call still shows more than one device. */ const MIN_LIST_HEIGHT = 160; -/** The width design sets for the menu; a long device name wraps instead. */ -const MENU_WIDTH = 296; - /** Space kept between the menu and the call area's sides. */ const MENU_MARGIN = 16; @@ -150,27 +147,17 @@ export const MediaMuteAndSwitchButton: FC = ({ // Measured on the call area: CSS can't size the portalled menu against it. const rootElement = useRootElement(); const [listMaxHeight, setListMaxHeight] = useState(); - const [menuWidth, setMenuWidth] = useState(MENU_WIDTH); useEffect(() => { if (!menuOpen) return; // Followed, since a host can resize the call while the menu is open. const subscription = observeElementSize$(rootElement) .pipe( - map(({ width, height }) => ({ - height: Math.max( - MIN_LIST_HEIGHT, - Math.round(height * LIST_SHARE_OF_CALL), - ), - width: Math.min(MENU_WIDTH, Math.round(width - 2 * MENU_MARGIN)), - })), - distinctUntilChanged( - (a, b) => a.height === b.height && a.width === b.width, + map(({ height }) => + Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)), ), + distinctUntilChanged(), ) - .subscribe(({ height, width }) => { - setListMaxHeight(height); - setMenuWidth(width); - }); + .subscribe(setListMaxHeight); return (): void => subscription.unsubscribe(); }, [menuOpen, rootElement]); @@ -343,6 +330,8 @@ export const MediaMuteAndSwitchButton: FC = ({ open={menuOpen} onOpenChange={onOpenChange} side="top" + collisionBoundary={rootElement} + collisionPadding={MENU_MARGIN} trigger={
= ({ )}
-
+ {toggles.length > 0 &&
} {toggles.map((toggle) => ( rendering > renders 1`] = ` tabindex="0" >
renders with AppBar android 1`] = ` tabindex="0" >
renders with AppBar android 1`] = ` tabindex="0" >
renders with AppBar ios 1`] = ` tabindex="0" >
renders with AppBar ios 1`] = ` tabindex="0" >
renders with header and participant count 1`] = ` tabindex="0" >
{ ...vitePluginsConfig({ mode, html: false }), root: "component/dev", // The same scoping the library build applies, so the harness shows what a - // host will get — including whether its own page is left alone - css: { postcss: { plugins: [scopeStylesToRoot()] } }, + // host will get — including whether its own page is left alone. The host's + // own stylesheets, Compound's among them, stay unscoped, as on a real host. + css: { + postcss: { + plugins: [ + scopeStylesToRoot({ + exclude: [ + /component[\\/]dev[\\/]/, + /@vector-im[\\/]compound-/, + /@fontsource[\\/]/, + ], + }), + ], + }, + }, // So that the harness can read the same config.json the standalone app // does, if the developer has written one publicDir: "../../public",