mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-05 23:27:15 +00:00
- Show the focus ring only when the keyboard moved the focus. Radix focuses whatever the pointer is over, so the browser cannot answer that question here: Chromium treats every focus after a key press as keyboard-driven, Firefox treats no programmatic focus as keyboard-driven. The menu records which modality arrived, watched at the document while it is open, since the first arrow key lands on the menu itself rather than on anything we render. - Suppress the browser's own ring on menu items, so there is one answer to that question rather than two. - Drop readOnly from the device radios. Compound paints a read-only control muted, and that rule comes after the checked rule, so it overrode the accent fill marking the selection and the menu stopped matching settings. - Make the decorative radio inert rather than aria-hidden. A negative tabindex inside an interactive control stays reachable to assistive technology even when hidden, which axe rejects. - Give each section a labelled group and mark its heading decorative. A menu may contain only items, separators and groups, and the headings were direct children of it. The list wrappers are role="none" for the same reason. - Run axe over the open menu, which is what found both of the above. Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC23, AC24, AC25
653 lines
21 KiB
TypeScript
653 lines
21 KiB
TypeScript
/*
|
|
Copyright 2023, 2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { describe, expect, test, vi } from "vitest";
|
|
import { axe } from "vitest-axe";
|
|
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { type JSX, useState, type ReactNode } from "react";
|
|
import { TooltipProvider } from "@vector-im/compound-web";
|
|
|
|
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
|
import { type MediaDevices } from "../state/MediaDevices";
|
|
|
|
interface RenderOptions {
|
|
requestDeviceNames: () => void;
|
|
}
|
|
|
|
function renderComponent(
|
|
component: ReactNode,
|
|
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
|
|
): RenderResult {
|
|
return render(
|
|
<TooltipProvider>
|
|
<MediaDevicesContext
|
|
value={{ requestDeviceNames } as unknown as MediaDevices}
|
|
>
|
|
{component}
|
|
</MediaDevicesContext>
|
|
</TooltipProvider>,
|
|
);
|
|
}
|
|
|
|
describe("MediaMuteAndSwitchButton", () => {
|
|
test("renders", () => {
|
|
const { container } = renderComponent(
|
|
<TooltipProvider>
|
|
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
|
|
</TooltipProvider>,
|
|
);
|
|
expect(container).toMatchSnapshot();
|
|
});
|
|
|
|
test("renders correct audio and video labels", () => {
|
|
const renderLabels = (
|
|
type: "video" | "audio",
|
|
enabled: boolean,
|
|
): RenderResult => {
|
|
return renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title={"Switcher"}
|
|
iconsAndLabels={type}
|
|
enabled={enabled}
|
|
/>,
|
|
);
|
|
};
|
|
const renderAudioEndabled = renderLabels("audio", true);
|
|
const renderAudioDisabled = renderLabels("audio", false);
|
|
const renderVideoEnabled = renderLabels("video", true);
|
|
const renderVideoDisabled = renderLabels("video", false);
|
|
|
|
expect(
|
|
renderAudioEndabled.getByRole("switch", { name: "Mute microphone" }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
renderAudioDisabled.getByRole("switch", { name: "Unmute microphone" }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
renderVideoEnabled.getByRole("switch", { name: "Start video" }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
renderVideoDisabled.getByRole("switch", { name: "Stop video" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
test("calls mute on mute press", async () => {
|
|
const user = userEvent.setup();
|
|
const onMute = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title={"Switcher"}
|
|
onMuteClick={onMute}
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("switch", { name: "Mute microphone" }));
|
|
|
|
expect(onMute).toHaveBeenCalled();
|
|
});
|
|
|
|
test("disables mute button while busy", async () => {
|
|
const user = userEvent.setup();
|
|
const onMute = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title={"Switcher"}
|
|
onMuteClick={onMute}
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
busy={true}
|
|
/>,
|
|
);
|
|
|
|
const muteButton = getByRole("switch", { name: "Mute microphone" });
|
|
expect(muteButton).toHaveAttribute("aria-disabled", "true");
|
|
expect(muteButton).toHaveAttribute("aria-busy", "true");
|
|
|
|
await user.click(muteButton);
|
|
expect(onMute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("disables video button while busy", async () => {
|
|
const user = userEvent.setup();
|
|
const onMute = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title={"Switcher"}
|
|
onMuteClick={onMute}
|
|
iconsAndLabels="video"
|
|
enabled={true}
|
|
busy={true}
|
|
/>,
|
|
);
|
|
|
|
const videoButton = getByRole("switch", { name: "Stop video" });
|
|
expect(videoButton).toHaveAttribute("aria-disabled", "true");
|
|
expect(videoButton).toHaveAttribute("aria-busy", "true");
|
|
|
|
await user.click(videoButton);
|
|
expect(onMute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("requests device names when opened", async () => {
|
|
const user = userEvent.setup();
|
|
const requestDeviceNames = vi.fn();
|
|
renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled
|
|
/>,
|
|
{ requestDeviceNames },
|
|
);
|
|
|
|
expect(requestDeviceNames).not.toHaveBeenCalled();
|
|
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
|
expect(requestDeviceNames).toHaveBeenCalled();
|
|
});
|
|
|
|
test("shows numbered devices correctly", async () => {
|
|
const user = userEvent.setup();
|
|
renderComponent(
|
|
<>
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled
|
|
options={[
|
|
{ label: { type: "number", number: 1 }, id: "mic1" },
|
|
{ label: { type: "number", number: 2 }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic1"
|
|
/>
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="video"
|
|
enabled
|
|
options={[
|
|
{ label: { type: "number", number: 1 }, id: "cam1" },
|
|
{ label: { type: "number", number: 2 }, id: "cam2" },
|
|
]}
|
|
selectedOption="cam1"
|
|
/>
|
|
</>,
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
|
screen.getByRole("menuitemradio", { name: "Microphone 1" });
|
|
screen.getByRole("menuitemradio", { name: "Microphone 2" });
|
|
await user.keyboard("[Escape]");
|
|
await user.click(screen.getByRole("button", { name: "Camera" }));
|
|
screen.getByRole("menuitemradio", { name: "Camera 1" });
|
|
screen.getByRole("menuitemradio", { name: "Camera 2" });
|
|
});
|
|
|
|
test("calls select callback on menu click", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelect = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic1"
|
|
onSelect={onSelect}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
await user.click(
|
|
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
|
);
|
|
|
|
expect(onSelect).toHaveBeenCalledWith("mic2");
|
|
});
|
|
test("does not call select callback on already selected menu click", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelect = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic1"
|
|
onSelect={onSelect}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
await user.click(
|
|
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
|
|
);
|
|
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("renders menu spinner until selection updates for the component", async () => {
|
|
const user = userEvent.setup();
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
const onSelectPressed = vi.fn();
|
|
const onOptionUpdated = vi.fn();
|
|
function Wrapper(): JSX.Element {
|
|
const [selectedOption, setSelectedOption] = useState("mic1");
|
|
return (
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption={selectedOption}
|
|
onSelect={(id) => {
|
|
onSelectPressed();
|
|
void promise.then(() => {
|
|
setSelectedOption(id);
|
|
onOptionUpdated();
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const { getByRole } = renderComponent(<Wrapper />);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
await user.click(
|
|
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
|
);
|
|
|
|
expect(onSelectPressed).toHaveBeenCalled();
|
|
expect(onOptionUpdated).not.toHaveBeenCalled();
|
|
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
|
|
// so mic2 should be in an activating state
|
|
screen.getByRole("menuitemradio", {
|
|
name: "Microphone 2 Activating…",
|
|
checked: false,
|
|
});
|
|
|
|
// The currently-selected mic1 item should not be activating
|
|
screen.getByRole("menuitemradio", {
|
|
name: "Microphone 1",
|
|
checked: true,
|
|
});
|
|
await act(async () => {
|
|
// resolve the promise that acutally updates the select option.
|
|
resolve();
|
|
await promise;
|
|
});
|
|
|
|
expect(onOptionUpdated).toHaveBeenCalled();
|
|
// Spinner should now be gone since the selection has caught up
|
|
const mic2ItemAfter = screen.getByRole("menuitemradio", {
|
|
name: "Microphone 2",
|
|
});
|
|
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
|
});
|
|
|
|
test("renders menu with toggle control and calls toggle callback", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelect = vi.fn();
|
|
const onVideoBlurToggle = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="video"
|
|
enabled={true}
|
|
videoBlurToggleClick={onVideoBlurToggle}
|
|
onSelect={onSelect}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Camera" }));
|
|
|
|
const toggle = screen.getByRole("menuitemcheckbox", {
|
|
name: "Blur background",
|
|
});
|
|
expect(toggle).toBeInTheDocument();
|
|
expect(toggle).toHaveAttribute("aria-checked", "false");
|
|
|
|
await user.click(toggle);
|
|
|
|
expect(onVideoBlurToggle).toHaveBeenCalled();
|
|
});
|
|
|
|
test("marks the selected menu item as checked", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic2"
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
|
|
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
|
|
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
|
|
});
|
|
|
|
test("disables every device while a selection is settling", async () => {
|
|
const user = userEvent.setup();
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
function Wrapper(): JSX.Element {
|
|
const [selectedOption, setSelectedOption] = useState("mic1");
|
|
return (
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption={selectedOption}
|
|
onSelect={(id) => {
|
|
void promise.then(() => setSelectedOption(id));
|
|
}}
|
|
outputOptions={[
|
|
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
|
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
|
]}
|
|
selectedOutputOption="spk1"
|
|
onSelectOutput={vi.fn()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const { getByRole } = renderComponent(<Wrapper />);
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
await user.click(
|
|
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
|
);
|
|
|
|
// In flight: nothing else can be picked, in either section, so a second
|
|
// request cannot overtake the first.
|
|
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
|
|
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
|
|
"aria-disabled",
|
|
"true",
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
resolve();
|
|
await promise;
|
|
});
|
|
|
|
// Settled: choosable again.
|
|
expect(
|
|
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
|
|
).not.toHaveAttribute("aria-disabled", "true");
|
|
expect(
|
|
screen.getByRole("menuitemradio", { name: "Headset" }),
|
|
).not.toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
|
|
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="video"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Camera 1" }, id: "cam1" },
|
|
{ label: { type: "name", name: "Camera 2" }, id: "cam2" },
|
|
]}
|
|
selectedOption="cam1"
|
|
onSelect={vi.fn()}
|
|
videoBlurToggleClick={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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 2", checked: false });
|
|
// And background blur is still reachable from here.
|
|
expect(
|
|
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic1"
|
|
onSelect={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
const list = screen
|
|
.getByRole("menuitemradio", { name: "Microphone 1" })
|
|
.closest("[data-focus-modality]");
|
|
|
|
// The menu focuses whatever the pointer is over, so focus alone says
|
|
// nothing about how someone is navigating.
|
|
expect(list).toHaveAttribute("data-focus-modality", "pointer");
|
|
|
|
await user.keyboard("{ArrowDown}");
|
|
expect(list).toHaveAttribute("data-focus-modality", "keyboard");
|
|
|
|
await user.pointer({
|
|
target: screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
|
coords: { clientX: 10, clientY: 10 },
|
|
});
|
|
expect(list).toHaveAttribute("data-focus-modality", "pointer");
|
|
});
|
|
|
|
test("marks the selected device with the accent fill", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic2"
|
|
onSelect={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
|
|
const selected = screen
|
|
.getByRole("menuitemradio", { name: "Microphone 2" })
|
|
.querySelector("input[type=radio]");
|
|
expect(selected).toBeChecked();
|
|
// A read-only control is painted muted, which loses the accent fill that
|
|
// marks the selection and makes the menu differ from settings.
|
|
expect(selected).not.toHaveAttribute("readonly");
|
|
});
|
|
|
|
test("the open menu has no accessibility violations", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic1"
|
|
onSelect={vi.fn()}
|
|
outputOptions={[
|
|
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
|
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
|
]}
|
|
selectedOutputOption="spk1"
|
|
onSelectOutput={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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"]');
|
|
expect(await axe(menu as HTMLElement)).toHaveNoViolations();
|
|
});
|
|
|
|
test("lists speaker and microphone sections", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic1"
|
|
onSelect={vi.fn()}
|
|
outputOptions={[
|
|
{
|
|
label: { type: "default", name: "Built-in Output" },
|
|
id: "default",
|
|
},
|
|
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
|
]}
|
|
selectedOutputOption="default"
|
|
onSelectOutput={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
|
|
screen.getByRole("menuitemradio", {
|
|
name: "Default (Built-in Output)",
|
|
checked: true,
|
|
});
|
|
screen.getByRole("menuitemradio", { name: "Headset", checked: false });
|
|
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: true });
|
|
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: false });
|
|
});
|
|
|
|
test("calls the output select callback on speaker click", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelectOutput = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
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={onSelectOutput}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
await user.click(screen.getByRole("menuitemradio", { name: "Headset" }));
|
|
|
|
expect(onSelectOutput).toHaveBeenCalledWith("spk2");
|
|
});
|
|
|
|
test("shows a single device entry disabled", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelect = vi.fn();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
]}
|
|
selectedOption="mic1"
|
|
onSelect={onSelect}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
|
|
// Shown rather than hidden, so the menu keeps its shape, but not choosable.
|
|
const only = screen.getByRole("menuitemradio", { name: "Microphone 1" });
|
|
expect(only).toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
|
|
test("shows the speaker section disabled when output selection is unsupported", async () => {
|
|
const user = userEvent.setup();
|
|
const { getByRole } = renderComponent(
|
|
<MediaMuteAndSwitchButton
|
|
title="Switcher"
|
|
iconsAndLabels="audio"
|
|
enabled={true}
|
|
options={[
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
|
]}
|
|
selectedOption="mic1"
|
|
onSelect={vi.fn()}
|
|
outputOptions={[
|
|
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
|
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
|
]}
|
|
selectedOutputOption="spk1"
|
|
// No callback: nothing can be picked here.
|
|
onSelectOutput={undefined}
|
|
/>,
|
|
);
|
|
|
|
await user.click(getByRole("button", { name: "Microphone" }));
|
|
|
|
expect(
|
|
screen.getByRole("menuitemradio", { name: "Speakers" }),
|
|
).toHaveAttribute("aria-disabled", "true");
|
|
expect(
|
|
screen.getByRole("menuitemradio", { name: "Headset" }),
|
|
).toHaveAttribute("aria-disabled", "true");
|
|
// The microphone section is unaffected.
|
|
expect(
|
|
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
|
).not.toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
});
|