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={