Keep only comments that give a reason the code can't

- Cut placement, surrounding context, design measurements and decision history
  from comments this branch added, across code, styles, tests and stories.
- Correct one that claimed most Firefox builds can't route audio.
- Drop a comment left duplicated in the footer story.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-23 20:42:22 +02:00
co-authored by Claude Opus 5.5
parent d9ed39c7aa
commit c4f0852e2b
22 changed files with 205 additions and 661 deletions
+15 -39
View File
@@ -18,11 +18,9 @@ test.describe("the quick audio menu", () => {
await joinACall(page, "Menu user", "Audio menu"); await joinACall(page, "Menu user", "Audio menu");
await openAudioMenu(page); await openAudioMenu(page);
// The speaker list is what the settings modal used to be the only home of.
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible(); await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible(); await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
// Named rather than counted: the browser contributes its own fake output // By name, not count: the browser adds its own fake output and a Default.
// and a "Default" entry, so a total would be a fact about the browser.
for (const n of [1, 2, 3]) for (const n of [1, 2, 3])
await expect( await expect(
page page
@@ -30,8 +28,6 @@ test.describe("the quick audio menu", () => {
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }), .getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
).toBeVisible(); ).toBeVisible();
// Only one entry of a kind is marked, and the meter reports a number
// rather than a colour.
await expect( await expect(
page.getByRole("menuitemradio", { checked: true }), page.getByRole("menuitemradio", { checked: true }),
).toHaveCount(2); ).toHaveCount(2);
@@ -40,10 +36,7 @@ test.describe("the quick audio menu", () => {
await expect(meter).toHaveAttribute("aria-valuenow", /\d+/); await expect(meter).toHaveAttribute("aria-valuenow", /\d+/);
await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/); await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/);
// Both browsers in the matrix can route audio to a chosen output, so the // Both browsers here can route audio; the platform that can't is a unit check.
// section offers a real choice. The case where a platform cannot — Safari,
// and anything without setSinkId — is covered by a unit check, since no
// browser here can reach it.
await expect( await expect(
page page
.getByRole("group", { name: "Speaker" }) .getByRole("group", { name: "Speaker" })
@@ -55,7 +48,7 @@ test.describe("the quick audio menu", () => {
test("moves the microphone and the speaker without disturbing the call", async ({ test("moves the microphone and the speaker without disturbing the call", async ({
browser, browser,
}) => { }) => {
// Two browsers, two joins and a real call between them. // Two browsers and a real call.
test.slow(); test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" }); const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage(); const host = await hostContext.newPage();
@@ -73,9 +66,7 @@ test.describe("the quick audio menu", () => {
await openAudioMenu(host); await openAudioMenu(host);
await selectDevice(host, "Speaker", "Fake Speaker 2"); await selectDevice(host, "Speaker", "Fake Speaker 2");
// The point of the criterion: the switch is not a rejoin. Neither side // Not a rejoin: neither side drops, and the guest still has both tiles.
// sees the call drop, and the guest still has both tiles — so the host
// never left and came back.
await expect( await expect(
host.getByRole("dialog", { name: "Reconnecting…" }), host.getByRole("dialog", { name: "Reconnecting…" }),
).not.toBeVisible(); ).not.toBeVisible();
@@ -92,7 +83,7 @@ test.describe("the quick audio menu", () => {
test("keeps the meter moving while muted, and sends nothing", async ({ test("keeps the meter moving while muted, and sends nothing", async ({
browser, browser,
}) => { }) => {
// Two browsers, two joins and a real call between them. // Two browsers and a real call.
test.slow(); test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" }); const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage(); const host = await hostContext.newPage();
@@ -110,15 +101,12 @@ test.describe("the quick audio menu", () => {
await expect(mute).toHaveAttribute("aria-checked", "false"); await expect(mute).toHaveAttribute("aria-checked", "false");
await openAudioMenu(host); await openAudioMenu(host);
// The microphone is held open while muted, so the meter still reports the
// hardware. The mute control is what says nothing is being transmitted.
const meter = host.getByRole("meter", { name: "Microphone level" }); const meter = host.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible(); await expect(meter).toBeVisible();
// Queried by test id, not by role: the menu is modal, so Radix takes the // By test id: the modal menu hides the rest of the call from the a11y tree.
// rest of the call out of the accessibility tree while it is open.
await expect(mute).toHaveAttribute("aria-checked", "false"); await expect(mute).toHaveAttribute("aria-checked", "false");
await expect(mute).toBeVisible(); await expect(mute).toBeVisible();
// And the listener is told so, rather than being left to guess from silence. // And the guest is shown the mute.
await expect( await expect(
guest.getByTestId("videoTile").filter({ hasText: "Muted host" }), guest.getByTestId("videoTile").filter({ hasText: "Muted host" }),
).toBeVisible(); ).toBeVisible();
@@ -137,9 +125,8 @@ test.describe("the quick audio menu", () => {
const meter = page.getByRole("meter", { name: "Microphone level" }); const meter = page.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible(); await expect(meter).toBeVisible();
// Scrolled so the microphones start at the top of the list and run past its // Scrolled so the microphones start at the top: only there does a pinned
// bottom: the position that tells a pinned meter from one that merely // meter differ from one that is simply last.
// happens to be last.
const list = page.locator("[role='menu'] div[role='none']").first(); const list = page.locator("[role='menu'] div[role='none']").first();
await list.evaluate((element) => { await list.evaluate((element) => {
const group = element.querySelector("[role='group'][aria-label*='icro']"); const group = element.querySelector("[role='group'][aria-label*='icro']");
@@ -150,7 +137,6 @@ test.describe("the quick audio menu", () => {
await expect(meter).toBeInViewport(); await expect(meter).toBeInViewport();
await expectPinnedInside(meter, list); await expectPinnedInside(meter, list);
// Every entry stays reachable, which is what the scroll is for.
await expect( await expect(
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }), page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
).toBeVisible(); ).toBeVisible();
@@ -169,16 +155,14 @@ test.describe("the quick audio menu", () => {
await openAudioMenu(page); await openAudioMenu(page);
const first = page.getByRole("menuitemradio").first(); const first = page.getByRole("menuitemradio").first();
// Opened by pointer, so no ring, even though Radix has moved focus into the // Opened by pointer: no ring, though Radix has moved focus into the menu.
// menu already.
await expect.poll(async () => outlineWidth(first)).toBe(0); await expect.poll(async () => outlineWidth(first)).toBe(0);
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
const focused = page.locator("[role='menuitemradio']:focus"); const focused = page.locator("[role='menuitemradio']:focus");
await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0); await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0);
// The pointer takes it away again: the menu focuses whatever it is over, so // And the pointer takes it away again.
// a ring that followed focus alone would trail the mouse.
await first.hover(); await first.hover();
await expect.poll(async () => outlineWidth(focused)).toBe(0); await expect.poll(async () => outlineWidth(focused)).toBe(0);
}); });
@@ -193,8 +177,7 @@ async function joinACall(
await page.goto("/"); await page.goto("/");
await SpaHelpers.createCall(page, userName, callName, true); await SpaHelpers.createCall(page, userName, callName, true);
await expect(page.getByTestId("name_tag")).toContainText(userName); await expect(page.getByTestId("name_tag")).toContainText(userName);
// The media controls stay disabled until the devices have enumerated, and // The media controls stay disabled until devices have enumerated.
// every test here drives them.
await expect(page.getByTestId("incall_mute")).toBeEnabled({ await expect(page.getByTestId("incall_mute")).toBeEnabled({
timeout: 10_000, timeout: 10_000,
}); });
@@ -214,19 +197,12 @@ async function selectDevice(
.getByRole("group", { name: section }) .getByRole("group", { name: section })
.getByRole("menuitemradio", { name }); .getByRole("menuitemradio", { name });
await item.click(); await item.click();
// Selecting does not close the menu — the component prevents the default so // Selecting doesn't close the menu, so dismiss it.
// the list survives a mis-click — so it is dismissed explicitly.
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).not.toBeVisible(); await expect(page.getByRole("menu")).not.toBeVisible();
} }
/** /** Asserts the meter sits within the scrollport and inside the menu's frame. */
* Asserts the meter sits within the scrollport, and inside the menu's frame.
*
* The meter is the one opaque element in the menu, so it is the one thing that
* can paint over the border. Whether it actually does needs a screenshot; this
* pins the geometry that decides it.
*/
async function expectPinnedInside( async function expectPinnedInside(
meter: Locator, meter: Locator,
list: Locator, list: Locator,
@@ -243,7 +219,7 @@ async function expectPinnedInside(
expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width); expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width);
} }
/** The painted outline width in pixels, however the stylesheet spells it. */ /** Painted outline width, in px. */
async function outlineWidth(item: Locator): Promise<number> { async function outlineWidth(item: Locator): Promise<number> {
if ((await item.count()) === 0) return 0; if ((await item.count()) === 0) return 0;
return item.first().evaluate((element) => { return item.first().evaluate((element) => {
+12 -42
View File
@@ -11,22 +11,12 @@ import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
import { installFakeDevices } from "../utils/fake-devices.ts"; import { installFakeDevices } from "../utils/fake-devices.ts";
/** /**
* The device menu where Element Call is a component in a host's page rather * The device menu with Element Call as a component in a host page, where the
* than the whole of one. * portalled menu must be sized against the call, not the window. Driven from
* * the lobby: it builds the same menu, without two connections' worth of flake.
* This is the case the stylesheets cannot describe: the menu is portalled to
* the document, so a container query and a viewport unit both measure the wrong
* thing — the first has no container to resolve against out there, the second
* measures a page Element Call does not own. The menu has to be sized against
* the space the call is actually drawn in.
*
* Driven from the lobby rather than a joined call. The footer builds the same
* menu from the same device behaviours in both, and the container is the same
* size either way, so joining would only add two connections' worth of flake.
*/ */
// Signing in, setting up crypto and syncing happen twice before anything is on // Sign-in, crypto setup and sync happen twice before anything shows.
// screen, as in component-call.spec.ts
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 });
test("sizes the device list against the call, not the window", async ({ test("sizes the device list against the call, not the window", async ({
@@ -37,8 +27,7 @@ test("sizes the device list against the call, not the window", async ({
const panes = await startHarness(page, username, roomId); const panes = await startHarness(page, username, roomId);
const pane = panes.first(); const pane = panes.first();
// A short call in a much taller page: the difference between measuring the // A short call in a much taller page.
// call and measuring the window.
const container = pane.getByTestId("call-container"); const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 900, height: 400 }); await resizeContainer(container, { width: 900, height: 400 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
@@ -50,8 +39,6 @@ test("sizes the device list against the call, not the window", async ({
const windowHeight = page.viewportSize()!.height; const windowHeight = page.viewportSize()!.height;
const listHeight = (await list.boundingBox())!.height; const listHeight = (await list.boundingBox())!.height;
// Sized against the call. Were it sized against the window the list would be
// half as tall again, and the assertion below would not be able to tell.
expect(callHeight).toBeLessThan(windowHeight * 0.75); expect(callHeight).toBeLessThan(windowHeight * 0.75);
expect(listHeight).toBeLessThanOrEqual(callHeight); expect(listHeight).toBeLessThanOrEqual(callHeight);
expect(listHeight).toBeLessThan(windowHeight * 0.6); expect(listHeight).toBeLessThan(windowHeight * 0.6);
@@ -72,9 +59,7 @@ test("follows the call area when the host resizes it", async ({ page }) => {
const list = await openDeviceList(page, pane); const list = await openDeviceList(page, pane);
const whenShort = (await list.boundingBox())!.height; const whenShort = (await list.boundingBox())!.height;
// The host grows the space Element Call is drawn in while the menu is open — // The host grows the call while the menu is open.
// a panel opening, a window dragged, a phone turned. A bound taken once on
// opening would still describe the smaller call.
await resizeContainer(container, { width: 900, height: 700 }); await resizeContainer(container, { width: 900, height: 700 });
await expect await expect
.poll(async () => (await list.boundingBox())!.height) .poll(async () => (await list.boundingBox())!.height)
@@ -88,15 +73,13 @@ test("keeps every device reachable in a small container", async ({ page }) => {
const pane = panes.first(); const pane = panes.first();
const container = pane.getByTestId("call-container"); const container = pane.getByTestId("call-container");
// Narrow as well as short, which is where entries get pushed out of reach.
await resizeContainer(container, { width: 400, height: 360 }); await resizeContainer(container, { width: 400, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000, timeout: 60_000,
}); });
const list = await openDeviceList(page, pane); const list = await openDeviceList(page, pane);
// More devices than the space allows, so the list has to scroll rather than // More devices than fit, so the list must scroll.
// put entries somewhere they cannot be got at.
expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe( expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(
true, true,
); );
@@ -104,9 +87,7 @@ test("keeps every device reachable in a small container", async ({ page }) => {
const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" }); const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" });
await last.scrollIntoViewIfNeeded(); await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport(); await expect(last).toBeInViewport();
// Reachable means usable, not merely painted: D11 accepts that the menu may // Reachable means clickable: the menu may be drawn outside the call area.
// be drawn outside the call area, so this asserts reach rather than
// containment.
await last.click(); await last.click();
await expect(last).toHaveAttribute("aria-checked", "true"); await expect(last).toHaveAttribute("aria-checked", "true");
}); });
@@ -124,20 +105,13 @@ test("tracks the focus source of its own call, not the page", async ({
timeout: 60_000, timeout: 60_000,
}); });
await openDeviceList(page, pane); await openDeviceList(page, pane);
// The menu owns the focus source, because every item it can focus has to answer
// to it — the device rows and the camera menu's blur toggle alike.
const menu = page.getByRole("menu"); const menu = page.getByRole("menu");
// Asserted on the attribute rather than the painted ring, which cannot be // Asserted on the attribute: the component build's scoped stylesheet doesn't
// read here: the menu is portalled outside the call root, and the component // reach the portalled menu, so its ring can't be read here.
// build scopes the stylesheet to it, so neither the ring nor the rule that
// suppresses the browser's own reaches this menu. The paint is asserted
// standalone instead — in the story and in audio-menu.spec.ts. What is on
// trial here is which call the tracking answers for.
await expect(menu).toHaveAttribute("data-focus-source", "pointer"); await expect(menu).toHaveAttribute("data-focus-source", "pointer");
// A key pressed in the other call on this page — or anywhere in the host's // A key pressed in the other call says nothing about this menu.
// own page — says nothing about how this menu is being used.
await other.evaluate((element) => await other.evaluate((element) =>
element.dispatchEvent( element.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
@@ -145,15 +119,11 @@ test("tracks the focus source of its own call, not the page", async ({
); );
await expect(menu).toHaveAttribute("data-focus-source", "pointer"); await expect(menu).toHaveAttribute("data-focus-source", "pointer");
// A key pressed in this menu does.
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
await expect(menu).toHaveAttribute("data-focus-source", "keyboard"); await expect(menu).toHaveAttribute("data-focus-source", "keyboard");
}); });
/** /** Opens a component's microphone menu and returns its device list. */
* Opens the microphone menu of one component and returns its scrolling device
* list, which lives outside the component: the menu is portalled to the page.
*/
async function openDeviceList(page: Page, pane: Locator): Promise<Locator> { async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
await pane await pane
.getByRole("button", { name: "Microphone" }) .getByRole("button", { name: "Microphone" })
+7 -20
View File
@@ -8,20 +8,10 @@ Please see LICENSE in the repository root for full details.
import { type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
/** /**
* Gives the browser more fake devices than it ships with. * Adds synthetic devices beside the browser's one fake microphone and speaker,
* * so the menu has a choice to show. They share one capture and `setSinkId` is
* A headless browser's fake capture offers one microphone and one speaker, * accepted but not honoured: routing still needs hardware and a manual check.
* which is one short of what a device menu is for: with no choice to make, every * Call before the page navigates.
* entry renders disabled. These are synthetic entries on top of the real fake
* device, so the menu has a list to show and a selection to move, and the app
* runs its real device pipeline against them.
*
* What they do not do is route audio: every entry is backed by the same capture,
* and `setSinkId` is accepted rather than honoured. A test can prove that
* choosing a device changes the app's state and does not disturb the call. That
* a listener hears the change needs hardware, and stays a manual check.
*
* Must be called before the page navigates.
*/ */
export async function installFakeDevices( export async function installFakeDevices(
page: Page, page: Page,
@@ -58,8 +48,7 @@ export async function installFakeDevices(
...synthetic("audiooutput", speakers, "Fake Speaker"), ...synthetic("audiooutput", speakers, "Fake Speaker"),
]; ];
// Our ids name no hardware, so an exact-device constraint on one would be // Our ids name no hardware, so drop the exact-device constraint.
// rejected. Drop it and let the one real fake device answer.
const getUserMedia = devices.getUserMedia.bind(devices); const getUserMedia = devices.getUserMedia.bind(devices);
devices.getUserMedia = async ( devices.getUserMedia = async (
constraints?: MediaStreamConstraints, constraints?: MediaStreamConstraints,
@@ -77,10 +66,8 @@ export async function installFakeDevices(
return getUserMedia(constraints); return getUserMedia(constraints);
}; };
// Routing to a device that does not exist would reject, and the app // Accept routing to our ids on both media elements and the AudioContext,
// treats that as a failed switch. Both sinks are patched: Element Call // which the app also routes.
// routes its own AudioContext as well as the media elements, and leaving
// that one alone logs a NotFoundError for every switch.
for (const proto of [ for (const proto of [
HTMLMediaElement.prototype, HTMLMediaElement.prototype,
AudioContext.prototype, AudioContext.prototype,
+3 -12
View File
@@ -180,8 +180,7 @@ export const StaysWhileAMenuIsOpen: Story = {
...Default, ...Default,
args: { args: {
...WithAudioAndVideoOptions.args, ...WithAudioAndVideoOptions.args,
// As it is in a short window, where the footer overlays the call and hides // In a short window the footer overlays the call and hides itself.
// itself once nothing is happening.
asOverlay: true, asOverlay: true,
showFooter: true, showFooter: true,
}, },
@@ -194,18 +193,10 @@ export const StaysWhileAMenuIsOpen: Story = {
); );
await expect(document.body.querySelector('[role="menu"]')).not.toBeNull(); await expect(document.body.querySelector('[role="menu"]')).not.toBeNull();
// The call now decides to hide the footer, which is the class it does it // The call hides the footer with this class.
// with. The menu is portalled out of the footer, so the focus inside it is
// not something the footer can see: without the trigger's aria-expanded to
// go on, this would fade the footer out and take the menu's anchor with it.
// The call now decides to hide the footer, which is the class it does that
// with. The menu is portalled out of the footer, so the focus inside it is
// not something the footer can see: without the trigger's aria-expanded to
// go on, this fades the footer out and takes the menu's anchor with it.
footer.classList.add(styles.hidden); footer.classList.add(styles.hidden);
// The footer fades over 0.15s, so a reading taken now is the value it // Read after the 0.15s fade, not mid-transition.
// started from whatever happens next. Let the transition finish first.
await new Promise((settled) => setTimeout(settled, 400)); await new Promise((settled) => setTimeout(settled, 400));
await expect(getComputedStyle(footer).opacity).toBe("1"); await expect(getComputedStyle(footer).opacity).toBe("1");
}, },
+1 -1
View File
@@ -99,7 +99,7 @@ export interface FooterState {
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[]; audioOptions: MenuOptions[];
/** Output devices shown as their own section in the audio menu. */ /** Output (speaker) devices. */
audioOutputOptions: MenuOptions[]; audioOutputOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[]; videoOptions: MenuOptions[];
+2 -8
View File
@@ -116,7 +116,6 @@ describe("createCallFooterViewModel", () => {
it("is withheld where the platform cannot route audio to a chosen device", () => { it("is withheld where the platform cannot route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(false); outputSelectionMock.mockReturnValue(false);
// Undefined is what renders the speaker section disabled.
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined(); expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
}); });
@@ -145,9 +144,7 @@ describe("createCallFooterViewModel", () => {
selected$: constant(undefined), selected$: constant(undefined),
select: vi.fn(), select: vi.fn(),
}, },
// Safari enumerates no output devices whatsoever. Reproduced by the // As Safari: no outputs listed.
// condition rather than by the browser, so it is checked on the
// Linux CI runners that have no Safari to check it with.
audioOutput: { audioOutput: {
available$: constant(new Map<string, DeviceLabel>()), available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined), selected$: constant(undefined),
@@ -158,10 +155,7 @@ describe("createCallFooterViewModel", () => {
{ showControls: true, header: HeaderStyle.Standard }, { showControls: true, header: HeaderStyle.Standard },
); );
// Empty rather than undefined: undefined means this menu has no notion // Empty, not undefined: undefined would hide the section.
// of outputs at all, as the camera menu has none, and hides the section.
// Empty means there are none to list, and the menu still shows the
// section with a default in it, disabled.
expect(vm.audioOutputOptions$.value).toEqual([]); expect(vm.audioOutputOptions$.value).toEqual([]);
}); });
}); });
+2 -2
View File
@@ -104,8 +104,8 @@ function buildDeviceBehaviors(
selectedAudioOutput$: scope.behavior( selectedAudioOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)), mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
), ),
// Safari and most Firefox builds cannot route audio to a chosen device at // Withheld where the platform can't route audio to a chosen device, which
// all. Withholding the callback is what renders the section disabled. // disables the speaker section.
selectAudioOutputOption$: constant( selectAudioOutputOption$: constant(
supportsAudioOutputSelection() supportsAudioOutputSelection()
? mediaDevices.audioOutput.select ? mediaDevices.audioOutput.select
@@ -41,55 +41,29 @@ Please see LICENSE in the repository root for full details.
flex-direction: column; flex-direction: column;
} }
/* Only the device lists scroll; the level meter stays put beneath them. /* Only the device lists scroll, bounded by the measured call area: the menu is
The bound comes from the measured height of the call area, set by the portalled out of the root, so CSS can't size it against the call. */
component: the menu is portalled outside the root, so neither a container
query nor a viewport unit describes the space it is allowed to fill. */
.deviceList { .deviceList {
overflow-y: auto; overflow-y: auto;
min-block-size: 0; min-block-size: 0;
max-block-size: var(--device-list-max-height); max-block-size: var(--device-list-max-height);
/* The heading stands over the head of this list and the level meter over its /* Keeps a row reached by keyboard clear of the sticky heading and meter. */
foot. Scrolling a row flush to either edge would put it underneath one of
them, which is how a row reached by the keyboard ends up half-readable;
this keeps both their heights clear for anything the browser scrolls to.
Set by the component, which is the only thing that can measure them. */
scroll-padding-block: var(--device-list-scroll-padding-start, 0) scroll-padding-block: var(--device-list-scroll-padding-start, 0)
var(--device-list-scroll-padding-end, 0); var(--device-list-scroll-padding-end, 0);
} }
/* Each section is headed by its own rule, which Compound draws under a menu /* Compound's heading rule heads each section; the design adds space below it. */
heading already. The heading spans the whole menu — the menu has no padding
of its own — so that rule runs edge to edge, stopping only where the frame
is, rather than inset as a separator between the sections would be.
Compound's spacing around it is the design's too, bar one thing: the design
leaves a section's first device further below the rule than Compound does —
measured off the mock, 27px from the rule to the top of the control, against
the 16px that Compound's 8px margin and the row's own 8px padding give. The
difference goes below the rule, so the rule stays tight under its own text
where Compound put it. */
.menu h3 { .menu h3 {
margin-block-end: var(--cpd-space-5x); margin-block-end: var(--cpd-space-5x);
} }
/* One section is set much further from the one above it than Compound's 8px /* And more above a heading that follows another section. */
heading margin allows: measured off the mock, 41px from the last device's
control to the next heading's text, against the 22px we had. Only between
sections — the first heading keeps Compound's spacing, because the menu's
own padding is already above it. */
.deviceList [role="group"] + [role="group"] h3 { .deviceList [role="group"] + [role="group"] h3 {
margin-block-start: var(--cpd-space-7x); margin-block-start: var(--cpd-space-7x);
} }
/* Each section's heading stays at the top of the scrollport while any of that /* Sticky within its own section. Opaque and inset a border width so it doesn't
section is still in view, so a long list never leaves you wondering which paint over the menu's outline. */
kind of device you are looking at. It leaves with its own section, because
sticky only holds while the group it belongs to is in view.
Opaque, and held a border width clear of the sides, for the same reason as
the meter below: this is a positioned element, so it paints above the
outline the menu draws its frame with, and would swallow it. */
.sectionHeading { .sectionHeading {
position: sticky; position: sticky;
inset-block-start: 0; inset-block-start: 0;
@@ -98,39 +72,22 @@ Please see LICENSE in the repository root for full details.
margin-block-start: var(--cpd-border-width-1); margin-block-start: var(--cpd-border-width-1);
} }
/* The meter belongs to the microphone section: it stays at the bottom of the /* Sticky at the foot of the microphone section; opaque and inset as above. */
scrollport while that section is in view, and leaves with it when the list
is scrolled up to the speakers. The wrapper is deliberately unpositioned —
a positioned one paints above the menu's outline and swallows the frame
along this whole section. Sticky is resolved against the scroll container,
so it does not need one. */
.stickyMeter { .stickyMeter {
position: sticky; position: sticky;
inset-block-end: 0; inset-block-end: 0;
/* Opaque, so the list does not show through it as it scrolls past. The menu
draws its frame as an outline inset by one border width, and the device
rows are transparent at rest, so this is the only thing that can cover it:
hold it clear on the sides and the bottom. */
background: var(--cpd-color-bg-canvas-default); background: var(--cpd-color-bg-canvas-default);
margin-inline: var(--cpd-border-width-1); margin-inline: var(--cpd-border-width-1);
margin-block-end: var(--cpd-border-width-1); margin-block-end: var(--cpd-border-width-1);
} }
/* Radix focuses whatever the pointer is over, so the browser's own ring marks /* Radix focuses whatever the pointer is over, so the browser's ring would
the item under the mouse. It cannot distinguish the two modalities here, so follow the mouse. Replaced by one shown only for keyboard focus. */
it is suppressed and replaced by one that can.
Every kind of item the menu can focus, not only the device rows: the camera
menu's blur toggle is a checkbox item and a child of the menu rather than of
the list, and left out it kept the browser's own ring and followed the
pointer with it. */
.menu [role^="menuitem"]:focus, .menu [role^="menuitem"]:focus,
.menu [role^="menuitem"]:focus-visible { .menu [role^="menuitem"]:focus-visible {
outline: none; outline: none;
} }
/* Shown only when the keyboard is what moved the focus. */
.menu[data-focus-source="keyboard"] [role^="menuitem"]:focus { .menu[data-focus-source="keyboard"] [role^="menuitem"]:focus {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused); outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2)); outline-offset: calc(-1 * var(--cpd-border-width-2));
@@ -21,24 +21,14 @@ const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false, controlledAudioDevices: false,
}); });
/** /** Supplies a microphone, a wavering tone: a story's invented device ids match no hardware. */
* Gives these stories a microphone to read.
*
* The menu opens a capture of whichever device it has been told is selected,
* and the devices in a story are invented: asking for one by an id no hardware
* answers to fails, and the meter reports that — correctly — as there being no
* microphone. So the story provides one rather than borrowing the machine's: a
* wavering tone played into a real MediaStream, which the meter then runs its
* own analyser over. Nothing here stands in for the meter itself.
*/
const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => { const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
useEffect(() => { useEffect(() => {
const context = new AudioContext(); const context = new AudioContext();
const microphone = context.createMediaStreamDestination(); const microphone = context.createMediaStreamDestination();
const tone = context.createOscillator(); const tone = context.createOscillator();
const loudness = context.createGain(); const loudness = context.createGain();
// Swinging between about a third and two thirds of the range, so the meter // Wavers between a third and two thirds of full scale, so it reads as live.
// reads as something live rather than as a level someone pinned there.
const swing = context.createOscillator(); const swing = context.createOscillator();
const depth = context.createGain(); const depth = context.createGain();
loudness.gain.value = 0.25; loudness.gain.value = 0.25;
@@ -53,8 +43,7 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
const devices = navigator.mediaDevices; const devices = navigator.mediaDevices;
const openedForReal = devices.getUserMedia.bind(devices); const openedForReal = devices.getUserMedia.bind(devices);
const opened = Promise.resolve(microphone.stream); const opened = Promise.resolve(microphone.stream);
// A fresh clone each time, so that a caller stopping its tracks when it is // A fresh clone each time, so a caller stopping its tracks doesn't end the next.
// done does not take the microphone away from the next one.
devices.getUserMedia = async (): Promise<MediaStream> => devices.getUserMedia = async (): Promise<MediaStream> =>
(await opened).clone(); (await opened).clone();
@@ -69,26 +58,14 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
return <>{children}</>; return <>{children}</>;
}; };
/** /** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
* Gives these stories the call area the menu belongs to.
*
* The menu sizes its device list against the space Element Call is drawn in,
* and takes that from a provider. Without one it falls back to the document
* body — which in a story is the whole of Storybook's frame, so the list is
* bounded by something far larger than the story it is drawn in and runs off
* the top of the canvas. Supplying a root is the same courtesy as supplying the
* devices: the story stands in for the call, so it has to say how big it is.
*/
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => { const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
const [callArea, setCallArea] = useState<HTMLElement | null>(null); const [callArea, setCallArea] = useState<HTMLElement | null>(null);
return ( return (
<div <div
ref={setCallArea} ref={setCallArea}
style={{ style={{
// The size of a call, not of a thumbnail: the device list is bounded to // A call's size: anything smaller makes a short device list scroll.
// a share of this, so a small area makes even a two-device menu scroll,
// which no real call does. Tall enough to leave the menu room to open
// upward and still be wholly on screen in the story's frame.
blockSize: 720, blockSize: 720,
display: "flex", display: "flex",
alignItems: "flex-end", alignItems: "flex-end",
@@ -129,10 +106,7 @@ export const Default: Story = {
{ label: { type: "name", name: "Option 2" }, id: "2" }, { label: { type: "name", name: "Option 2" }, id: "2" },
], ],
selectedOption: "1", selectedOption: "1",
// The audio menu always has a speaker section: the footer hands it an // The footer always passes an output list to the audio menu.
// output list whenever it draws the chevron at all, so a microphone menu
// with no speakers in it is a shape nothing in the app produces. Set here
// rather than in each story, since the others build on these.
outputOptions: [ outputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" }, { label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "spk2" }, { label: { type: "name", name: "Headset" }, id: "spk2" },
@@ -234,16 +208,13 @@ export const SpeakerAndMicrophoneSections: Story = {
await userEvent.click(headset); await userEvent.click(headset);
await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2"); await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2");
// A handful of devices fits: only a list longer than the space it is given // A few devices fit, so the list doesn't scroll.
// scrolls, and a menu that scrolled at four devices would be bounded by
// something far smaller than the call it is drawn in.
const list = document.body.querySelector<HTMLElement>( const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`, `.${styles.deviceList}`,
)!; )!;
await expect(list.scrollHeight).toBe(list.clientHeight); await expect(list.scrollHeight).toBe(list.clientHeight);
// Each section is headed by its own rule, running the full width of the // Each section is headed by its own edge-to-edge rule, with no separator.
// menu rather than inset — and nothing divides the sections besides.
const menu = document.body.querySelector("[role='menu']")!; const menu = document.body.querySelector("[role='menu']")!;
await expect( await expect(
document.body.querySelectorAll("[role='separator']"), document.body.querySelectorAll("[role='separator']"),
@@ -258,16 +229,14 @@ export const SpeakerAndMicrophoneSections: Story = {
await expect( await expect(
Number.parseFloat(getComputedStyle(rule).borderBottomWidth), Number.parseFloat(getComputedStyle(rule).borderBottomWidth),
).toBeGreaterThan(0); ).toBeGreaterThan(0);
// Edge to edge, stopping only where the menu's frame is drawn. // Edge to edge, within the frame.
const box = rule.getBoundingClientRect(); const box = rule.getBoundingClientRect();
await expect(box.left - frame.left).toBeLessThanOrEqual(2); await expect(box.left - frame.left).toBeLessThanOrEqual(2);
await expect(frame.right - box.right).toBeLessThanOrEqual(2); await expect(frame.right - box.right).toBeLessThanOrEqual(2);
} }
// A section's first device sits further below the rule than it does from // The first device sits further below the rule than from the menu's edge.
// the menu's edge. Stated as the relationship rather than a number: what // Asserted as a relationship, not pixels: the asymmetry is the design.
// the design asks for is the asymmetry, and Compound's own heading margin
// alone would make the two equal.
const control = document.body.querySelector("input[type='radio']")!; const control = document.body.querySelector("input[type='radio']")!;
const ruleBottom = headings[0] const ruleBottom = headings[0]
.querySelector("h3")! .querySelector("h3")!
@@ -275,8 +244,8 @@ export const SpeakerAndMicrophoneSections: Story = {
const box = control.getBoundingClientRect(); const box = control.getBoundingClientRect();
await expect(box.top - ruleBottom).toBeGreaterThan(box.left - frame.left); await expect(box.top - ruleBottom).toBeGreaterThan(box.left - frame.left);
// And one section stands further from the one above it than a heading does // And a section stands further from the one above than from its own first
// from its own first device — again the relationship, not a number. // device.
const groups = document.body.querySelectorAll("[role='group']"); const groups = document.body.querySelectorAll("[role='group']");
const speakers = groups[0].querySelectorAll("input[type='radio']"); const speakers = groups[0].querySelectorAll("input[type='radio']");
const lastSpeaker = speakers[speakers.length - 1].getBoundingClientRect(); const lastSpeaker = speakers[speakers.length - 1].getBoundingClientRect();
@@ -331,7 +300,7 @@ export const OnlyOneDevice: Story = {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
// Shown rather than hidden, so the menu keeps its shape everywhere. // Shown, disabled, rather than hidden.
const only = await within(document.body).findByRole("menuitemradio", { const only = await within(document.body).findByRole("menuitemradio", {
name: "Microphone 1", name: "Microphone 1",
}); });
@@ -339,10 +308,7 @@ export const OnlyOneDevice: Story = {
}, },
}; };
/** /** A requested device hasn't arrived: nothing in either section can be picked. */
* A device has been asked for and has not arrived. Nothing in either section
* can be picked until it does, so a second request cannot overtake the first.
*/
export const SelectionSettling: Story = { export const SelectionSettling: Story = {
args: { args: {
...Default.args, ...Default.args,
@@ -365,8 +331,7 @@ export const SelectionSettling: Story = {
await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body); const menu = within(document.body);
// The story never changes `selectedOption`, which is what a device that has // selectedOption never changes, so the request stays in flight.
// not taken effect yet looks like from here.
await userEvent.click( await userEvent.click(
await menu.findByRole("menuitemradio", { name: "Microphone 2" }), await menu.findByRole("menuitemradio", { name: "Microphone 2" }),
); );
@@ -378,12 +343,8 @@ export const SelectionSettling: Story = {
}; };
/** /**
* The focus ring belongs to the keyboard. Radix focuses whatever the pointer is * The ring shows for keyboard focus only. Asserted on the painted outline:
* over, so a ring that followed focus alone would trail the mouse. * asserting data-focus-source would still pass with the CSS rule deleted.
*
* Asserted on the painted outline rather than on `data-focus-source`: the
* attribute is what the stylesheet keys off, so asserting it would pass even
* with the rule deleted.
*/ */
export const KeyboardFocusRing: Story = { export const KeyboardFocusRing: Story = {
args: { args: {
@@ -418,10 +379,7 @@ export const KeyboardFocusRing: Story = {
}, },
}; };
/** /** The list scrolls; the meter stays at the foot of the microphone section. */
* More devices than the menu can show. The list scrolls, and the meter stays at
* the foot of the Microphone section rather than scrolling away with it.
*/
export const ManyDevices: Story = { export const ManyDevices: Story = {
args: { args: {
...Default.args, ...Default.args,
@@ -444,11 +402,8 @@ export const ManyDevices: Story = {
await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body); const menu = within(document.body);
// The scroll container and the opaque sticky wrapper, named rather than // The sticky wrapper rather than the meter: without a fake microphone (as on
// walked: the nesting between them is layout, and it moves. The wrapper // WebKit) the meter shows a message instead of a level.
// rather than the meter itself, because this story is about where the meter
// sits, not what it reads — without a fake microphone, as on WebKit, it
// says it has no permission instead of showing a level.
const list = document.body.querySelector<HTMLElement>( const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`, `.${styles.deviceList}`,
)!; )!;
@@ -461,10 +416,8 @@ export const ManyDevices: Story = {
}); });
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight); await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
// Scrolled so the Microphone section starts at the top of the scrollport. // Scrolled so the microphone section starts at the top: only there does a
// Its devices then run past the bottom, which is the position that tells a // pinned meter differ from one that is simply last.
// pinned meter from one that simply happens to be the last element: at the
// very bottom of the list the two look identical.
const group = await menu.findByRole("group", { name: "Microphone" }); const group = await menu.findByRole("group", { name: "Microphone" });
list.scrollTop += list.scrollTop +=
group.getBoundingClientRect().top - list.getBoundingClientRect().top; group.getBoundingClientRect().top - list.getBoundingClientRect().top;
@@ -477,35 +430,27 @@ export const ManyDevices: Story = {
await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1); await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1); await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1);
// The whole menu is on screen. It opens upward from the foot of the call, // The whole menu is on screen, not bounded by the document.
// so a list bounded by something bigger than the call — the document, say —
// runs off the top and takes the speakers with it.
const frame = document.body const frame = document.body
.querySelector("[role='menu']")! .querySelector("[role='menu']")!
.getBoundingClientRect(); .getBoundingClientRect();
await expect(frame.top).toBeGreaterThanOrEqual(0); await expect(frame.top).toBeGreaterThanOrEqual(0);
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1); await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
// The meter is the one opaque thing in the menu, so it is the one thing // The meter is the menu's one opaque part, so it must stay inside the frame.
// that can cover the frame. Its box has to stay inside the menu's own. The
// paint itself needs a screenshot; this pins the geometry that decides it.
await expect(pinned.left).toBeGreaterThan(frame.left); await expect(pinned.left).toBeGreaterThan(frame.left);
await expect(pinned.right).toBeLessThan(frame.right); await expect(pinned.right).toBeLessThan(frame.right);
}, },
}; };
/** The painted outline width, in pixels, however the stylesheet spells it. */ /** Painted outline width, in px. */
function outlineWidth(element: HTMLElement): number { function outlineWidth(element: HTMLElement): number {
const { outlineStyle, outlineWidth } = getComputedStyle(element); const { outlineStyle, outlineWidth } = getComputedStyle(element);
if (outlineStyle === "none") return 0; if (outlineStyle === "none") return 0;
return Number.parseFloat(outlineWidth) || 0; return Number.parseFloat(outlineWidth) || 0;
} }
/** /** Safari lists no outputs: a disabled, selected Default stands in. */
* A platform that enumerates no output devices and offers no way to choose one
* — Safari. The section still names where audio is going, disabled, rather than
* leaving a heading with nothing under it.
*/
export const OutputNotEnumerated: Story = { export const OutputNotEnumerated: Story = {
args: { args: {
...Default.args, ...Default.args,
@@ -531,15 +476,7 @@ export const OutputNotEnumerated: Story = {
}, },
}; };
/** /** The meter's icon shares the radio controls' centre line; only a real browser lays this out. */
* The level meter's icon sits on the same centre line as the radio controls of
* the devices above it.
*
* Held here because it is a fact about two components side by side, and because
* layout decides it: the meter's row is inset to keep the menu's frame clear,
* and its icon is a different size from a radio control, so the padding that
* lines them up is arithmetic that would otherwise go stale in silence.
*/
export const MeterAlignsWithTheDeviceRows: Story = { export const MeterAlignsWithTheDeviceRows: Story = {
args: { args: {
...Default.args, ...Default.args,
@@ -568,28 +505,19 @@ export const MeterAlignsWithTheDeviceRows: Story = {
}, },
}; };
/** Where an element sits on the inline axis, at its middle. */ /** Inline-axis centre of an element. */
function centre(element: Element): number { function centre(element: Element): number {
const box = element.getBoundingClientRect(); const box = element.getBoundingClientRect();
return box.left + box.width / 2; return box.left + box.width / 2;
} }
/** /** Every row the keyboard reaches is fully visible, not under a heading or the meter. */
* Walking the device list with the keyboard, all the way to the last entry.
*
* The level meter stands over the foot of the list, so a row scrolled flush to
* the bottom edge arrives underneath it and can only half be read. Nothing in
* the DOM says an element is covered, so this compares where the two were
* actually drawn.
*/
export const KeyboardReachesEveryDevice: Story = { export const KeyboardReachesEveryDevice: Story = {
args: { args: {
...Default.args, ...Default.args,
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
// Enough of them that the list scrolls well past its own height, so that // Enough to scroll both ways, so either end can hide a row.
// arrowing back up has to scroll too — which is where the heading can hide
// a row, as the meter can on the way down.
options: Array.from({ length: 20 }, (_, i) => ({ options: Array.from({ length: 20 }, (_, i) => ({
label: { type: "name" as const, name: `Microphone ${i + 1}` }, label: { type: "name" as const, name: `Microphone ${i + 1}` },
id: `mic${i + 1}`, id: `mic${i + 1}`,
@@ -614,8 +542,7 @@ export const KeyboardReachesEveryDevice: Story = {
return element; return element;
}); });
// Everything that is drawn over the scrolling list: a heading holds the top // Drawn over the list: the sticky headings and the meter.
// while its section is in view, the meter holds the foot.
const overlays = [ const overlays = [
meter, meter,
...document.body.querySelectorAll<HTMLElement>( ...document.body.querySelectorAll<HTMLElement>(
@@ -624,28 +551,20 @@ export const KeyboardReachesEveryDevice: Story = {
]; ];
const items = within(document.body).getAllByRole("menuitemradio"); const items = within(document.body).getAllByRole("menuitemradio");
// Down to the last device, as someone reading the list would, and back up // Down to the last device and back up.
// again: a row can be hidden at either end.
for (const key of ["{ArrowDown}", "{ArrowUp}"]) for (const key of ["{ArrowDown}", "{ArrowUp}"])
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
await userEvent.keyboard(key); await userEvent.keyboard(key);
const focused = document.activeElement as HTMLElement; const focused = document.activeElement as HTMLElement;
await expect(focused).toHaveRole("menuitemradio"); await expect(focused).toHaveRole("menuitemradio");
// Nothing is drawn over the row the keyboard has just reached. Stated // Checked as overlap: a heading only covers rows while its section is on
// as overlap rather than as an edge, because whether a heading is in // screen.
// the way depends on whether its section is still on screen.
await expect(overlapping(focused, overlays)).toBeLessThanOrEqual(1); await expect(overlapping(focused, overlays)).toBeLessThanOrEqual(1);
} }
}, },
}; };
/** /** A section's heading stays at the top while the section is in view, and leaves with it. */
* A long list scrolled well into the microphones.
*
* The heading of the section you are in stays at the top of the list, so it is
* always clear which kind of device the rows below are. It leaves with its own
* section rather than stacking with the next one.
*/
export const HeadingsStayWhileScrolling: Story = { export const HeadingsStayWhileScrolling: Story = {
args: { args: {
...Default.args, ...Default.args,
@@ -672,14 +591,12 @@ export const HeadingsStayWhileScrolling: Story = {
`.${styles.deviceList}`, `.${styles.deviceList}`,
)!; )!;
const group = await menu.findByRole("group", { name: "Microphone" }); const group = await menu.findByRole("group", { name: "Microphone" });
// By class, not role: the heading is aria-hidden decoration, because the // By class: the heading is aria-hidden, and its group carries the name.
// group it belongs to is what carries the name.
const heading = group.querySelector<HTMLElement>( const heading = group.querySelector<HTMLElement>(
`.${styles.sectionHeading}`, `.${styles.sectionHeading}`,
)!; )!;
// Far enough in that the heading's own place in the list is well above the // Far enough that the heading is only on screen if it is stuck there.
// top of the scrollport: it is only still on screen if it is stuck there.
list.scrollTop += list.scrollTop +=
group.getBoundingClientRect().top - list.getBoundingClientRect().top + 80; group.getBoundingClientRect().top - list.getBoundingClientRect().top + 80;
@@ -690,7 +607,7 @@ export const HeadingsStayWhileScrolling: Story = {
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan( await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
scrollport.top, scrollport.top,
); );
// And it keeps clear of the menu's frame, as the meter does. // Clear of the menu's frame.
const frame = document.body const frame = document.body
.querySelector("[role='menu']")! .querySelector("[role='menu']")!
.getBoundingClientRect(); .getBoundingClientRect();
@@ -700,13 +617,7 @@ export const HeadingsStayWhileScrolling: Story = {
}, },
}; };
/** /** How far the most overlapping of `overlays` covers `element`, in px. */
* How far an element is covered, in pixels, by the most overlapping of others.
*
* Nothing in the DOM says an element is obscured, and an element scrolled flush
* to an edge of its container looks no different there from one a sticky
* heading is sitting on top of. The boxes are the only witness.
*/
function overlapping(element: Element, overlays: Element[]): number { function overlapping(element: Element, overlays: Element[]): number {
const box = element.getBoundingClientRect(); const box = element.getBoundingClientRect();
return overlays.reduce((worst, overlay) => { return overlays.reduce((worst, overlay) => {
@@ -717,13 +628,7 @@ function overlapping(element: Element, overlays: Element[]): number {
}, 0); }, 0);
} }
/** /** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
* The camera menu's blur toggle, which the keyboard reaches after the cameras.
*
* It is a checkbox item and a child of the menu rather than of the device list,
* so a focus ring hung on the list alone left it with the browser's own —
* which follows the pointer, and is what the ring exists to replace.
*/
export const FocusRingCoversTheBlurToggle: Story = { export const FocusRingCoversTheBlurToggle: Story = {
args: { args: {
...VideoUnmute.args, ...VideoUnmute.args,
@@ -738,16 +643,14 @@ export const FocusRingCoversTheBlurToggle: Story = {
name: /Blur background/, name: /Blur background/,
}); });
// Arrowed down past the cameras to the toggle, which is the last thing in // Arrow down past the cameras to the toggle.
// the menu.
for (let i = 0; i < 6 && document.activeElement !== toggle; i++) for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
await userEvent.keyboard("{ArrowDown}"); await userEvent.keyboard("{ArrowDown}");
await expect(document.activeElement).toBe(toggle); await expect(document.activeElement).toBe(toggle);
// The same ring the device rows get. // The same ring the device rows get.
await expect(outlineWidth(toggle)).toBeGreaterThan(0); await expect(outlineWidth(toggle)).toBeGreaterThan(0);
// And the pointer takes it away again, with the toggle still focused — so // And the pointer takes it away, with the toggle still focused.
// there is something to light up and it is not lit.
await userEvent.hover(toggle); await userEvent.hover(toggle);
await expect(document.activeElement).toBe(toggle); await expect(document.activeElement).toBe(toggle);
await expect(outlineWidth(toggle)).toBe(0); await expect(outlineWidth(toggle)).toBe(0);
@@ -27,8 +27,8 @@ import { type MediaDevices } from "../state/MediaDevices";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test"; import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
import type * as MediaDevicesContextModule from "../MediaDevicesContext"; import type * as MediaDevicesContextModule from "../MediaDevicesContext";
// The menu reads the devices once per render and the meter never does, so // The menu reads the devices on every render and the meter never does, so
// counting these calls counts how often the menu itself re-rendered. // these calls count the menu's own renders.
vi.mock("../MediaDevicesContext", async (importOriginal) => { vi.mock("../MediaDevicesContext", async (importOriginal) => {
const actual = await importOriginal<typeof MediaDevicesContextModule>(); const actual = await importOriginal<typeof MediaDevicesContextModule>();
return { ...actual, useMediaDevices: vi.fn(actual.useMediaDevices) }; return { ...actual, useMediaDevices: vi.fn(actual.useMediaDevices) };
@@ -61,8 +61,7 @@ function renderComponent(
} }
describe("MediaMuteAndSwitchButton", () => { describe("MediaMuteAndSwitchButton", () => {
// Only one test stubs the capture, but leaving it stubbed would follow // Only one test stubs the capture; don't let it leak into the rest.
// every later test in the run.
afterEach(restoreAudioCapture); afterEach(restoreAudioCapture);
test("renders", () => { test("renders", () => {
@@ -393,8 +392,7 @@ describe("MediaMuteAndSwitchButton", () => {
screen.getByRole("menuitemradio", { name: "Microphone 2" }), screen.getByRole("menuitemradio", { name: "Microphone 2" }),
); );
// In flight: nothing else can be picked, in either section, so a second // In flight: nothing else can be picked, in either section.
// request cannot overtake the first.
for (const name of ["Microphone 1", "Speakers", "Headset"]) { for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute( expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
"aria-disabled", "aria-disabled",
@@ -407,7 +405,7 @@ describe("MediaMuteAndSwitchButton", () => {
await promise; await promise;
}); });
// Settled: choosable again. // Settled: selectable again.
expect( expect(
screen.getByRole("menuitemradio", { name: "Microphone 1" }), screen.getByRole("menuitemradio", { name: "Microphone 1" }),
).not.toHaveAttribute("aria-disabled", "true"); ).not.toHaveAttribute("aria-disabled", "true");
@@ -418,9 +416,7 @@ describe("MediaMuteAndSwitchButton", () => {
test("lets go of a device switch that never arrives", async () => { test("lets go of a device switch that never arrives", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
// onSelect that never reports back is what a device removed mid-switch // onSelect never reports back, as when a device is removed mid-switch.
// looks like from here: the selection falls back to the default, so what
// was asked for never becomes the selection.
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
iconsAndLabels="audio" iconsAndLabels="audio"
@@ -447,8 +443,6 @@ describe("MediaMuteAndSwitchButton", () => {
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
// Otherwise every device, in both sections, stays unselectable for the
// rest of the call.
for (const name of ["Microphone 1", "Speakers", "Headset"]) { for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).not.toHaveAttribute( expect(screen.getByRole("menuitemradio", { name })).not.toHaveAttribute(
"aria-disabled", "aria-disabled",
@@ -485,20 +479,18 @@ describe("MediaMuteAndSwitchButton", () => {
screen.getByRole("menuitemradio", { name: "Microphone 2" }), screen.getByRole("menuitemradio", { name: "Microphone 2" }),
); );
// The second microphone is unplugged before the switch to it lands, so it // The second microphone is unplugged before the switch lands.
// is never going to become the selection.
rerender(withProviders(menu(mics.slice(0, 1)))); rerender(withProviders(menu(mics.slice(0, 1))));
// The speakers are choosable again without the menu being closed: a // Selectable again without closing the menu.
// request for a device that is gone is not in flight, it is over.
expect( expect(
screen.getByRole("menuitemradio", { name: "Headset" }), screen.getByRole("menuitemradio", { name: "Headset" }),
).not.toHaveAttribute("aria-disabled", "true"); ).not.toHaveAttribute("aria-disabled", "true");
}); });
test("redraws the meter and not the device rows around it", async () => { test("redraws the meter and not the device rows around it", async () => {
// A level arrives many times a second. Held in the menu it would reconcile // The level is held by the meter, so a moving level doesn't re-render the
// every device row on its way to the bars, so it is held in the meter. // menu.
const capture = stubAudioCapture(); const capture = stubAudioCapture();
const user = userEvent.setup(); const user = userEvent.setup();
const menuRenders = vi.mocked(useMediaDevices); const menuRenders = vi.mocked(useMediaDevices);
@@ -529,16 +521,13 @@ describe("MediaMuteAndSwitchButton", () => {
const meter = await screen.findByRole("meter"); const meter = await screen.findByRole("meter");
const settled = menuRenders.mock.calls.length; const settled = menuRenders.mock.calls.length;
// The meter follows elapsed time rather than frames, so hand-driven frames // The meter smooths by elapsed time, so hand-driven frames need a clock.
// need a clock to move at all: a frame every 16ms, from where the capture
// started.
let elapsed = performance.now(); let elapsed = performance.now();
const clock = vi const clock = vi
.spyOn(performance, "now") .spyOn(performance, "now")
.mockImplementation(() => (elapsed += 16)); .mockImplementation(() => (elapsed += 16));
// One frame per task, as a browser delivers them: drawing them all inside // One frame per task, as a browser delivers them: one act() would batch them.
// one act would let React batch what it would not batch in a browser.
for (let step = 1; step <= 8; step++) { for (let step = 1; step <= 8; step++) {
capture.speak(step / 8); capture.speak(step / 8);
await act(async () => { await act(async () => {
@@ -548,9 +537,7 @@ describe("MediaMuteAndSwitchButton", () => {
} }
clock.mockRestore(); clock.mockRestore();
// The level moved...
expect(meter.getAttribute("aria-valuenow")).not.toBe("0"); expect(meter.getAttribute("aria-valuenow")).not.toBe("0");
// ...and the menu around it did not render once on the way.
expect(menuRenders.mock.calls.length - settled).toBe(0); expect(menuRenders.mock.calls.length - settled).toBe(0);
}); });
@@ -572,10 +559,8 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Camera" })); await user.click(getByRole("button", { name: "Camera" }));
// Same selection pattern as the microphone menu.
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true }); screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false }); screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
// And background blur is still reachable from here.
expect( expect(
screen.getByRole("menuitemcheckbox", { name: "Blur background" }), screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -601,8 +586,7 @@ describe("MediaMuteAndSwitchButton", () => {
.getByRole("menuitemradio", { name: "Microphone 1" }) .getByRole("menuitemradio", { name: "Microphone 1" })
.closest("[data-focus-source]"); .closest("[data-focus-source]");
// The menu focuses whatever the pointer is over, so focus alone says // The menu focuses whatever the pointer is over, so focus alone says nothing.
// nothing about how someone is navigating.
expect(list).toHaveAttribute("data-focus-source", "pointer"); expect(list).toHaveAttribute("data-focus-source", "pointer");
await user.keyboard("{ArrowDown}"); await user.keyboard("{ArrowDown}");
@@ -636,8 +620,7 @@ describe("MediaMuteAndSwitchButton", () => {
.getByRole("menuitemradio", { name: "Microphone 2" }) .getByRole("menuitemradio", { name: "Microphone 2" })
.querySelector("input[type=radio]"); .querySelector("input[type=radio]");
expect(selected).toBeChecked(); expect(selected).toBeChecked();
// A read-only control is painted muted, which loses the accent fill that // readOnly would paint the selected radio muted.
// marks the selection and makes the menu differ from settings.
expect(selected).not.toHaveAttribute("readonly"); expect(selected).not.toHaveAttribute("readonly");
}); });
@@ -664,8 +647,6 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
// Includes the menu's own structure: wrappers between the menu and its
// items break the relationship the roles describe.
const menu = document.querySelector('[role="menu"]'); const menu = document.querySelector('[role="menu"]');
expect(await axe(menu as HTMLElement)).toHaveNoViolations(); expect(await axe(menu as HTMLElement)).toHaveNoViolations();
}); });
@@ -782,7 +763,7 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
// Shown rather than hidden, so the menu keeps its shape, but not choosable. // Shown, but not selectable.
const only = screen.getByRole("menuitemradio", { name: "Microphone 1" }); const only = screen.getByRole("menuitemradio", { name: "Microphone 1" });
expect(only).toHaveAttribute("aria-disabled", "true"); expect(only).toHaveAttribute("aria-disabled", "true");
}); });
@@ -799,8 +780,7 @@ describe("MediaMuteAndSwitchButton", () => {
]} ]}
selectedOption="mic1" selectedOption="mic1"
onSelect={vi.fn()} onSelect={vi.fn()}
// Safari enumerates no output devices at all, and offers no way to // As Safari: no outputs listed.
// choose one.
outputOptions={[]} outputOptions={[]}
selectedOutputOption={undefined} selectedOutputOption={undefined}
onSelectOutput={undefined} onSelectOutput={undefined}
@@ -809,8 +789,6 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
// A heading with nothing under it says the feature is broken. Audio is
// playing somewhere, so the section names that somewhere and disables it.
const speakers = screen const speakers = screen
.getAllByRole("group") .getAllByRole("group")
.find((group) => group.getAttribute("aria-label") === "Speaker")!; .find((group) => group.getAttribute("aria-label") === "Speaker")!;
@@ -818,8 +796,6 @@ describe("MediaMuteAndSwitchButton", () => {
expect(entries).toHaveLength(1); expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAccessibleName("Default"); expect(entries[0]).toHaveAccessibleName("Default");
expect(entries[0]).toHaveAttribute("aria-disabled", "true"); expect(entries[0]).toHaveAttribute("aria-disabled", "true");
// And marked as the selection: it is where audio is going, so an unchecked
// lone entry would read as nothing being chosen at all.
expect(entries[0]).toHaveAttribute("aria-checked", "true"); expect(entries[0]).toHaveAttribute("aria-checked", "true");
expect( expect(
within(entries[0]).getByRole("radio", { hidden: true }), within(entries[0]).getByRole("radio", { hidden: true }),
@@ -843,7 +819,6 @@ describe("MediaMuteAndSwitchButton", () => {
{ label: { type: "name", name: "Headset" }, id: "spk2" }, { label: { type: "name", name: "Headset" }, id: "spk2" },
]} ]}
selectedOutputOption="spk1" selectedOutputOption="spk1"
// No callback: nothing can be picked here.
onSelectOutput={undefined} onSelectOutput={undefined}
/>, />,
); );
@@ -856,7 +831,6 @@ describe("MediaMuteAndSwitchButton", () => {
expect( expect(
screen.getByRole("menuitemradio", { name: "Headset" }), screen.getByRole("menuitemradio", { name: "Headset" }),
).toHaveAttribute("aria-disabled", "true"); ).toHaveAttribute("aria-disabled", "true");
// The microphone section is unaffected.
expect( expect(
screen.getByRole("menuitemradio", { name: "Microphone 2" }), screen.getByRole("menuitemradio", { name: "Microphone 2" }),
).not.toHaveAttribute("aria-disabled", "true"); ).not.toHaveAttribute("aria-disabled", "true");
+23 -69
View File
@@ -62,10 +62,7 @@ export interface MediaMuteAndSwitchButtonProps {
outputOptions?: MenuOptions[]; outputOptions?: MenuOptions[];
/** The output option currently rendered as selected */ /** The output option currently rendered as selected */
selectedOutputOption?: string; selectedOutputOption?: string;
/** /** Picks an output device. Undefined disables the speaker section. */
* Called when an output device is picked. Undefined means no output can be
* chosen here, and the section renders disabled.
*/
onSelectOutput?: (id: string) => void; onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void; videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean; videoBlurEnabled?: boolean;
@@ -101,9 +98,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
videoBlurToggleClick, videoBlurToggleClick,
onSelect, onSelect,
}) => { }) => {
// Which device we have asked for but not yet been given. Carries the kind as // Requested but not yet selected. Keyed by kind too, since Chrome uses
// well as the id, because an input and an output can share an id: "default" // "default" for both an input and an output.
// names both on Chrome.
const [plannedSelection, setPlannedSelection] = useState<{ const [plannedSelection, setPlannedSelection] = useState<{
kind: "input" | "output"; kind: "input" | "output";
id: string; id: string;
@@ -111,7 +107,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback((open: boolean): void => { const onOpenChange = useCallback((open: boolean): void => {
setMenuOpen(open); setMenuOpen(open);
// A request that never arrived does not outlive the menu it was made in. // Drop a request that never arrived.
if (!open) setPlannedSelection(null); if (!open) setPlannedSelection(null);
}, []); }, []);
const isBusy = busy ?? false; const isBusy = busy ?? false;
@@ -144,18 +140,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
[], [],
); );
// The menu is portalled outside the call root, so nothing in the stylesheets // Measured on the call area: CSS can't size the portalled menu against it.
// can size it against the call. Measure the call area rather than the window,
// or the menu is wrong wherever Element Call is not the whole page.
const rootElement = useRootElement(); const rootElement = useRootElement();
const [listMaxHeight, setListMaxHeight] = useState<number>(); const [listMaxHeight, setListMaxHeight] = useState<number>();
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
// Followed rather than measured once: a host can resize the space Element // Followed, since a host can resize the call while the menu is open.
// Call is drawn in while the menu is open — a panel animating, a window
// dragged, a phone turned — and a bound taken on opening then describes a
// call area that no longer exists. Quantised before it reaches React, so a
// resize re-renders only when the bound itself moves.
const subscription = observeElementSize$(rootElement) const subscription = observeElementSize$(rootElement)
.pipe( .pipe(
map(({ height }) => map(({ height }) =>
@@ -167,23 +157,17 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
return (): void => subscription.unsubscribe(); return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]); }, [menuOpen, rootElement]);
// The meter sits over the foot of the scrolling list, so the list has to // Kept clear at the list's foot, so a row reached by keyboard isn't under
// keep that much of itself clear: a row scrolled to by the keyboard would // the meter.
// otherwise arrive underneath it, half-read. Its own height, measured,
// because the failure states are two lines where a level is one.
const [meterHeight, meter] = useMeasuredHeight(); const [meterHeight, meter] = useMeasuredHeight();
// The headings stand over the head of the list, so it has to keep their // Likewise at its head, for the sticky headings.
// height clear too — the same bargain as the meter, at the other end. One
// measurement serves both: the sections are headed alike.
const [headingHeight, heading] = useMeasuredHeight(); const [headingHeight, heading] = useMeasuredHeight();
useEffect(() => { useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]); }, [menuOpen, devices]);
// The mute control differs between the two only in which button it is and
// what it is called; how it behaves is the same, and was worth saying once.
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton; const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
const button = ( const button = (
<MuteButton <MuteButton
@@ -201,7 +185,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/> />
); );
// Only the camera menu carries a toggle, and only when the caller offers one.
const toggles = const toggles =
iconsAndLabels === "video" && videoBlurToggleClick !== undefined iconsAndLabels === "video" && videoBlurToggleClick !== undefined
? [ ? [
@@ -231,7 +214,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
break; break;
} }
/** The text shown for a device, whichever kind of label it carries. */
const labelText = ( const labelText = (
label: MenuOptions["label"], label: MenuOptions["label"],
numbered: (n: number) => string, numbered: (n: number) => string,
@@ -252,13 +234,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
} }
}; };
// A device we asked for that has not arrived yet. Until it does, nothing in // Nothing can be picked while a requested device is on offer but not yet
// the menu can be picked, so a second request cannot overtake the first. // selected, so a second request can't overtake it.
//
// A request only counts as in flight while the device is still on offer. One
// that is removed before it takes effect never arrives — the selection falls
// back to the default instead — and waiting for it would leave every device
// in both sections unselectable for the rest of the call.
const plannedOutput = plannedSelection?.kind === "output"; const plannedOutput = plannedSelection?.kind === "output";
const selectedOfPlannedKind = plannedOutput const selectedOfPlannedKind = plannedOutput
? selectedOutputOption ? selectedOutputOption
@@ -269,17 +246,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
plannedSelection.id !== selectedOfPlannedKind && plannedSelection.id !== selectedOfPlannedKind &&
offeredOfPlannedKind?.some(({ id }) => id === plannedSelection.id) === true; offeredOfPlannedKind?.some(({ id }) => id === plannedSelection.id) === true;
// Safari enumerates no output devices at all, and offers no way to choose // Safari lists no outputs: show a disabled, selected Default rather than an
// one, so the list arrives empty. The section is shown all the same — audio // empty section.
// is playing somewhere — naming that somewhere and disabling it like any
// single entry. A heading with nothing beneath it reads as a broken feature,
// and leaves the menu a different shape on one browser.
const noOutputsListed = outputOptions?.length === 0; const noOutputsListed = outputOptions?.length === 0;
const speakerOptions: MenuOptions[] | undefined = noOutputsListed const speakerOptions: MenuOptions[] | undefined = noOutputsListed
? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }] ? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }]
: outputOptions; : outputOptions;
// And it is the selection, not merely the only row: it is where audio is
// going. An unchecked lone entry reads as nothing being chosen at all.
const selectedSpeaker = noOutputsListed const selectedSpeaker = noOutputsListed
? DEFAULT_OUTPUT_ID ? DEFAULT_OUTPUT_ID
: selectedOutputOption; : selectedOutputOption;
@@ -292,30 +264,23 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
numbered: (n: number) => string, numbered: (n: number) => string,
): ReactElement[] => { ): ReactElement[] => {
const list = items ?? []; const list = items ?? [];
// Shown but not choosable when nothing can be picked here, or when there is // Disabled rather than hidden, so the menu keeps its shape.
// only one device. The entry stays visible so the menu keeps the same shape
// on every platform.
const disabled = select === undefined || list.length <= 1 || settling; const disabled = select === undefined || list.length <= 1 || settling;
return list.map(({ label, id }) => ( return list.map(({ label, id }) => (
<MenuItem <MenuItem
// A radio input inside a button is invalid, and the menu needs an // A radio input may not sit inside a button.
// element it can give menuitemradio semantics to.
as="div" as="div"
hideChevron hideChevron
disabled={disabled} disabled={disabled}
label={labelText(label, numbered)} label={labelText(label, numbered)}
Icon={ Icon={
// Inert, not aria-hidden: a form control inside a menu item must be // Inert, not aria-hidden: aria-hidden alone leaves it focusable.
// out of the focus order and out of the accessibility tree, and
// aria-hidden alone leaves it focusable. The item's aria-checked is
// what conveys the selection.
<span inert> <span inert>
<RadioInput <RadioInput
checked={selected === id} checked={selected === id}
disabled={disabled} disabled={disabled}
// Not readOnly: that styles the control as muted, losing the // Not readOnly, which mutes the selected fill. The item handles
// accent fill that marks the selection. The menu item owns the // the click.
// interaction, so the change handler has nothing to do.
onChange={(): void => {}} onChange={(): void => {}}
/> />
</span> </span>
@@ -377,8 +342,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
> >
<div <div
ref={trackFocusSource} ref={trackFocusSource}
// Transparent to assistive technology, so the menu still sees its // Keeps the items the menu's own children for assistive tech.
// items as its own children.
role="none" role="none"
className={styles.deviceList} className={styles.deviceList}
style={ style={
@@ -395,11 +359,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{iconsAndLabels === "audio" && speakerOptions && ( {iconsAndLabels === "audio" && speakerOptions && (
<> <>
{/* A menu may only contain items, separators and groups, so each {/* A menu may only contain items, separators and groups, so each
heading belongs to a group rather than sitting beside the heading is a hidden part of a named group. */}
items it names. */}
<div role="group" aria-label={t("settings.devices.speaker")}> <div role="group" aria-label={t("settings.devices.speaker")}>
{/* The heading is decoration: the group carries the name, and
a menu may only contain items, separators and groups. */}
<div aria-hidden className={styles.sectionHeading}> <div aria-hidden className={styles.sectionHeading}>
<MenuTitle title={t("settings.devices.speaker")} /> <MenuTitle title={t("settings.devices.speaker")} />
</div> </div>
@@ -417,8 +378,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
<div ref={heading} aria-hidden className={styles.sectionHeading}> <div ref={heading} aria-hidden className={styles.sectionHeading}>
<MenuTitle title={optionsButtonLabel} /> <MenuTitle title={optionsButtonLabel} />
</div> </div>
{/* The heading sits outside, so the meter can never ride up over it: {/* Apart from the heading, so the sticky meter can't ride over it. */}
sticky only holds while this block is in view. */}
<div role="none"> <div role="none">
{deviceItems( {deviceItems(
"input", "input",
@@ -431,8 +391,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
<LiveMicrophoneLevelMeter <LiveMicrophoneLevelMeter
ref={meter} ref={meter}
deviceId={selectedOption} deviceId={selectedOption}
// Only while the menu is open, so nothing holds a second // Only while open, so the microphone isn't held all call.
// capture of the microphone for the length of a call.
active={menuOpen} active={menuOpen}
className={styles.stickyMeter} className={styles.stickyMeter}
/> />
@@ -457,12 +416,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
); );
}; };
/** /** Follows an element's height. */
* Follows an element's height, for the two pieces of chrome that stand over the
* scrolling device list. Both have to keep their own height clear of it, and
* neither height is knowable in advance: a heading wraps, and the meter's
* failure states are two lines where a level is one.
*/
function useMeasuredHeight(): [ function useMeasuredHeight(): [
number | undefined, number | undefined,
(element: HTMLElement | null) => (() => void) | undefined, (element: HTMLElement | null) => (() => void) | undefined,
+10 -32
View File
@@ -6,25 +6,18 @@ Please see LICENSE in the repository root for full details.
*/ */
.meter { .meter {
/* The icon column, the same width as a device row's, so the bars start where /* A device row's icon column, so the bars start where the names do. */
the device names start. */
--meter-icon-size: 24px; --meter-icon-size: 24px;
/* Compound's radio control, which the icon has to share a centre line with. */ /* Compound's radio size, whose centre line the icon shares. */
--device-control-size: 20px; --device-control-size: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--cpd-space-3x); gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-2x); padding-block: var(--cpd-space-2x);
/* The icon shares a centre with the radio controls in the device rows above, /* Centres the icon on the radio column: less the row's inset border and half
which sit one 4x padding in from the menu's edge. Two things pull this row the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
out of line with them, and both come off the padding: the row is inset by a
border width so that the menu's frame stays visible behind it (see
`.stickyMeter`), and the icon is wider than a radio control, so it starts
half that difference further left for the two to share a centre. Both are
measured in the MeterAlignsWithTheDeviceRows story rather than trusted:
jsdom lays nothing out, so only a real browser can hold this. */
padding-inline-start: calc( padding-inline-start: calc(
var(--cpd-space-4x) - var(--cpd-border-width-1) - var(--cpd-space-4x) - var(--cpd-border-width-1) -
(var(--meter-icon-size) - var(--device-control-size)) / 2 (var(--meter-icon-size) - var(--device-control-size)) / 2
@@ -40,27 +33,15 @@ Please see LICENSE in the repository root for full details.
} }
.segments { .segments {
/* A bar and the space beside it are always the same size; what changes with /* Bars keep one size and the width decides how many; the component reads the
the width available is how many bars there are. Spreading a fixed number of sizes back from here. */
bars instead would make the meter a different shape in every place it is
used, and close them up into one solid block wherever the space ran short —
and a meter whose bars touch stops reading as a count, which is what
carries the level for anyone who cannot rely on the colour.
Both sizes are set here and nowhere else: the component reads them back off
the rendered bars to work out how many fit. */
flex: 1; flex: 1;
/* The width decides the count, never the other way round. Containment is /* So the bars never set the menu's width. */
what enforces that: without it the bars are both a floor under the width
and the widest thing in the menu, so the meter would set the menu's width
and the device names — which are what a person is reading — would have to
fit around it. */
contain: inline-size; contain: inline-size;
min-inline-size: 0; min-inline-size: 0;
display: flex; display: flex;
align-items: center; align-items: center;
/* Half again as wide as a bar, as the design has it: bars set as close as /* Half again a bar's width, per the design. */
their own width read as a solid block long before they touch. */
gap: var(--cpd-space-1-5x); gap: var(--cpd-space-1-5x);
} }
@@ -72,16 +53,13 @@ Please see LICENSE in the repository root for full details.
background: var(--cpd-color-bg-subtle-primary); background: var(--cpd-color-bg-subtle-primary);
} }
/* Filled segments carry the level by count as well as by colour, so the meter /* The count of lit bars carries the level as well as their colour. */
stays readable without relying on hue. */
.segmentLit { .segmentLit {
background: var(--cpd-color-bg-accent-rest); background: var(--cpd-color-bg-accent-rest);
} }
.message { .message {
color: var(--cpd-color-text-secondary); color: var(--cpd-color-text-secondary);
/* A paragraph brings a margin below it, which in a centred row does not push /* A paragraph's margin would lift the text off centre. */
the text down but lifts it: the margin box is what gets centred, so the
words end up above the middle and the icon beside them looks low. */
margin-block: 0; margin-block: 0;
} }
+11 -44
View File
@@ -16,15 +16,7 @@ import {
import styles from "./MicrophoneLevelMeter.module.css"; import styles from "./MicrophoneLevelMeter.module.css";
import { LEVEL_SCALE } from "../state/MicrophoneLevel"; import { LEVEL_SCALE } from "../state/MicrophoneLevel";
/** /** Roughly the menu's width. It only decides how many bars fit. */
* A width to show the meter at, close to the menu it lives in.
*
* Not a copy of the menu's width, and nothing depends on the two agreeing: a
* bar and the gap beside it are a fixed size now, so this only decides how many
* bars there is room for. Without a width at all the stories would shrink-wrap
* to almost nothing and show a meter two bars wide, which is no use to anyone
* looking at them.
*/
const STORY_WIDTH = 256; const STORY_WIDTH = 256;
const meta = { const meta = {
@@ -47,10 +39,7 @@ const meta = {
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
/** /** A quiet room: hiss below the noise floor lights nothing. */
* A quiet room. Nothing is lit: room hiss below the noise floor must not read
* as "it can hear me".
*/
export const Silent: Story = { export const Silent: Story = {
args: { state: { type: "level", level: 0 } }, args: { state: { type: "level", level: 0 } },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@@ -65,11 +54,7 @@ export const QuietSpeech: Story = {
export const NormalSpeech: Story = { export const NormalSpeech: Story = {
args: { state: { type: "level", level: 12 } }, args: { state: { type: "level", level: 12 } },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
// Shown at something like the width of the menu, so the meter in a story // A floor, not a count: the count follows from the design's bar and gap sizes.
// reads like the meter in a call rather than like a handful of bars. The
// design's own mock has sixteen of them at this width; a floor rather than
// a count, because the number follows from the bar and gap sizes and those
// are the design's to change.
await expect( await expect(
canvasElement.getElementsByClassName(styles.segment).length, canvasElement.getElementsByClassName(styles.segment).length,
).toBeGreaterThanOrEqual(15); ).toBeGreaterThanOrEqual(15);
@@ -80,10 +65,7 @@ export const LoudSpeech: Story = {
args: { state: { type: "level", level: LEVEL_SCALE } }, args: { state: { type: "level", level: LEVEL_SCALE } },
}; };
/** /** The three volumes differ in how many bars are lit, not only in colour. */
* The three volumes differ by how many bars are lit, so the level survives
* greyscale and a screen reader as well as it survives colour.
*/
export const VolumesAreDistinguishable: Story = { export const VolumesAreDistinguishable: Story = {
args: { state: { type: "level", level: 5 } }, args: { state: { type: "level", level: 5 } },
play: async ({ canvasElement, mount }) => { play: async ({ canvasElement, mount }) => {
@@ -96,17 +78,12 @@ export const VolumesAreDistinguishable: Story = {
String(level), String(level),
); );
} }
// Three different counts, rising: the level is carried by how many bars
// are lit, not by their colour alone.
await expect(new Set(lit).size).toBe(lit.length); await expect(new Set(lit).size).toBe(lit.length);
await expect(lit).toEqual([...lit].sort((a, b) => a - b)); await expect(lit).toEqual([...lit].sort((a, b) => a - b));
}, },
}; };
/** /** Permission refused: a message with a next action, not a still meter. */
* Permission refused. A message with a next action, never a still meter that
* reads as silence.
*/
export const PermissionDenied: Story = { export const PermissionDenied: Story = {
args: { state: { type: "permission-denied" } }, args: { state: { type: "permission-denied" } },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@@ -118,7 +95,7 @@ export const PermissionDenied: Story = {
}, },
}; };
/** No input device at all, told apart from a refusal. */ /** No input device, told apart from a refusal. */
export const NoDevice: Story = { export const NoDevice: Story = {
args: { state: { type: "no-device" } }, args: { state: { type: "no-device" } },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@@ -126,9 +103,7 @@ export const NoDevice: Story = {
await expect(canvas.queryByRole("meter")).toBeNull(); await expect(canvas.queryByRole("meter")).toBeNull();
await expect(canvas.getByText(/No microphone found/)).toBeVisible(); await expect(canvas.getByText(/No microphone found/)).toBeVisible();
// The icon sits on the middle of the words, however many lines they run to. // The icon sits on the middle of the text, however many lines it runs to.
// A paragraph's own margin would centre its margin box instead, leaving the
// text high and the icon looking low beside it.
const middle = (element: Element): number => { const middle = (element: Element): number => {
const box = element.getBoundingClientRect(); const box = element.getBoundingClientRect();
return box.top + box.height / 2; return box.top + box.height / 2;
@@ -139,15 +114,7 @@ export const NoDevice: Story = {
}, },
}; };
/** /** The same meter at two widths: the bars keep their size and only their count changes. */
* The same meter at two widths.
*
* A bar and the space beside it are always the same size; what changes is how
* many bars there are. Spreading a fixed number of bars instead would make the
* meter a different shape in every place it is used, and close the bars up into
* one block wherever the space ran short — and bars that touch cannot be
* counted, which is what carries the level without colour.
*/
export const ShapeStaysTheSameAtAnyWidth: Story = { export const ShapeStaysTheSameAtAnyWidth: Story = {
args: { state: { type: "level", level: 12 } }, args: { state: { type: "level", level: 12 } },
play: async ({ mount, args }) => { play: async ({ mount, args }) => {
@@ -157,17 +124,17 @@ export const ShapeStaysTheSameAtAnyWidth: Story = {
await expect(narrow.bar).toBe(wide.bar); await expect(narrow.bar).toBe(wide.bar);
await expect(narrow.gap).toBe(wide.gap); await expect(narrow.gap).toBe(wide.gap);
await expect(narrow.count).toBeLessThan(wide.count); await expect(narrow.count).toBeLessThan(wide.count);
// And the bars are still bars, not one run of colour. // The bars are still separate, not one run of colour.
await expect(narrow.gap).toBeGreaterThan(0); await expect(narrow.gap).toBeGreaterThan(0);
}, },
}; };
/** How many bars are painted as carrying level, rather than as empty. */ /** How many bars are lit. */
function litSegments(canvasElement: HTMLElement): number { function litSegments(canvasElement: HTMLElement): number {
return canvasElement.getElementsByClassName(styles.segmentLit).length; return canvasElement.getElementsByClassName(styles.segmentLit).length;
} }
/** Renders the meter at one width and reports the shape of its bars. */ /** Renders the meter at one width and reports its bars' shape. */
async function measureAt( async function measureAt(
mount: (ui: JSX.Element) => Promise<unknown>, mount: (ui: JSX.Element) => Promise<unknown>,
args: MicrophoneLevelMeterProps, args: MicrophoneLevelMeterProps,
@@ -25,7 +25,6 @@ describe("MicrophoneLevelMeter", () => {
const denied = render( const denied = render(
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />, <MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
); );
// A next action, not a flat meter that reads as silence.
expect( expect(
denied.getByText(/Microphone access is blocked/), denied.getByText(/Microphone access is blocked/),
).toBeInTheDocument(); ).toBeInTheDocument();
+8 -26
View File
@@ -28,12 +28,8 @@ export interface LiveMicrophoneLevelMeterProps {
} }
/** /**
* The level meter, wired to a live capture of a microphone. * The meter, wired to a live capture. Its own component so a changing level
* * re-renders the meter, not its parent.
* A component of its own so that a level which does change redraws the meter
* and not whatever is rendered beside it. A moving level arrives many times a
* second; held in the menu, it would reconcile every device row on the way to
* the bars.
*/ */
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({ export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
deviceId, deviceId,
@@ -48,27 +44,18 @@ export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
export interface MicrophoneLevelMeterProps { export interface MicrophoneLevelMeterProps {
state: MicrophoneState; state: MicrophoneState;
className?: string; className?: string;
/**
* The meter's own element. Its height is what a scroll container has to keep
* clear to stop the meter covering the row it has just scrolled to.
*/
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
} }
/** /** The input level of a microphone, or why there is none. */
* The live input level of the selected microphone, shown beneath it, or the
* reason there is no level to show.
*/
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
state, state,
className, className,
ref, ref,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// How many bars there is room for. The bars never change size, so this is // Bars keep one size, so their count follows the width. Starts full so the
// what absorbs a change of width. Starts at the full count so that the first // first paint, and jsdom, draw a whole meter.
// paint is a meter rather than a single bar, and so that a renderer with no
// layout at all — jsdom — still draws the whole thing.
const [barCount, setBarCount] = useState(LEVEL_SCALE); const [barCount, setBarCount] = useState(LEVEL_SCALE);
const track = useCallback( const track = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => { (element: HTMLDivElement | null): (() => void) | undefined => {
@@ -112,8 +99,7 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
key={i} key={i}
aria-hidden aria-hidden
className={classNames(styles.segment, { className={classNames(styles.segment, {
// The level is a share of the scale, not a count of bars: how // The level is a share of the scale, not a bar count.
// many stand for it depends on how many there are.
[styles.segmentLit]: [styles.segmentLit]:
i < Math.round((state.level / LEVEL_SCALE) * barCount), i < Math.round((state.level / LEVEL_SCALE) * barCount),
})} })}
@@ -125,15 +111,11 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
); );
}; };
/** /** How many bars fit across `width`, measured so the stylesheet alone sets their size. */
* How many bars fit across `width`, measured off a rendered one rather than
* told: a bar's size is a design question, settled in the stylesheet, and
* reading it back is what keeps it from being settled twice.
*/
function barsThatFit(track: HTMLElement, width: number): number { function barsThatFit(track: HTMLElement, width: number): number {
const gap = Number.parseFloat(getComputedStyle(track).columnGap); const gap = Number.parseFloat(getComputedStyle(track).columnGap);
const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0; const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
// A renderer that lays nothing out tells us nothing; keep the full count. // No layout (jsdom): keep the full count.
if (!(bar > 0) || !(gap >= 0)) return LEVEL_SCALE; if (!(bar > 0) || !(gap >= 0)) return LEVEL_SCALE;
return Math.max(1, Math.floor((width + gap) / (bar + gap))); return Math.max(1, Math.floor((width + gap) / (bar + gap)));
} }
+2 -4
View File
@@ -11,9 +11,7 @@ import { renderHook } from "@testing-library/react";
import { useMicrophoneLevel } from "./useMicrophoneLevel"; import { useMicrophoneLevel } from "./useMicrophoneLevel";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test"; import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
// The capture itself, and releasing it, belong to observeMicrophoneState$ and // Capture and release are covered in MicrophoneLevel.test.ts; this covers the bridge.
// are covered in src/state/MicrophoneLevel.test.ts. What is left here is the
// bridging: when the hook watches, and what it reports before it has an answer.
describe("useMicrophoneLevel", () => { describe("useMicrophoneLevel", () => {
afterEach(restoreAudioCapture); afterEach(restoreAudioCapture);
@@ -36,7 +34,7 @@ describe("useMicrophoneLevel", () => {
rerender({ id: "mic2" }); rerender({ id: "mic2" });
// No level carried over: the meter reads the new device or nothing at all. // No level carried over from the previous device.
expect(result.current).toEqual({ type: "level", level: 0 }); expect(result.current).toEqual({ type: "level", level: 0 });
}); });
}); });
+2 -10
View File
@@ -14,14 +14,7 @@ import {
const IDLE: MicrophoneState = { type: "level", level: 0 }; const IDLE: MicrophoneState = { type: "level", level: 0 };
/** /** The live level of a microphone, captured only while `active`. */
* Reads the live input level of a microphone, while `active`.
*
* A bridge and nothing else: the capture, its lifetime and the maths belong to
* {@link observeMicrophoneState$}. Scoped to `active` so the device is held
* only while whatever shows the meter is on screen, rather than for the length
* of a call.
*/
export function useMicrophoneLevel( export function useMicrophoneLevel(
deviceId: string | undefined, deviceId: string | undefined,
active: boolean, active: boolean,
@@ -30,8 +23,7 @@ export function useMicrophoneLevel(
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
// Idle first, so a new device starts from nothing rather than from the // Idle first, so a new device doesn't start from the previous level.
// level the previous one was reading.
setState(IDLE); setState(IDLE);
const subscription = observeMicrophoneState$(deviceId).subscribe(setState); const subscription = observeMicrophoneState$(deviceId).subscribe(setState);
return (): void => subscription.unsubscribe(); return (): void => subscription.unsubscribe();
+2 -6
View File
@@ -191,7 +191,6 @@ describe("LobbyView microphone level", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
// Put navigator back, or every later test in the run inherits the stub.
if (realMediaDevices === undefined) { if (realMediaDevices === undefined) {
Reflect.deleteProperty(navigator, "mediaDevices"); Reflect.deleteProperty(navigator, "mediaDevices");
} else { } else {
@@ -199,7 +198,6 @@ describe("LobbyView microphone level", () => {
} }
}); });
/** Just enough of the Web Audio and capture APIs for the meter to run. */
function stubAudioCapture(): void { function stubAudioCapture(): void {
vi.stubGlobal( vi.stubGlobal(
"AudioContext", "AudioContext",
@@ -217,8 +215,7 @@ describe("LobbyView microphone level", () => {
public close(): void {} public close(): void {}
}, },
); );
// Only this property: replacing navigator wholesale drops the getters on // Only this property: replacing navigator loses getters like userAgent.
// its prototype, such as userAgent.
Object.defineProperty(navigator, "mediaDevices", { Object.defineProperty(navigator, "mediaDevices", {
configurable: true, configurable: true,
value: { value: {
@@ -241,8 +238,7 @@ describe("LobbyView microphone level", () => {
}, },
} as unknown as Partial<MediaDevices>); } as unknown as Partial<MediaDevices>);
// The meter lives with the microphone picker, which the pre-join screen // Pre-join reaches the meter through the same chevron as a call.
// reaches through the same chevron as a call in progress.
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
expect( expect(
+4 -12
View File
@@ -14,7 +14,6 @@ vi.mock("../Platform", () => ({
}, },
isFirefox: (): boolean => false, isFirefox: (): boolean => false,
})); }));
// One observer per device kind, so a test can add and remove hardware.
const observers = vi.hoisted( const observers = vi.hoisted(
() => new Map<string, { next: (devices: unknown[]) => void }>(), () => new Map<string, { next: (devices: unknown[]) => void }>(),
); );
@@ -75,7 +74,7 @@ function device(deviceId: string, label: string, groupId = deviceId): object {
return { deviceId, label, groupId, kind: "audioinput" }; return { deviceId, label, groupId, kind: "audioinput" };
} }
/** Replaces the hardware of one kind, as the browser would report it. */ /** Replaces the devices of one kind, as the browser reports them. */
function setDevices(kind: string, devices: object[]): void { function setDevices(kind: string, devices: object[]): void {
const observer = observers.get(kind); const observer = observers.get(kind);
if (observer === undefined) throw new Error(`nothing observing ${kind}`); if (observer === undefined) throw new Error(`nothing observing ${kind}`);
@@ -103,7 +102,7 @@ describe("MediaDevices selection", () => {
devices.audioInput.select("mic2"); devices.audioInput.select("mic2");
// A later call on the same machine reads the same stored preference. // A later call reads the same stored preference.
expect(newMediaDevices().audioInput.selected$.value?.id).toBe("mic2"); expect(newMediaDevices().audioInput.selected$.value?.id).toBe("mic2");
}); });
@@ -112,7 +111,6 @@ describe("MediaDevices selection", () => {
setDevices("audioinput", [device("mic1", "Microphone 1")]); setDevices("audioinput", [device("mic1", "Microphone 1")]);
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]); expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
// A headset is plugged in.
setDevices("audioinput", [ setDevices("audioinput", [
device("mic1", "Microphone 1"), device("mic1", "Microphone 1"),
device("mic2", "Headset"), device("mic2", "Headset"),
@@ -122,7 +120,6 @@ describe("MediaDevices selection", () => {
"mic2", "mic2",
]); ]);
// And unplugged again.
setDevices("audioinput", [device("mic1", "Microphone 1")]); setDevices("audioinput", [device("mic1", "Microphone 1")]);
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]); expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
}); });
@@ -136,7 +133,6 @@ describe("MediaDevices selection", () => {
devices.audioInput.select("mic2"); devices.audioInput.select("mic2");
expect(devices.audioInput.selected$.value?.id).toBe("mic2"); expect(devices.audioInput.selected$.value?.id).toBe("mic2");
// The headset is unplugged mid-call.
setDevices("audioinput", [device("mic1", "Microphone 1")]); setDevices("audioinput", [device("mic1", "Microphone 1")]);
expect(devices.audioInput.selected$.value?.id).toBe("mic1"); expect(devices.audioInput.selected$.value?.id).toBe("mic1");
@@ -145,7 +141,6 @@ describe("MediaDevices selection", () => {
test("falls back when the remembered device is absent", () => { test("falls back when the remembered device is absent", () => {
const devices = newMediaDevices(); const devices = newMediaDevices();
setDevices("audioinput", [device("mic1", "Microphone 1")]); setDevices("audioinput", [device("mic1", "Microphone 1")]);
// Remembered from a previous call, on hardware that is not here now.
devices.audioInput.select("a-device-from-last-time"); devices.audioInput.select("a-device-from-last-time");
expect(devices.audioInput.selected$.value?.id).toBe("mic1"); expect(devices.audioInput.selected$.value?.id).toBe("mic1");
@@ -153,7 +148,7 @@ describe("MediaDevices selection", () => {
test("falls back to numbered labels when labels are unavailable", () => { test("falls back to numbered labels when labels are unavailable", () => {
const devices = newMediaDevices(); const devices = newMediaDevices();
// The browser withholds names until permission has been granted. // Names are withheld until permission is granted.
setDevices("audioinput", [device("mic1", ""), device("mic2", "")]); setDevices("audioinput", [device("mic1", ""), device("mic2", "")]);
expect([...devices.audioInput.available$.value.values()]).toEqual([ expect([...devices.audioInput.available$.value.values()]).toEqual([
@@ -167,10 +162,7 @@ describe("MediaDevices selection", () => {
setDevices("audiooutput", [device("spk1", "Speakers")]); setDevices("audiooutput", [device("spk1", "Speakers")]);
const available = devices.audioOutput.available$.value; const available = devices.audioOutput.available$.value;
// Default follows the operating system and re-points when it changes, so // Default is its own entry, unnamed: which device it resolves to isn't knowable.
// it is its own choice rather than an alias for the device it resolves to.
// It carries no name of its own precisely because which device it resolves
// to is not knowable from here.
expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" }); expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" });
expect(available.get("")).toEqual({ type: "default", name: null }); expect(available.get("")).toEqual({ type: "default", name: null });
}); });
+8 -18
View File
@@ -23,8 +23,6 @@ describe("segmentsForVolume", () => {
}); });
test("shows nothing for the hiss of a quiet room", () => { test("shows nothing for the hiss of a quiet room", () => {
// Without a noise floor these light the first segments permanently, which
// reads as "it can hear me" when nobody is speaking.
expect(segmentsForVolume(0.005)).toBe(0); expect(segmentsForVolume(0.005)).toBe(0);
expect(segmentsForVolume(0.015)).toBe(0); expect(segmentsForVolume(0.015)).toBe(0);
}); });
@@ -40,8 +38,7 @@ describe("segmentsForVolume", () => {
}); });
test("moves the meter visibly for normal speech", () => { test("moves the meter visibly for normal speech", () => {
// Ordinary speech should reach the middle of the meter, not scrape along // Ordinary speech reaches the middle of the meter.
// the floor: a meter that barely moves reads as a broken microphone.
expect(segmentsForVolume(0.2)).toBeGreaterThanOrEqual(LEVEL_SCALE / 4); expect(segmentsForVolume(0.2)).toBeGreaterThanOrEqual(LEVEL_SCALE / 4);
}); });
@@ -65,16 +62,14 @@ describe("smoothVolume", () => {
}); });
test("registers a syllable as it starts", () => { test("registers a syllable as it starts", () => {
// Most of the way there within one attack time constant, so speech does // Most of the way within one attack time constant.
// not lag the speaker.
expect(smoothVolume(0, 1, ATTACK_MS)).toBeGreaterThan(0.6); expect(smoothVolume(0, 1, ATTACK_MS)).toBeGreaterThan(0.6);
}); });
test("rides over the gaps between words", () => { test("rides over the gaps between words", () => {
// A pause of a few tens of milliseconds should not collapse the meter, or // A short pause doesn't collapse the meter...
// it flickers rather than reading as a level.
expect(smoothVolume(1, 0, 30)).toBeGreaterThan(0.7); expect(smoothVolume(1, 0, 30)).toBeGreaterThan(0.7);
// A real silence still brings it down. // ...but a real silence brings it down.
expect(smoothVolume(1, 0, RELEASE_MS * 3)).toBeLessThan(0.1); expect(smoothVolume(1, 0, RELEASE_MS * 3)).toBeLessThan(0.1);
}); });
@@ -98,8 +93,7 @@ describe("observeMicrophoneState$", () => {
const capture = stubAudioCapture(); const capture = stubAudioCapture();
const subscription = observeMicrophoneState$("mic1").subscribe(); const subscription = observeMicrophoneState$("mic1").subscribe();
// The user gives up on the permission prompt and closes the menu, and only // The menu closes before the browser hands the microphone over.
// then does the browser hand the microphone over.
subscription.unsubscribe(); subscription.unsubscribe();
capture.grant(); capture.grant();
await vi.waitFor(() => expect(capture.track.stop).toHaveBeenCalled()); await vi.waitFor(() => expect(capture.track.stop).toHaveBeenCalled());
@@ -120,8 +114,7 @@ describe("observeMicrophoneState$", () => {
test("gives the microphone back when the audio graph fails to build", async () => { test("gives the microphone back when the audio graph fails to build", async () => {
const capture = stubAudioCapture(); const capture = stubAudioCapture();
// The graph fails only after getUserMedia has handed the device over, so // Fails only after getUserMedia has granted the device.
// there is a live capture to lose.
vi.stubGlobal( vi.stubGlobal(
"AudioContext", "AudioContext",
class { class {
@@ -138,8 +131,6 @@ describe("observeMicrophoneState$", () => {
capture.grant(); capture.grant();
await vi.waitFor(() => expect(seen).toContain("no-device")); await vi.waitFor(() => expect(seen).toContain("no-device"));
// Without this the microphone stays open, and its in-use light on, behind
// a meter that says it is unavailable.
expect(capture.track.stop).toHaveBeenCalled(); expect(capture.track.stop).toHaveBeenCalled();
subscription.unsubscribe(); subscription.unsubscribe();
@@ -173,8 +164,7 @@ describe("observeMicrophoneState$", () => {
capture.grant(); capture.grant();
await vi.waitFor(() => expect(emissions).toBe(1)); await vi.waitFor(() => expect(emissions).toBe(1));
// The analyser is read every animation frame, but the meter has only // Read every frame, but a steady signal emits once.
// LEVEL_SCALE steps: a steady signal must not redraw the meter.
capture.drawFrames(20); capture.drawFrames(20);
expect(emissions).toBe(1); expect(emissions).toBe(1);
@@ -182,7 +172,7 @@ describe("observeMicrophoneState$", () => {
}); });
}); });
/** An error with the `name` the browser would give it, not just a message. */ /** An error carrying the browser's `name`. */
function named(error: Error, name: string): Error { function named(error: Error, name: string): Error {
error.name = name; error.name = name;
return error; return error;
+17 -64
View File
@@ -8,37 +8,19 @@ Please see LICENSE in the repository root for full details.
import { distinctUntilChanged, Observable } from "rxjs"; import { distinctUntilChanged, Observable } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
/** /** What the microphone picks up, or why it can't be read. */
* What the microphone selector can say about the input, beyond its level.
*
* Silence and a broken microphone look identical on a meter, so the states a
* user has to act on are named rather than drawn as a flat bar.
*/
export type MicrophoneState = export type MicrophoneState =
| { type: "level"; level: number } | { type: "level"; level: number }
| { type: "permission-denied" } | { type: "permission-denied" }
| { type: "no-device" }; | { type: "no-device" };
/** /** Scale a level is reported on. Fixed rather than the bar count, so a level reads the same at any width. */
* The scale a level is reported on: 0 means silence, this means full scale.
*
* Fixed, and deliberately not the number of bars drawn — that follows the
* width available. A scale that moved with the width would announce the same
* loudness as different numbers in different places, and would make this layer
* depend on how wide something is drawn.
*/
export const LEVEL_SCALE = 24; export const LEVEL_SCALE = 24;
/** /**
* Observes what a microphone is picking up, as the meter should show it. * The level of a microphone, captured while subscribed. Its own capture rather
* * than the call's track, because pre-join freezes that track to the device
* - Held for exactly as long as something is watching: subscribing opens the * selected at mount.
* device, unsubscribing releases it. The menu, not the call.
* - Its own capture rather than the track the call holds, by design. The
* pre-join screen freezes that track to the device selected when it mounted,
* so a meter fed from it could not follow the picker.
* - Says whether the microphone hears anything, not whether anyone hears the
* user: it keeps reading while muted, and the mute control carries that.
*/ */
export function observeMicrophoneState$( export function observeMicrophoneState$(
deviceId: string | undefined, deviceId: string | undefined,
@@ -48,8 +30,7 @@ export function observeMicrophoneState$(
let context: AudioContext | undefined; let context: AudioContext | undefined;
let frame: number | undefined; let frame: number | undefined;
// Safe to call more than once: unsubscribing runs it, and `start` runs it // Idempotent: teardown and start can both call it.
// again for anything the browser handed over after that.
const release = (): void => { const release = (): void => {
if (frame !== undefined) cancelAnimationFrame(frame); if (frame !== undefined) cancelAnimationFrame(frame);
stream?.getTracks().forEach((track) => track.stop()); stream?.getTracks().forEach((track) => track.stop());
@@ -64,16 +45,11 @@ export function observeMicrophoneState$(
audio: audio:
deviceId === undefined ? true : { deviceId: { exact: deviceId } }, deviceId === undefined ? true : { deviceId: { exact: deviceId } },
}); });
// A permission prompt outlives the subscription that asked for it, so by // Unsubscribed while the permission prompt was open.
// now nobody may be watching — and `release` ran while `stream` was still
// undefined. Nothing will call it again, so release here or the device
// stays held, with the indicator lit and no meter on screen.
if (subscriber.closed) return release(); if (subscriber.closed) return release();
context = new AudioContext(); context = new AudioContext();
// Chrome starts the context suspended unless it was created during a // Starts suspended outside a user gesture, which would read as silence.
// gesture; opening the menu is one, but resume explicitly so the meter
// cannot silently sit at zero.
if (context.state === "suspended") await context.resume(); if (context.state === "suspended") await context.resume();
if (subscriber.closed) return release(); if (subscriber.closed) return release();
@@ -86,8 +62,7 @@ export function observeMicrophoneState$(
const read = (): void => { const read = (): void => {
analyser.getByteTimeDomainData(samples); analyser.getByteTimeDomainData(samples);
// Root mean square of the waveform around its centre, which is the // RMS: perceived loudness rather than the peak.
// loudness a listener perceives rather than the tallest spike.
let sum = 0; let sum = 0;
for (const sample of samples) { for (const sample of samples) {
const centred = (sample - 128) / 128; const centred = (sample - 128) / 128;
@@ -107,19 +82,14 @@ export function observeMicrophoneState$(
}; };
start().catch((e: unknown) => { start().catch((e: unknown) => {
// Building the graph can fail after getUserMedia has already resolved, // Building the graph can fail after the device was granted.
// and the capture would then outlive its own failure: the microphone
// open and its in-use light on, behind a meter reporting it as
// unavailable. Releasing is its own step, which teardown and this path
// both take.
release(); release();
subscriber.next(stateForFailure(e)); subscriber.next(stateForFailure(e));
}); });
return release; return release;
}).pipe( }).pipe(
// Read every animation frame, but quantised to a whole number of segments, // Frames that don't move the quantised level don't reach React.
// so most frames say nothing new and should not reach React.
distinctUntilChanged( distinctUntilChanged(
(a, b) => (a, b) =>
a.type === b.type && a.type === b.type &&
@@ -139,41 +109,24 @@ function stateForFailure(e: unknown): MicrophoneState {
return { type: "no-device" }; return { type: "no-device" };
} }
/** /** Below this counts as silence, so a quiet room's hiss doesn't light the first bars. */
* Loudness below which the microphone counts as hearing nothing. A quiet room
* is never digitally silent, and without a floor that hiss lights the first
* bars permanently — which reads as "it can hear me" when nobody is speaking.
*/
const NOISE_FLOOR = 0.02; const NOISE_FLOOR = 0.02;
/** /** Quantises a 0..1 volume onto {@link LEVEL_SCALE}. */
* Quantises a 0..1 volume onto {@link LEVEL_SCALE}. Exported for the tests:
* this mapping is what decides whether quiet, normal and loud look different.
*/
export function segmentsForVolume(volume: number): number { export function segmentsForVolume(volume: number): number {
if (!Number.isFinite(volume) || volume <= NOISE_FLOOR) return 0; if (!Number.isFinite(volume) || volume <= NOISE_FLOOR) return 0;
// Volume arrives as amplitude, where speech occupies a small part of the top // Square root, so ordinary speech reaches the middle of the scale.
// of the range. A square root spreads that out, so ordinary speech moves the
// meter through its middle rather than barely leaving the floor.
const aboveFloor = (Math.min(volume, 1) - NOISE_FLOOR) / (1 - NOISE_FLOOR); const aboveFloor = (Math.min(volume, 1) - NOISE_FLOOR) / (1 - NOISE_FLOOR);
return Math.min(LEVEL_SCALE, Math.ceil(Math.sqrt(aboveFloor) * LEVEL_SCALE)); return Math.min(LEVEL_SCALE, Math.ceil(Math.sqrt(aboveFloor) * LEVEL_SCALE));
} }
/** Time constant for a rise. Short, so a syllable registers as it starts. */ /** Rise time constant: short, so a syllable registers as it starts. */
export const ATTACK_MS = 50; export const ATTACK_MS = 50;
/** /** Fall time constant: longer, so the gaps between words don't flicker. */
* Time constant for a fall. Longer than the attack: speech is full of gaps a
* few tens of milliseconds long, and tracking them exactly would flicker.
*/
export const RELEASE_MS = 120; export const RELEASE_MS = 120;
/** /** Eases towards a reading, by elapsed time so the frame rate doesn't matter. */
* Moves a displayed level towards a new reading, fast up and slowly down.
*
* In elapsed time rather than frames, so it behaves the same at 60Hz and
* 120Hz, and does not jump when a frame is dropped.
*/
export function smoothVolume( export function smoothVolume(
displayed: number, displayed: number,
reading: number, reading: number,
+7 -16
View File
@@ -594,26 +594,19 @@ export class MockConnection extends Connection {
export interface StubbedCapture { export interface StubbedCapture {
getUserMedia: Mock; getUserMedia: Mock;
/** Hands the microphone over, as the browser does once permission is given. */ /** Grants the microphone, as the browser does once permission is given. */
grant: () => void; grant: () => void;
track: { stop: Mock }; track: { stop: Mock };
contexts: { close: Mock }[]; contexts: { close: Mock }[];
/** Runs the animation frames the level meter reads on, in order. */ /** Runs the pending animation frames, in order. */
drawFrames: (count: number) => void; drawFrames: (count: number) => void;
/** /** Sets the microphone's loudness from the next frame, 0 to 1. */
* Sets how loud the microphone is, 0 for silence and 1 for full scale.
* Takes effect on the next frame drawn.
*/
speak: (amplitude: number) => void; speak: (amplitude: number) => void;
} }
/** /**
* Stubs just enough of the capture and Web Audio APIs for a microphone level to * Stubs the capture and Web Audio APIs, with the grant and the animation
* be read, with the grant held back so a test decides when — or whether — it * frames driven by the test. Undo with {@link restoreAudioCapture}.
* lands, and with animation frames driven by hand rather than by a clock.
*
* Call {@link restoreAudioCapture} afterwards, or every later test in the run
* inherits the stub.
*/ */
export function stubAudioCapture(): StubbedCapture { export function stubAudioCapture(): StubbedCapture {
const track = { stop: vi.fn() }; const track = { stop: vi.fn() };
@@ -643,8 +636,7 @@ export function stubAudioCapture(): StubbedCapture {
return { return {
fftSize: 1024, fftSize: 1024,
getByteTimeDomainData: (samples: Uint8Array): void => { getByteTimeDomainData: (samples: Uint8Array): void => {
// Digital silence is the midpoint of the range and not zero: a // Silence is the midpoint of the range; a zeroed buffer reads as full scale.
// buffer left at zero reads as a full-scale waveform.
if (amplitude <= 0) { if (amplitude <= 0) {
samples.fill(128); samples.fill(128);
return; return;
@@ -660,8 +652,7 @@ export function stubAudioCapture(): StubbedCapture {
} }
}, },
); );
// Only this property: replacing navigator wholesale drops the getters on its // Only this property: replacing navigator loses getters like userAgent.
// prototype, such as userAgent.
Object.defineProperty(navigator, "mediaDevices", { Object.defineProperty(navigator, "mediaDevices", {
configurable: true, configurable: true,
value: { getUserMedia: vi.fn().mockReturnValue(granted) }, value: { getUserMedia: vi.fn().mockReturnValue(granted) },