From b1255f04243ca2107db129f203551b9cb42b143c Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 7 Oct 2026 11:19:03 +0200 Subject: [PATCH] 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) --- component/build/scopeStylesToRoot.test.ts | 8 ++++ component/build/scopeStylesToRoot.ts | 12 +++-- package.json | 2 +- playwright/component/audio-menu.spec.ts | 5 +- playwright/component/component-call.spec.ts | 24 ++++------ pnpm-lock.yaml | 12 ++--- src/__snapshots__/AppBar.test.tsx.snap | 4 +- .../MediaMuteAndSwitchButton.module.css | 47 +++++++++++-------- .../MediaMuteAndSwitchButton.stories.tsx | 18 ------- .../MediaMuteAndSwitchButton.test.tsx | 30 ------------ src/components/MediaMuteAndSwitchButton.tsx | 47 +++++++------------ .../MicrophoneLevelMeter.module.css | 6 +-- src/components/menuIsDrawer.ts | 13 ----- .../__snapshots__/InCallView.test.tsx.snap | 2 +- .../__snapshots__/LobbyView.test.tsx.snap | 10 ++-- vite-component-dev.config.ts | 17 ++++++- 16 files changed, 109 insertions(+), 148 deletions(-) delete mode 100644 src/components/menuIsDrawer.ts 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",