From 9e6f2e89a8c9875ff51faa69d12c486f581fb80a 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 +++-- playwright/component/component-call.spec.ts | 24 ++++------ .../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 ----- vite-component-dev.config.ts | 17 ++++++- 10 files changed, 90 insertions(+), 132 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/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/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index 0a242b263..fb298a635 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -36,25 +36,17 @@ Please see LICENSE in the repository root for full details. } } +/* The width design sets: a long device name wraps. */ .menu { - display: flex; - flex-direction: column; + inline-size: 296px; } -/* Only the device lists scroll, bounded by the measured call area: the menu is - portalled out of the root, so CSS can't size it against the call. */ +/* Its share of the measured call area: the menu is portalled out of the root, + so CSS can't size it against the call. */ .deviceList { - /* A list that scrolls can paint above the menu's outline, hiding it. */ - margin-inline: var(--cpd-border-width-1); - inline-size: calc( - var(--device-list-inline-size) - 2 * var(--cpd-border-width-1) - ); - overflow-y: auto; - min-block-size: 0; max-block-size: var(--device-list-max-height); - /* Keeps a row reached by keyboard clear of the sticky heading and meter. */ - scroll-padding-block: var(--device-list-scroll-padding-start, 0) - var(--device-list-scroll-padding-end, 0); + /* Keeps a row reached by keyboard clear of the sticky heading. */ + scroll-padding-block-start: var(--device-list-scroll-padding-start, 0); } /* Compound's heading rule heads each section; the design adds space below it. */ @@ -67,21 +59,38 @@ Please see LICENSE in the repository root for full details. margin-block-start: var(--cpd-space-7x); } -/* Sticky within its own section. Opaque and inset a border width so it doesn't - paint over the menu's outline. */ +/* Sticky within its own section, and opaque so rows scroll under it. */ .sectionHeading { position: sticky; inset-block-start: 0; background: var(--cpd-color-bg-canvas-default); - margin-block-start: var(--cpd-border-width-1); } -/* Sticky at the foot of the microphone section; opaque and inset as above. */ +/* Sticky at the foot of the microphone section; opaque as above. */ .stickyMeter { position: sticky; inset-block-end: 0; background: var(--cpd-color-bg-canvas-default); - margin-block-end: var(--cpd-border-width-1); +} + +/* Fills the band between the meter and the frame, or rows show through it. */ +.stickyMeter::after { + content: ""; + position: absolute; + inset-inline: 0; + inset-block-start: 100%; + block-size: var(--cpd-menu-padding-block-end, 0px); + background: var(--cpd-color-bg-canvas-default); +} + +/* Not Compound's fade: that stays at the edge while a bounce carries the meter. + This one starts at the chevron, below the meter at rest. */ +.deviceList.deviceListWithMeter[data-more-below] { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--cpd-space-6x)), + transparent calc(100% - var(--cpd-space-3x)) + ); } /* Radix focuses whatever the pointer is over, so the browser's ring would diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 653a56418..bd1def28a 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -469,17 +469,6 @@ export const ManyDevices: Story = { .getBoundingClientRect(); await expect(frame.top).toBeGreaterThanOrEqual(0); await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1); - - // The meter is the menu's one opaque part, so it must stay inside the frame. - await expect(pinned.left).toBeGreaterThan(frame.left); - await expect(pinned.right).toBeLessThan(frame.right); - - // A list long enough to scroll keeps clear of the frame, which it can - // otherwise paint over. - await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight); - const box = list.getBoundingClientRect(); - await expect(box.left).toBeGreaterThanOrEqual(frame.left + 1); - await expect(box.right).toBeLessThanOrEqual(frame.right - 1); }, }; @@ -647,13 +636,6 @@ export const HeadingsStayWhileScrolling: Story = { await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan( scrollport.top, ); - // Clear of the menu's frame. - const frame = document.body - .querySelector("[role='menu']")! - .getBoundingClientRect(); - await expect(heading.getBoundingClientRect().left).toBeGreaterThan( - frame.left, - ); }, }; diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 4eb04cfe6..9fd67dfdb 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -26,14 +26,6 @@ import { MediaDevicesContext } from "../MediaDevicesContext"; import { type MediaDevices } from "../state/MediaDevices"; import { restoreAudioCapture, stubAudioCapture } from "../utils/test"; -const platformMock = vi.hoisted(() => 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 d8abf5a9f..f0f685370 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={