mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Add the sound-effect volume to the audio menu and scroll the device lists
The menu gains a third group with the sound-effect volume slider, reading and writing the same stored value as the slider in settings, so the next effect plays at the new level. The menu is bounded to the height Radix reports for it and only the device lists scroll; the heading and the slider stay in place. Every control is now reachable by keyboard alone. Radix swallows Tab inside its menus so that the arrow keys walk the items; the audio menu keeps Tab from it, letting the browser move focus from the device rows to the meter and on to the slider, while the menu's focus trap keeps that order inside the menu. The slider's own keys stop at the slider so the menu does not treat them as navigation. The meter keeps its live-region text on blur: removing it handed the focus trap an empty active element mid-Tab and pulled focus back into the menu. Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 4 — AC4, AC16, AC17, AC20, AC21 (manual), AC22, AC27. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,88 @@ test("level indicator moves with microphone input", async ({
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("audio menu is keyboard operable in a real browser", async ({
|
||||
browser,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
'Firefox headless drives page.keyboard.press("Tab") unreliably, as reconnect.spec.ts records.',
|
||||
);
|
||||
const context = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Keys", "Keyboard menu", true);
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(1);
|
||||
|
||||
// Open from the chevron; the first device row takes focus.
|
||||
await page.getByRole("button", { name: "Microphone" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
const rows = menu.getByRole("menuitemradio");
|
||||
await expect(rows.first()).toBeFocused();
|
||||
|
||||
// Arrow keys walk the device rows, where the browser lists more than one.
|
||||
if ((await rows.count()) > 1) {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(rows.nth(1)).toBeFocused();
|
||||
}
|
||||
|
||||
// Tab reaches the meter and then the slider; arrows adjust the slider and
|
||||
// leave the menu open.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(menu.getByRole("meter")).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
const slider = menu.getByRole("slider");
|
||||
await expect(slider).toBeFocused();
|
||||
const before = Number(await slider.getAttribute("aria-valuenow"));
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect
|
||||
.poll(async () => Number(await slider.getAttribute("aria-valuenow")))
|
||||
.toBeGreaterThan(before);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Shift+Tab goes back; Escape closes.
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(menu.getByRole("meter")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("audio menu stays inside a short window", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
viewport: { width: 1280, height: 560 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Devices", "Long lists", true);
|
||||
await page.getByTestId("videoTile").first().waitFor();
|
||||
|
||||
await page.getByRole("button", { name: "Microphone" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// However many devices the browser reports, the menu fits the window and
|
||||
// the heading and the slider are on screen with it. How many devices that
|
||||
// is differs between browsers, so the case where the lists actually
|
||||
// overflow is covered by the CallFooter "With Many Devices" story, which
|
||||
// fixes the device count.
|
||||
await expect(menu).toBeInViewport({ ratio: 1 });
|
||||
await expect(
|
||||
menu.getByRole("heading", { name: "Audio controls" }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(menu.getByRole("slider")).toBeInViewport({ ratio: 1 });
|
||||
|
||||
// The menu itself never becomes the scroller: that would carry the heading
|
||||
// out of view, which is what the scroll area exists to prevent.
|
||||
await expect
|
||||
.poll(async () => menu.evaluate((el) => el.scrollHeight <= el.clientHeight))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
async function firstUncheckedIndex(
|
||||
rows: Locator,
|
||||
count: number,
|
||||
|
||||
Reference in New Issue
Block a user