mirror of
https://github.com/vector-im/element-call.git
synced 2026-05-13 10:34:37 +00:00
Add MediaMuteAndSwitchButton component (storybook only) (#3938)
* add MeidaMuteAndSwitchButton * User button in footer * Add tests * update styling (dark bg on menu open + chevron white + chevron up) * fix tests * add storybook to CI only add storybook with storybook label test names another env name test TestName new default name remove label condition Update pr-deploy.yaml * Update pr-deploy.yaml * add toggle example to default component * hook up footer select actions * fix video audio button (swapped) and lable in story
This commit is contained in:
@@ -88,6 +88,24 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioAndVideoOptions: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: true,
|
||||
audioOptions: [
|
||||
{ label: "Microphone 1", id: "1" },
|
||||
{ label: "Microphone 2", id: "2" },
|
||||
],
|
||||
videoOptions: [
|
||||
{ label: "Camera 1", id: "1" },
|
||||
{ label: "Camera 2", id: "2" },
|
||||
],
|
||||
selectedAudio: "2",
|
||||
selectedVideo: "1",
|
||||
},
|
||||
};
|
||||
export const WithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from "../button";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -79,6 +83,13 @@ export interface FooterProps {
|
||||
// debug stuff
|
||||
debugTileLayout?: boolean;
|
||||
tileStoreGeneration?: number;
|
||||
|
||||
audioOptions?: MenuOptions[];
|
||||
videoOptions?: MenuOptions[];
|
||||
selectedAudio?: string;
|
||||
selectedVideo?: string;
|
||||
selectAudioDevice?: (deviceId: string) => void;
|
||||
selectVideoDevice?: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
@@ -104,6 +115,13 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
hangup,
|
||||
debugTileLayout,
|
||||
tileStoreGeneration,
|
||||
|
||||
audioOptions,
|
||||
videoOptions,
|
||||
selectedAudio,
|
||||
selectedVideo,
|
||||
selectAudioDevice,
|
||||
selectVideoDevice,
|
||||
}) => {
|
||||
const buttons: JSX.Element[] = [];
|
||||
const buttonSize = asPip ? "md" : "lg";
|
||||
@@ -125,24 +143,58 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
onClick={toggleVideo}
|
||||
disabled={toggleVideo === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Mic Source"}
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
onMuteClick={toggleAudio}
|
||||
data-testid="incall_mute"
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioDevice}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if ((videoOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Camera Source"}
|
||||
key="video"
|
||||
iconsAndLabels="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
onMuteClick={toggleVideo}
|
||||
data-testid="incall_videomute"
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoDevice}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
onClick={toggleVideo}
|
||||
disabled={toggleVideo === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (toggleScreenSharing !== undefined) {
|
||||
buttons.push(
|
||||
|
||||
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
border-radius: 32px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
.containerOpen {
|
||||
background-color: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
.chevronIconOpen > svg {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
.menuButton {
|
||||
width: 40px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.itemIcon {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.rotate {
|
||||
animation: spinner 1.5s linear infinite;
|
||||
}
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
117
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
117
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
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 { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { fn, userEvent, within, expect } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "SomeMenu",
|
||||
iconsAndLabels: {
|
||||
IconEnabled: AdvancedSettingsIcon,
|
||||
IconDisabled: AdvancedSettingsIcon,
|
||||
enabledLabel: "Enabled",
|
||||
disabledLabel: "Disabled",
|
||||
optionsButtonLabel: "Options",
|
||||
},
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: "option 1", id: "1" },
|
||||
{ label: "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: "Microphone 1", id: "1" },
|
||||
{ label: "Microphone 2", id: "2" },
|
||||
],
|
||||
toggles: [
|
||||
{
|
||||
label: "example toggle",
|
||||
id: "t0",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
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.getByLabelText("Unmute microphone");
|
||||
await userEvent.click(muteButton);
|
||||
await expect(args.onMuteClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioUnmute: Story = {
|
||||
args: {
|
||||
title: "Microphone",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: "Microphone 1", id: "1" },
|
||||
{ label: "Microphone 2", id: "2" },
|
||||
],
|
||||
toggles: [],
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoMute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: false,
|
||||
options: [
|
||||
{ label: "Camera 1", id: "1" },
|
||||
{ label: "Camera 2", id: "2" },
|
||||
],
|
||||
toggles: [],
|
||||
selectedOption: "1",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoUnmute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: "Camera 1", id: "1" },
|
||||
{ label: "Camera 2", id: "2" },
|
||||
],
|
||||
toggles: [
|
||||
{
|
||||
label: "Blur Background",
|
||||
id: "background_blurring",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
226
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
226
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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 { act, render, screen, type RenderResult } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type JSX, useState } from "react";
|
||||
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
|
||||
describe("MediaMuteAndSwitchButton", () => {
|
||||
test("renders", () => {
|
||||
const { container } = render(
|
||||
<MediaMuteAndSwitchButton title={"Switcher"} />,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("renders correct audio and video labels", () => {
|
||||
const renderLabels = (
|
||||
type: "video" | "audio",
|
||||
enabled: boolean,
|
||||
): RenderResult => {
|
||||
return render(
|
||||
<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("button", { name: "Mute microphone" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderVideoEnabled.getByRole("button", { name: "Start video" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls mute on mute press", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Mute microphone" }));
|
||||
|
||||
expect(onMute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls select callback on menu click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { 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 } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { 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: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={(id) => {
|
||||
onSelectPressed();
|
||||
void promise.then(() => {
|
||||
setSelectedOption(id);
|
||||
onOptionUpdated();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByRole } = render(<Wrapper />);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||
|
||||
expect(onSelectPressed).toHaveBeenCalled();
|
||||
expect(onOptionUpdated).not.toHaveBeenCalled();
|
||||
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
|
||||
// so a spinner should appear on the mic2 item
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
|
||||
|
||||
// The currently-selected mic1 item should not have a spinner
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
expect(mic1Item.querySelector(".rotate")).toBeNull();
|
||||
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("menuitem", {
|
||||
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 { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const toggle = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Background blur",
|
||||
});
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("bg_blur");
|
||||
});
|
||||
|
||||
test("renders check icon to mark the selected menu item", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = render(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: "Microphone 1", id: "mic1" },
|
||||
{ label: "Microphone 2", id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic2"
|
||||
/>,
|
||||
);
|
||||
|
||||
// open menu
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
||||
|
||||
// The unselected item (mic1) only renders its IconOptions SVG
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
|
||||
});
|
||||
});
|
||||
230
src/components/MediaMuteAndSwitchButton.tsx
Normal file
230
src/components/MediaMuteAndSwitchButton.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
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 ComponentType, useState, type FC } from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MicOffSolidIcon,
|
||||
MicOnIcon,
|
||||
MicOnSolidIcon,
|
||||
SpinnerIcon,
|
||||
VideoCallIcon,
|
||||
VideoCallOffSolidIcon,
|
||||
VideoCallSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: string;
|
||||
id: string;
|
||||
}
|
||||
export interface ToggleOption {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IconsAndLabels {
|
||||
/** The Icon used if the mute button is enabled */
|
||||
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||
/** The Icon used if the mute button is disabled */
|
||||
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||
/** The icon used for the different options */
|
||||
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||
enabledLabel: string;
|
||||
disabledLabel: string;
|
||||
optionsButtonLabel: string;
|
||||
}
|
||||
|
||||
export interface MediaMuteAndSwitchButtonProps {
|
||||
/** The title used in the Switcher modal. */
|
||||
title: string;
|
||||
/** If the Mute button is enabled */
|
||||
enabled?: boolean;
|
||||
/** Callback if the mute button is clicked */
|
||||
onMuteClick?: () => void;
|
||||
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
|
||||
/** The options available for the media device selector modal */
|
||||
options?: MenuOptions[];
|
||||
/** The option that will currently be rendered as the selected option */
|
||||
selectedOption?: string;
|
||||
/**
|
||||
* The available toggles (including there current state)
|
||||
* The toggle state is not stored by this component.
|
||||
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
|
||||
*/
|
||||
toggles?: ToggleOption[];
|
||||
/**
|
||||
* For any toggle and option this method will be called.
|
||||
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
|
||||
*/
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
title,
|
||||
enabled,
|
||||
onMuteClick,
|
||||
iconsAndLabels: iconsAndLabelsWithDefaultCases,
|
||||
options,
|
||||
selectedOption,
|
||||
toggles,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
let iconsAndLabels: IconsAndLabels | undefined;
|
||||
switch (iconsAndLabelsWithDefaultCases) {
|
||||
case "video":
|
||||
iconsAndLabels = {
|
||||
IconEnabled: VideoCallSolidIcon,
|
||||
IconDisabled: VideoCallOffSolidIcon,
|
||||
IconOptions: VideoCallIcon,
|
||||
disabledLabel: t("stop_video_button_label"),
|
||||
enabledLabel: t("start_video_button_label"),
|
||||
optionsButtonLabel: t("settings.devices.microphone"),
|
||||
};
|
||||
break;
|
||||
case "audio":
|
||||
iconsAndLabels = {
|
||||
IconEnabled: MicOnSolidIcon,
|
||||
IconDisabled: MicOffSolidIcon,
|
||||
IconOptions: MicOnIcon,
|
||||
disabledLabel: t("mute_microphone_button_label"),
|
||||
enabledLabel: t("unmute_microphone_button_label"),
|
||||
optionsButtonLabel: t("settings.devices.microphone"),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
iconsAndLabels = iconsAndLabelsWithDefaultCases;
|
||||
break;
|
||||
}
|
||||
const {
|
||||
IconEnabled,
|
||||
IconDisabled,
|
||||
IconOptions,
|
||||
disabledLabel,
|
||||
enabledLabel,
|
||||
optionsButtonLabel,
|
||||
} = iconsAndLabels ?? {
|
||||
IconEnabled: undefined,
|
||||
IconDisabled: undefined,
|
||||
IconOptions: undefined,
|
||||
disabledLabel: undefined,
|
||||
enabledLabel: undefined,
|
||||
optionsButtonLabel: undefined,
|
||||
};
|
||||
{
|
||||
logger.info(
|
||||
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
|
||||
selectedOption,
|
||||
" !==",
|
||||
"option.id",
|
||||
" && ",
|
||||
plannedSelection,
|
||||
" === ",
|
||||
"option.id",
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
[styles.container]: true,
|
||||
[styles.containerOpen]: menuOpen,
|
||||
})}
|
||||
>
|
||||
{/* The mute button lives inside */}
|
||||
<Button
|
||||
iconOnly
|
||||
Icon={enabled ? IconEnabled : IconDisabled}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
kind={enabled ? "secondary" : "primary"}
|
||||
size="lg"
|
||||
className={styles.button}
|
||||
aria-label={enabled ? disabledLabel : enabledLabel}
|
||||
/>
|
||||
<Menu
|
||||
title={title}
|
||||
showTitle={true}
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
side="top"
|
||||
trigger={
|
||||
<Button
|
||||
iconOnly
|
||||
className={classNames({
|
||||
[styles.menuButton]: true,
|
||||
[styles.chevronIconOpen]: menuOpen,
|
||||
})}
|
||||
Icon={menuOpen ? ChevronUpIcon : ChevronDownIcon}
|
||||
kind={"tertiary"}
|
||||
size="lg"
|
||||
aria-label={optionsButtonLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{options?.map((option) => (
|
||||
<MenuItem
|
||||
hideChevron
|
||||
label={option.label}
|
||||
Icon={
|
||||
IconOptions && (
|
||||
<IconOptions
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (option.id === selectedOption) return;
|
||||
setPlannedSelection(option.id);
|
||||
onSelect?.(option.id);
|
||||
}}
|
||||
key={option.id}
|
||||
>
|
||||
{selectedOption === option.id && (
|
||||
<CheckIcon width={24} height={24} />
|
||||
)}
|
||||
{selectedOption !== option.id && plannedSelection === option.id && (
|
||||
<SpinnerIcon width={24} height={24} className={styles.rotate} />
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||
{toggles?.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
onSelect?.(toggle.id);
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<button
|
||||
class="_button_1nw83_8 button _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_0_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
Reference in New Issue
Block a user