mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
- Mark the selection with a radio control instead of a trailing check icon beside a device glyph, matching the design and the settings modal, so both device pickers read the same way. - Take the visual from Compound's RadioInput rather than restyling a span of our own. RadioControl is wrapped in a Radix form control and needs a Form ancestor, which a dropdown menu has no business providing; RadioInput does not. - Keep rows as menuitemradio with aria-checked, rendered through MenuItem as="div" so an input is never nested inside a button. - Render the radio aria-hidden and not focusable: Radix owns focus inside the menu, and aria-checked carries the state. - Leave the activating spinner unchanged. - Keep every scope.behavior call inside the function that owns the scope, which no-observablescope-leak requires. - Cover the speaker section, an output that cannot be chosen, and a lone device shown disabled, with unit tests and stories for each. Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC7
200 lines
5.7 KiB
TypeScript
200 lines
5.7 KiB
TypeScript
/*
|
|
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 { fn, userEvent, within, expect } from "storybook/test";
|
|
import { type JSX } from "react";
|
|
|
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
|
import { MediaDevices } from "../state/MediaDevices";
|
|
import { globalScope } from "../state/ObservableScope";
|
|
|
|
const mediaDevices = new MediaDevices(globalScope, {
|
|
controlledAudioDevices: false,
|
|
});
|
|
|
|
const meta = {
|
|
component: MediaMuteAndSwitchButton,
|
|
decorators: [
|
|
(Story): JSX.Element => (
|
|
<MediaDevicesContext value={mediaDevices}>
|
|
<Story />
|
|
</MediaDevicesContext>
|
|
),
|
|
],
|
|
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
|
|
|
|
export default meta;
|
|
type Story = StoryObj<typeof meta>;
|
|
|
|
export const Default: Story = {
|
|
args: {
|
|
title: "SomeMenu",
|
|
iconsAndLabels: "audio",
|
|
enabled: true,
|
|
options: [
|
|
{ label: { type: "name", name: "Option 1" }, id: "1" },
|
|
{ label: { type: "name", name: "Option 2" }, id: "2" },
|
|
],
|
|
selectedOption: "1",
|
|
onMuteClick: fn(),
|
|
onSelect: fn(),
|
|
},
|
|
};
|
|
|
|
export const AudioMute: Story = {
|
|
args: {
|
|
...Default.args,
|
|
title: "Microphone",
|
|
iconsAndLabels: "audio",
|
|
enabled: false,
|
|
options: [
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
|
],
|
|
videoBlurEnabled: true,
|
|
videoBlurToggleClick: fn(),
|
|
selectedOption: "2",
|
|
},
|
|
play: async ({ args, canvasElement }) => {
|
|
const canvas = within(canvasElement);
|
|
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
|
|
// (both are TODO placeholders in the component). The mute button is first in the DOM.
|
|
const muteButton = canvas.getByTestId("incall_mute");
|
|
await userEvent.click(muteButton);
|
|
await expect(args.onMuteClick).toHaveBeenCalled();
|
|
},
|
|
};
|
|
|
|
export const AudioUnmute: Story = {
|
|
args: {
|
|
title: "Microphone",
|
|
iconsAndLabels: "audio",
|
|
enabled: true,
|
|
options: [
|
|
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
|
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
|
],
|
|
|
|
selectedOption: "2",
|
|
},
|
|
};
|
|
|
|
export const VideoMute: Story = {
|
|
args: {
|
|
title: "Camera",
|
|
iconsAndLabels: "video",
|
|
enabled: false,
|
|
options: [
|
|
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
|
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
|
],
|
|
|
|
selectedOption: "1",
|
|
},
|
|
};
|
|
|
|
export const VideoUnmute: Story = {
|
|
args: {
|
|
title: "Camera",
|
|
iconsAndLabels: "video",
|
|
enabled: true,
|
|
options: [
|
|
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
|
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
|
],
|
|
videoBlurEnabled: true,
|
|
videoBlurToggleClick: fn(),
|
|
selectedOption: "2",
|
|
},
|
|
};
|
|
|
|
export const SpeakerAndMicrophoneSections: 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: "default", name: "Built-in Output" }, id: "default" },
|
|
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
|
],
|
|
selectedOutputOption: "default",
|
|
onSelectOutput: fn(),
|
|
},
|
|
play: async ({ args, canvasElement }) => {
|
|
const canvas = within(canvasElement);
|
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
|
|
|
const headset = await within(document.body).findByRole("menuitemradio", {
|
|
name: "Headset",
|
|
});
|
|
await userEvent.click(headset);
|
|
await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2");
|
|
},
|
|
};
|
|
|
|
export const OutputCannotBeChosen: 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",
|
|
// No callback: the speakers are listed, but none can be picked.
|
|
onSelectOutput: undefined,
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
const canvas = within(canvasElement);
|
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
|
|
|
const speakers = await within(document.body).findByRole("menuitemradio", {
|
|
name: "Speakers",
|
|
});
|
|
await expect(speakers).toHaveAttribute("aria-disabled", "true");
|
|
},
|
|
};
|
|
|
|
export const OnlyOneDevice: Story = {
|
|
args: {
|
|
...Default.args,
|
|
title: "Microphone",
|
|
iconsAndLabels: "audio",
|
|
enabled: true,
|
|
options: [{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }],
|
|
selectedOption: "mic1",
|
|
outputOptions: [{ label: { type: "name", name: "Speakers" }, id: "spk1" }],
|
|
selectedOutputOption: "spk1",
|
|
onSelectOutput: fn(),
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
const canvas = within(canvasElement);
|
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
|
|
|
// Shown rather than hidden, so the menu keeps its shape everywhere.
|
|
const only = await within(document.body).findByRole("menuitemradio", {
|
|
name: "Microphone 1",
|
|
});
|
|
await expect(only).toHaveAttribute("aria-disabled", "true");
|
|
},
|
|
};
|