mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Cover the device menu with stories and end-to-end specs
- The stories supply what the app supplies rather than what a harness has lying about: a microphone, and a call-sized root element. - Invented device ids meant the menu asked for hardware that does not exist, so the meter reported no microphone. A tone played into a real MediaStream fixes it, and the meter runs its own analyser over it. - Without a root element the list was bounded by Storybook's whole frame and the menu ran off the canvas; without a call-sized one, four devices scrolled. - Standalone specs: the switch that does not drop the call, the meter alive while muted, the pinned meter, the focus ring. - Component specs: the list sized to the call and not the window, resized while open, every device reachable, and the modality belonging to one call. - A headless browser has one microphone and one speaker, so fake-devices.ts adds synthetic ones. It is explicit that it does not route audio.
This commit is contained in:
@@ -0,0 +1,253 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { SpaHelpers } from "./spa-helpers.ts";
|
||||||
|
import { installFakeDevices } from "./utils/fake-devices.ts";
|
||||||
|
|
||||||
|
test.describe("the quick audio menu", () => {
|
||||||
|
test("lists speakers and microphones with a live level meter", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installFakeDevices(page, { microphones: 3, speakers: 3 });
|
||||||
|
await joinACall(page, "Menu user", "Audio menu");
|
||||||
|
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: "Microphone" })).toBeVisible();
|
||||||
|
// Named rather than counted: the browser contributes its own fake output
|
||||||
|
// and a "Default" entry, so a total would be a fact about the browser.
|
||||||
|
for (const n of [1, 2, 3])
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("group", { name: "Speaker" })
|
||||||
|
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Only one entry of a kind is marked, and the meter reports a number
|
||||||
|
// rather than a colour.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("menuitemradio", { checked: true }),
|
||||||
|
).toHaveCount(2);
|
||||||
|
const meter = page.getByRole("meter", { name: "Microphone level" });
|
||||||
|
await expect(meter).toBeVisible();
|
||||||
|
await expect(meter).toHaveAttribute("aria-valuenow", /\d+/);
|
||||||
|
await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/);
|
||||||
|
|
||||||
|
// Both browsers in the matrix can route audio to a chosen output, so the
|
||||||
|
// 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(
|
||||||
|
page
|
||||||
|
.getByRole("group", { name: "Speaker" })
|
||||||
|
.getByRole("menuitemradio")
|
||||||
|
.first(),
|
||||||
|
).toHaveAttribute("aria-disabled", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moves the microphone and the speaker without disturbing the call", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
// Two browsers, two joins and a real call between them.
|
||||||
|
test.slow();
|
||||||
|
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||||
|
const host = await hostContext.newPage();
|
||||||
|
await installFakeDevices(host, { microphones: 3, speakers: 3 });
|
||||||
|
await joinACall(host, "Host", "Device switch");
|
||||||
|
|
||||||
|
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||||
|
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||||
|
const guest = await guestContext.newPage();
|
||||||
|
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Guest");
|
||||||
|
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||||
|
|
||||||
|
await openAudioMenu(host);
|
||||||
|
await selectDevice(host, "Microphone", "Fake Microphone 2");
|
||||||
|
await openAudioMenu(host);
|
||||||
|
await selectDevice(host, "Speaker", "Fake Speaker 2");
|
||||||
|
|
||||||
|
// The point of the criterion: the switch is not a rejoin. Neither side
|
||||||
|
// sees the call drop, and the guest still has both tiles — so the host
|
||||||
|
// never left and came back.
|
||||||
|
await expect(
|
||||||
|
host.getByRole("dialog", { name: "Reconnecting…" }),
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
guest.getByRole("dialog", { name: "Reconnecting…" }),
|
||||||
|
).not.toBeVisible();
|
||||||
|
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||||
|
await expect(guest.getByText("Waiting for media...")).not.toBeVisible();
|
||||||
|
|
||||||
|
await hostContext.close();
|
||||||
|
await guestContext.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the meter moving while muted, and sends nothing", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
// Two browsers, two joins and a real call between them.
|
||||||
|
test.slow();
|
||||||
|
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||||
|
const host = await hostContext.newPage();
|
||||||
|
await installFakeDevices(host);
|
||||||
|
await joinACall(host, "Muted host", "Muted meter");
|
||||||
|
|
||||||
|
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||||
|
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||||
|
const guest = await guestContext.newPage();
|
||||||
|
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Listener");
|
||||||
|
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||||
|
|
||||||
|
const mute = host.getByTestId("incall_mute");
|
||||||
|
await mute.click();
|
||||||
|
await expect(mute).toHaveAttribute("aria-checked", "false");
|
||||||
|
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" });
|
||||||
|
await expect(meter).toBeVisible();
|
||||||
|
// Queried by test id, not by role: the menu is modal, so Radix takes the
|
||||||
|
// rest of the call out of the accessibility tree while it is open.
|
||||||
|
await expect(mute).toHaveAttribute("aria-checked", "false");
|
||||||
|
await expect(mute).toBeVisible();
|
||||||
|
// And the listener is told so, rather than being left to guess from silence.
|
||||||
|
await expect(
|
||||||
|
guest.getByTestId("videoTile").filter({ hasText: "Muted host" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await hostContext.close();
|
||||||
|
await guestContext.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the meter at the foot of the list while it scrolls", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||||
|
await joinACall(page, "Scroller", "Long device list");
|
||||||
|
await openAudioMenu(page);
|
||||||
|
|
||||||
|
const meter = page.getByRole("meter", { name: "Microphone level" });
|
||||||
|
await expect(meter).toBeVisible();
|
||||||
|
|
||||||
|
// Scrolled so the microphones start at the top of the list and run past its
|
||||||
|
// bottom: the position that tells a pinned meter from one that merely
|
||||||
|
// happens to be last.
|
||||||
|
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||||
|
await list.evaluate((element) => {
|
||||||
|
const group = element.querySelector("[role='group'][aria-label*='icro']");
|
||||||
|
element.scrollTop +=
|
||||||
|
group!.getBoundingClientRect().top -
|
||||||
|
element.getBoundingClientRect().top;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(meter).toBeInViewport();
|
||||||
|
await expectPinnedInside(meter, list);
|
||||||
|
// Every entry stays reachable, which is what the scroll is for.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the focus ring only when the keyboard moved the focus", async ({
|
||||||
|
page,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
test.skip(
|
||||||
|
browserName === "firefox",
|
||||||
|
"Headless Firefox does not deliver synthetic key presses reliably; see reconnect.spec.ts",
|
||||||
|
);
|
||||||
|
await installFakeDevices(page);
|
||||||
|
await joinACall(page, "Keyboard user", "Focus ring");
|
||||||
|
await openAudioMenu(page);
|
||||||
|
|
||||||
|
const first = page.getByRole("menuitemradio").first();
|
||||||
|
// Opened by pointer, so no ring, even though Radix has moved focus into the
|
||||||
|
// menu already.
|
||||||
|
await expect.poll(async () => outlineWidth(first)).toBe(0);
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
const focused = page.locator("[role='menuitemradio']:focus");
|
||||||
|
await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The pointer takes it away again: the menu focuses whatever it is over, so
|
||||||
|
// a ring that followed focus alone would trail the mouse.
|
||||||
|
await first.hover();
|
||||||
|
await expect.poll(async () => outlineWidth(focused)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Creates a call and joins it, leaving the page in the call. */
|
||||||
|
async function joinACall(
|
||||||
|
page: Page,
|
||||||
|
userName: string,
|
||||||
|
callName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.goto("/");
|
||||||
|
await SpaHelpers.createCall(page, userName, callName, true);
|
||||||
|
await expect(page.getByTestId("name_tag")).toContainText(userName);
|
||||||
|
// The media controls stay disabled until the devices have enumerated, and
|
||||||
|
// every test here drives them.
|
||||||
|
await expect(page.getByTestId("incall_mute")).toBeEnabled({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAudioMenu(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Microphone" }).click();
|
||||||
|
await expect(page.getByRole("menu")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectDevice(
|
||||||
|
page: Page,
|
||||||
|
section: "Speaker" | "Microphone",
|
||||||
|
name: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const item = page
|
||||||
|
.getByRole("group", { name: section })
|
||||||
|
.getByRole("menuitemradio", { name });
|
||||||
|
await item.click();
|
||||||
|
// Selecting does not close the menu — the component prevents the default so
|
||||||
|
// the list survives a mis-click — so it is dismissed explicitly.
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("menu")).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
meter: Locator,
|
||||||
|
list: Locator,
|
||||||
|
): Promise<void> {
|
||||||
|
const meterBox = (await meter.boundingBox())!;
|
||||||
|
const listBox = (await list.boundingBox())!;
|
||||||
|
const frame = (await meter.page().getByRole("menu").boundingBox())!;
|
||||||
|
|
||||||
|
expect(meterBox.y + meterBox.height).toBeLessThanOrEqual(
|
||||||
|
listBox.y + listBox.height + 1,
|
||||||
|
);
|
||||||
|
expect(meterBox.y).toBeGreaterThanOrEqual(listBox.y - 1);
|
||||||
|
expect(meterBox.x).toBeGreaterThan(frame.x);
|
||||||
|
expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The painted outline width in pixels, however the stylesheet spells it. */
|
||||||
|
async function outlineWidth(item: Locator): Promise<number> {
|
||||||
|
if ((await item.count()) === 0) return 0;
|
||||||
|
return item.first().evaluate((element) => {
|
||||||
|
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
||||||
|
return outlineStyle === "none" ? 0 : Number.parseFloat(outlineWidth) || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, type Locator, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
|
||||||
|
import { installFakeDevices } from "../utils/fake-devices.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device menu where Element Call is a component in a host's page rather
|
||||||
|
* than the whole of one.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
// screen, as in component-call.spec.ts
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("sizes the device list against the call, not the window", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||||
|
const { username, roomId } = await createUserAndRoom("menusize");
|
||||||
|
const panes = await startHarness(page, username, roomId);
|
||||||
|
const pane = panes.first();
|
||||||
|
|
||||||
|
// A short call in a much taller page: the difference between measuring the
|
||||||
|
// call and measuring the window.
|
||||||
|
const container = pane.getByTestId("call-container");
|
||||||
|
await resizeContainer(container, { width: 900, height: 400 });
|
||||||
|
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await openDeviceList(page, pane);
|
||||||
|
const callHeight = (await container.boundingBox())!.height;
|
||||||
|
const windowHeight = page.viewportSize()!.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(listHeight).toBeLessThanOrEqual(callHeight);
|
||||||
|
expect(listHeight).toBeLessThan(windowHeight * 0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("follows the call area when the host resizes it", async ({ page }) => {
|
||||||
|
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||||
|
const { username, roomId } = await createUserAndRoom("menuresize");
|
||||||
|
const panes = await startHarness(page, username, roomId);
|
||||||
|
const pane = panes.first();
|
||||||
|
|
||||||
|
const container = pane.getByTestId("call-container");
|
||||||
|
await resizeContainer(container, { width: 900, height: 360 });
|
||||||
|
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await openDeviceList(page, pane);
|
||||||
|
const whenShort = (await list.boundingBox())!.height;
|
||||||
|
|
||||||
|
// The host grows the space Element Call is drawn in 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 expect
|
||||||
|
.poll(async () => (await list.boundingBox())!.height)
|
||||||
|
.toBeGreaterThan(whenShort);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps every device reachable in a small container", async ({ page }) => {
|
||||||
|
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||||
|
const { username, roomId } = await createUserAndRoom("menureach");
|
||||||
|
const panes = await startHarness(page, username, roomId);
|
||||||
|
const pane = panes.first();
|
||||||
|
|
||||||
|
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 expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await openDeviceList(page, pane);
|
||||||
|
// More devices than the space allows, so the list has to scroll rather than
|
||||||
|
// put entries somewhere they cannot be got at.
|
||||||
|
expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" });
|
||||||
|
await last.scrollIntoViewIfNeeded();
|
||||||
|
await expect(last).toBeInViewport();
|
||||||
|
// Reachable means usable, not merely painted: D11 accepts that the menu may
|
||||||
|
// be drawn outside the call area, so this asserts reach rather than
|
||||||
|
// containment.
|
||||||
|
await last.click();
|
||||||
|
await expect(last).toHaveAttribute("aria-checked", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tracks the focus modality of its own call, not the page", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installFakeDevices(page);
|
||||||
|
const { username, roomId } = await createUserAndRoom("menufocus");
|
||||||
|
const panes = await startHarness(page, username, roomId);
|
||||||
|
const pane = panes.first();
|
||||||
|
const other = panes.nth(1);
|
||||||
|
|
||||||
|
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await openDeviceList(page, pane);
|
||||||
|
// The menu owns the modality, 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");
|
||||||
|
|
||||||
|
// Asserted on the attribute rather than the painted ring, which cannot be
|
||||||
|
// read here: the menu is portalled outside the call root, and the component
|
||||||
|
// 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-modality", "pointer");
|
||||||
|
|
||||||
|
// A key pressed in the other call on this page — or anywhere in the host's
|
||||||
|
// own page — says nothing about how this menu is being used.
|
||||||
|
await other.evaluate((element) =>
|
||||||
|
element.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(menu).toHaveAttribute("data-focus-modality", "pointer");
|
||||||
|
|
||||||
|
// A key pressed in this menu does.
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expect(menu).toHaveAttribute("data-focus-modality", "keyboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
||||||
|
await pane
|
||||||
|
.getByRole("button", { name: "Microphone" })
|
||||||
|
.click({ timeout: 60_000 });
|
||||||
|
await expect(page.getByRole("menu")).toBeVisible();
|
||||||
|
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||||
|
await expect(list).toBeVisible();
|
||||||
|
return list;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives the browser more fake devices than it ships with.
|
||||||
|
*
|
||||||
|
* A headless browser's fake capture offers one microphone and one speaker,
|
||||||
|
* which is one short of what a device menu is for: with no choice to make, every
|
||||||
|
* 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(
|
||||||
|
page: Page,
|
||||||
|
{ microphones = 2, speakers = 2 } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ microphones, speakers }) => {
|
||||||
|
const synthetic = (
|
||||||
|
kind: MediaDeviceKind,
|
||||||
|
count: number,
|
||||||
|
name: string,
|
||||||
|
): MediaDeviceInfo[] =>
|
||||||
|
Array.from({ length: count }, (_, i) => {
|
||||||
|
const info = {
|
||||||
|
deviceId: `${kind}-${i + 1}`,
|
||||||
|
groupId: `${kind}-group-${i + 1}`,
|
||||||
|
kind,
|
||||||
|
label: `${name} ${i + 1}`,
|
||||||
|
};
|
||||||
|
return { ...info, toJSON: () => info } as MediaDeviceInfo;
|
||||||
|
});
|
||||||
|
const ids = new Set(
|
||||||
|
[
|
||||||
|
...synthetic("audioinput", microphones, ""),
|
||||||
|
...synthetic("audiooutput", speakers, ""),
|
||||||
|
].map((d) => d.deviceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const devices = navigator.mediaDevices;
|
||||||
|
const enumerate = devices.enumerateDevices.bind(devices);
|
||||||
|
devices.enumerateDevices = async (): Promise<MediaDeviceInfo[]> => [
|
||||||
|
...(await enumerate()),
|
||||||
|
...synthetic("audioinput", microphones, "Fake Microphone"),
|
||||||
|
...synthetic("audiooutput", speakers, "Fake Speaker"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Our ids name no hardware, so an exact-device constraint on one would be
|
||||||
|
// rejected. Drop it and let the one real fake device answer.
|
||||||
|
const getUserMedia = devices.getUserMedia.bind(devices);
|
||||||
|
devices.getUserMedia = async (
|
||||||
|
constraints?: MediaStreamConstraints,
|
||||||
|
): Promise<MediaStream> => {
|
||||||
|
const audio = constraints?.audio;
|
||||||
|
if (typeof audio === "object") {
|
||||||
|
const requested = audio.deviceId;
|
||||||
|
const id =
|
||||||
|
typeof requested === "object" && requested !== null
|
||||||
|
? ((requested as ConstrainDOMStringParameters).exact as string)
|
||||||
|
: (requested as string | undefined);
|
||||||
|
if (id !== undefined && ids.has(id))
|
||||||
|
return getUserMedia({ ...constraints, audio: true });
|
||||||
|
}
|
||||||
|
return getUserMedia(constraints);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Routing to a device that does not exist would reject, and the app
|
||||||
|
// treats that as a failed switch. Both sinks are patched: Element Call
|
||||||
|
// 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 [
|
||||||
|
HTMLMediaElement.prototype,
|
||||||
|
AudioContext.prototype,
|
||||||
|
]) {
|
||||||
|
const sink = proto as { setSinkId?: (id: string) => Promise<void> };
|
||||||
|
const setSinkId = sink.setSinkId;
|
||||||
|
if (setSinkId === undefined) continue;
|
||||||
|
sink.setSinkId = async function (id: string): Promise<void> {
|
||||||
|
if (!ids.has(id)) await setSinkId.call(this, id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ microphones, speakers },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fn, userEvent, waitFor, within, expect } from "storybook/test";
|
import { fn, userEvent, waitFor, within, expect } from "storybook/test";
|
||||||
import { type JSX } from "react";
|
import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||||
|
import meterStyles from "./MicrophoneLevelMeter.module.css";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
|
import { RootElementProvider } from "../RootElementContext";
|
||||||
import { MediaDevices } from "../state/MediaDevices";
|
import { MediaDevices } from "../state/MediaDevices";
|
||||||
import { globalScope } from "../state/ObservableScope";
|
import { globalScope } from "../state/ObservableScope";
|
||||||
|
|
||||||
@@ -19,12 +21,97 @@ const mediaDevices = new MediaDevices(globalScope, {
|
|||||||
controlledAudioDevices: false,
|
controlledAudioDevices: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const context = new AudioContext();
|
||||||
|
const microphone = context.createMediaStreamDestination();
|
||||||
|
const tone = context.createOscillator();
|
||||||
|
const loudness = context.createGain();
|
||||||
|
// Swinging between about a third and two thirds of the range, so the meter
|
||||||
|
// reads as something live rather than as a level someone pinned there.
|
||||||
|
const swing = context.createOscillator();
|
||||||
|
const depth = context.createGain();
|
||||||
|
loudness.gain.value = 0.25;
|
||||||
|
depth.gain.value = 0.2;
|
||||||
|
swing.frequency.value = 0.6;
|
||||||
|
tone.frequency.value = 220;
|
||||||
|
swing.connect(depth).connect(loudness.gain);
|
||||||
|
tone.connect(loudness).connect(microphone);
|
||||||
|
tone.start();
|
||||||
|
swing.start();
|
||||||
|
|
||||||
|
const devices = navigator.mediaDevices;
|
||||||
|
const openedForReal = devices.getUserMedia.bind(devices);
|
||||||
|
const opened = Promise.resolve(microphone.stream);
|
||||||
|
// A fresh clone each time, so that a caller stopping its tracks when it is
|
||||||
|
// done does not take the microphone away from the next one.
|
||||||
|
devices.getUserMedia = async (): Promise<MediaStream> =>
|
||||||
|
(await opened).clone();
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
devices.getUserMedia = openedForReal;
|
||||||
|
tone.stop();
|
||||||
|
swing.stop();
|
||||||
|
void context.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 [callArea, setCallArea] = useState<HTMLElement | null>(null);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setCallArea}
|
||||||
|
style={{
|
||||||
|
// The size of a call, not of a thumbnail: the device list is bounded to
|
||||||
|
// 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,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{callArea !== null && (
|
||||||
|
<RootElementProvider value={callArea}>{children}</RootElementProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
component: MediaMuteAndSwitchButton,
|
component: MediaMuteAndSwitchButton,
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story): JSX.Element => (
|
(Story): JSX.Element => (
|
||||||
<MediaDevicesContext value={mediaDevices}>
|
<MediaDevicesContext value={mediaDevices}>
|
||||||
<Story />
|
<WithACallArea>
|
||||||
|
<WithAMicrophone>
|
||||||
|
<Story />
|
||||||
|
</WithAMicrophone>
|
||||||
|
</WithACallArea>
|
||||||
</MediaDevicesContext>
|
</MediaDevicesContext>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -43,6 +130,16 @@ 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
|
||||||
|
// 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: [
|
||||||
|
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||||
|
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||||
|
],
|
||||||
|
selectedOutputOption: "default",
|
||||||
|
onSelectOutput: fn(),
|
||||||
onMuteClick: fn(),
|
onMuteClick: fn(),
|
||||||
onSelect: fn(),
|
onSelect: fn(),
|
||||||
},
|
},
|
||||||
@@ -74,6 +171,7 @@ export const AudioMute: Story = {
|
|||||||
|
|
||||||
export const AudioUnmute: Story = {
|
export const AudioUnmute: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
...Default.args,
|
||||||
title: "Microphone",
|
title: "Microphone",
|
||||||
iconsAndLabels: "audio",
|
iconsAndLabels: "audio",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -81,7 +179,6 @@ export const AudioUnmute: Story = {
|
|||||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||||
],
|
],
|
||||||
|
|
||||||
selectedOption: "2",
|
selectedOption: "2",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -142,6 +239,14 @@ 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
|
||||||
|
// 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>(
|
||||||
|
`.${styles.deviceList}`,
|
||||||
|
)!;
|
||||||
|
await expect(list.scrollHeight).toBe(list.clientHeight);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,6 +304,171 @@ export const OnlyOneDevice: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||||
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||||
|
],
|
||||||
|
selectedOption: "mic1",
|
||||||
|
outputOptions: [
|
||||||
|
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||||
|
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||||
|
],
|
||||||
|
selectedOutputOption: "spk1",
|
||||||
|
onSelectOutput: fn(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||||
|
const menu = within(document.body);
|
||||||
|
|
||||||
|
// The story never changes `selectedOption`, which is what a device that has
|
||||||
|
// not taken effect yet looks like from here.
|
||||||
|
await userEvent.click(
|
||||||
|
await menu.findByRole("menuitemradio", { name: "Microphone 2" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(await menu.findByLabelText("Activating…")).toBeVisible();
|
||||||
|
for (const item of menu.getAllByRole("menuitemradio"))
|
||||||
|
await expect(item).toHaveAttribute("aria-disabled", "true");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The focus ring belongs to the keyboard. Radix focuses whatever the pointer is
|
||||||
|
* over, so a ring that followed focus alone would trail the mouse.
|
||||||
|
*
|
||||||
|
* Asserted on the painted outline rather than on `data-focus-modality`: the
|
||||||
|
* attribute is what the stylesheet keys off, so asserting it would pass even
|
||||||
|
* with the rule deleted.
|
||||||
|
*/
|
||||||
|
export const KeyboardFocusRing: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||||
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||||
|
],
|
||||||
|
selectedOption: "mic1",
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||||
|
const menu = within(document.body);
|
||||||
|
const first = await menu.findByRole("menuitemradio", {
|
||||||
|
name: "Microphone 1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opened by pointer: no ring, even though Radix has moved focus.
|
||||||
|
await expect(outlineWidth(first)).toBe(0);
|
||||||
|
|
||||||
|
await userEvent.keyboard("{ArrowDown}");
|
||||||
|
const focused = document.activeElement as HTMLElement;
|
||||||
|
await expect(focused).toHaveRole("menuitemradio");
|
||||||
|
await expect(outlineWidth(focused)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// And the pointer takes it away again.
|
||||||
|
await userEvent.hover(first);
|
||||||
|
await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: true,
|
||||||
|
options: Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
label: { type: "name" as const, name: `Microphone ${i + 1}` },
|
||||||
|
id: `mic${i + 1}`,
|
||||||
|
})),
|
||||||
|
selectedOption: "mic1",
|
||||||
|
outputOptions: Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
label: { type: "name" as const, name: `Speaker ${i + 1}` },
|
||||||
|
id: `spk${i + 1}`,
|
||||||
|
})),
|
||||||
|
selectedOutputOption: "spk1",
|
||||||
|
onSelectOutput: fn(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||||
|
const menu = within(document.body);
|
||||||
|
|
||||||
|
// The scroll container and the opaque sticky wrapper, named rather than
|
||||||
|
// walked: the nesting between them is layout, and it moves. The wrapper
|
||||||
|
// 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>(
|
||||||
|
`.${styles.deviceList}`,
|
||||||
|
)!;
|
||||||
|
const sticky = await waitFor(() => {
|
||||||
|
const element = document.body.querySelector<HTMLElement>(
|
||||||
|
`.${styles.stickyMeter}`,
|
||||||
|
);
|
||||||
|
if (element === null) throw new Error("the meter has not rendered yet");
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||||
|
|
||||||
|
// Scrolled so the Microphone section starts at the top of the scrollport.
|
||||||
|
// Its devices then run past the bottom, which is the position that tells a
|
||||||
|
// 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" });
|
||||||
|
list.scrollTop +=
|
||||||
|
group.getBoundingClientRect().top - list.getBoundingClientRect().top;
|
||||||
|
await expect(list.scrollTop + list.clientHeight).toBeLessThan(
|
||||||
|
list.scrollHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollport = list.getBoundingClientRect();
|
||||||
|
const pinned = sticky.getBoundingClientRect();
|
||||||
|
await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
|
||||||
|
await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1);
|
||||||
|
|
||||||
|
// The whole menu is on screen. It opens upward from the foot of the call,
|
||||||
|
// 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
|
||||||
|
.querySelector("[role='menu']")!
|
||||||
|
.getBoundingClientRect();
|
||||||
|
await expect(frame.top).toBeGreaterThanOrEqual(0);
|
||||||
|
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
|
||||||
|
|
||||||
|
// The meter is the one opaque thing in the menu, so it is the one thing
|
||||||
|
// 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.right).toBeLessThan(frame.right);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The painted outline width, in pixels, however the stylesheet spells it. */
|
||||||
|
function outlineWidth(element: HTMLElement): number {
|
||||||
|
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
||||||
|
if (outlineStyle === "none") return 0;
|
||||||
|
return Number.parseFloat(outlineWidth) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A platform that enumerates no output devices and offers no way to choose one
|
* 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
|
* — Safari. The section still names where audio is going, disabled, rather than
|
||||||
@@ -230,6 +500,50 @@ export const OutputNotEnumerated: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||||
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||||
|
],
|
||||||
|
selectedOption: "mic1",
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
|
const menu = document.body.querySelector("[role='menu']")!;
|
||||||
|
const radio = menu.querySelector("input[type='radio']")!;
|
||||||
|
const icon = await waitFor(() => {
|
||||||
|
const element = document.body.querySelector(`.${meterStyles.icon}`);
|
||||||
|
if (element === null) throw new Error("the meter has not rendered yet");
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A pixel of slack, for subpixel layout.
|
||||||
|
await expect(Math.abs(centre(icon) - centre(radio))).toBeLessThanOrEqual(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Where an element sits on the inline axis, at its middle. */
|
||||||
|
function centre(element: Element): number {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return box.left + box.width / 2;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walking the device list with the keyboard, all the way to the last entry.
|
* Walking the device list with the keyboard, all the way to the last entry.
|
||||||
*
|
*
|
||||||
@@ -375,55 +689,6 @@ function overlapping(element: Element, overlays: Element[]): number {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The focus ring belongs to the keyboard. Radix focuses whatever the pointer is
|
|
||||||
* over, so a ring that followed focus alone would trail the mouse.
|
|
||||||
*
|
|
||||||
* Asserted on the painted outline rather than on `data-focus-modality`: the
|
|
||||||
* attribute is what the stylesheet keys off, so asserting it would pass even
|
|
||||||
* with the rule deleted.
|
|
||||||
*/
|
|
||||||
export const KeyboardFocusRing: Story = {
|
|
||||||
args: {
|
|
||||||
...Default.args,
|
|
||||||
title: "Microphone",
|
|
||||||
iconsAndLabels: "audio",
|
|
||||||
enabled: true,
|
|
||||||
options: [
|
|
||||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
||||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
||||||
],
|
|
||||||
selectedOption: "mic1",
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
|
||||||
const menu = within(document.body);
|
|
||||||
const first = await menu.findByRole("menuitemradio", {
|
|
||||||
name: "Microphone 1",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Opened by pointer: no ring, even though Radix has moved focus.
|
|
||||||
await expect(outlineWidth(first)).toBe(0);
|
|
||||||
|
|
||||||
await userEvent.keyboard("{ArrowDown}");
|
|
||||||
const focused = document.activeElement as HTMLElement;
|
|
||||||
await expect(focused).toHaveRole("menuitemradio");
|
|
||||||
await expect(outlineWidth(focused)).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// And the pointer takes it away again.
|
|
||||||
await userEvent.hover(first);
|
|
||||||
await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The painted outline width, in pixels, however the stylesheet spells it. */
|
|
||||||
function outlineWidth(element: HTMLElement): number {
|
|
||||||
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
|
||||||
if (outlineStyle === "none") return 0;
|
|
||||||
return Number.parseFloat(outlineWidth) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The camera menu's blur toggle, which the keyboard reaches after the cameras.
|
* The camera menu's blur toggle, which the keyboard reaches after the cameras.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user