Cut the menu's comments down to what the code does not say

- Drop the title prop: no caller passes it, and the menu's name is for screen
  readers only.
- One line each for the output options, the Default placeholder id and the
  list's height bounds.
- Keep only the two non-obvious reasons behind the focus tracking.
- Call what moved the focus its source rather than its modality.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-23 20:23:50 +02:00
co-authored by Claude Opus 5.5
parent cf486cf42a
commit d9ed39c7aa
5 changed files with 31 additions and 118 deletions
+5 -5
View File
@@ -111,7 +111,7 @@ test("keeps every device reachable in a small container", async ({ page }) => {
await expect(last).toHaveAttribute("aria-checked", "true"); await expect(last).toHaveAttribute("aria-checked", "true");
}); });
test("tracks the focus modality of its own call, not the page", async ({ test("tracks the focus source of its own call, not the page", async ({
page, page,
}) => { }) => {
await installFakeDevices(page); await installFakeDevices(page);
@@ -124,7 +124,7 @@ test("tracks the focus modality of its own call, not the page", async ({
timeout: 60_000, timeout: 60_000,
}); });
await openDeviceList(page, pane); await openDeviceList(page, pane);
// The menu owns the modality, because every item it can focus has to answer // The menu owns the focus source, because every item it can focus has to answer
// to it — the device rows and the camera menu's blur toggle alike. // to it — the device rows and the camera menu's blur toggle alike.
const menu = page.getByRole("menu"); const menu = page.getByRole("menu");
@@ -134,7 +134,7 @@ test("tracks the focus modality of its own call, not the page", async ({
// suppresses the browser's own reaches this menu. The paint is asserted // 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 // standalone instead — in the story and in audio-menu.spec.ts. What is on
// trial here is which call the tracking answers for. // trial here is which call the tracking answers for.
await expect(menu).toHaveAttribute("data-focus-modality", "pointer"); await expect(menu).toHaveAttribute("data-focus-source", "pointer");
// A key pressed in the other call on this page — or anywhere in the host's // 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. // own page — says nothing about how this menu is being used.
@@ -143,11 +143,11 @@ test("tracks the focus modality of its own call, not the page", async ({
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
), ),
); );
await expect(menu).toHaveAttribute("data-focus-modality", "pointer"); await expect(menu).toHaveAttribute("data-focus-source", "pointer");
// A key pressed in this menu does. // A key pressed in this menu does.
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
await expect(menu).toHaveAttribute("data-focus-modality", "keyboard"); await expect(menu).toHaveAttribute("data-focus-source", "keyboard");
}); });
/** /**
@@ -131,7 +131,7 @@ Please see LICENSE in the repository root for full details.
} }
/* Shown only when the keyboard is what moved the focus. */ /* Shown only when the keyboard is what moved the focus. */
.menu[data-focus-modality="keyboard"] [role^="menuitem"]:focus { .menu[data-focus-source="keyboard"] [role^="menuitem"]:focus {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused); outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2)); outline-offset: calc(-1 * var(--cpd-border-width-2));
} }
@@ -122,7 +122,6 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {
args: { args: {
title: "SomeMenu",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -148,7 +147,6 @@ export const Default: Story = {
export const AudioMute: Story = { export const AudioMute: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: false, enabled: false,
options: [ options: [
@@ -172,7 +170,6 @@ export const AudioMute: Story = {
export const AudioUnmute: Story = { export const AudioUnmute: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -185,7 +182,6 @@ export const AudioUnmute: Story = {
export const VideoMute: Story = { export const VideoMute: Story = {
args: { args: {
title: "Camera",
iconsAndLabels: "video", iconsAndLabels: "video",
enabled: false, enabled: false,
options: [ options: [
@@ -199,7 +195,6 @@ export const VideoMute: Story = {
export const VideoUnmute: Story = { export const VideoUnmute: Story = {
args: { args: {
title: "Camera",
iconsAndLabels: "video", iconsAndLabels: "video",
enabled: true, enabled: true,
options: [ options: [
@@ -215,7 +210,6 @@ export const VideoUnmute: Story = {
export const SpeakerAndMicrophoneSections: Story = { export const SpeakerAndMicrophoneSections: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -296,7 +290,6 @@ export const SpeakerAndMicrophoneSections: Story = {
export const OutputCannotBeChosen: Story = { export const OutputCannotBeChosen: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -326,7 +319,6 @@ export const OutputCannotBeChosen: Story = {
export const OnlyOneDevice: Story = { export const OnlyOneDevice: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }], options: [{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }],
@@ -354,7 +346,6 @@ export const OnlyOneDevice: Story = {
export const SelectionSettling: Story = { export const SelectionSettling: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -390,14 +381,13 @@ export const SelectionSettling: Story = {
* The focus ring belongs to the keyboard. Radix focuses whatever the pointer is * 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. * over, so a ring that followed focus alone would trail the mouse.
* *
* Asserted on the painted outline rather than on `data-focus-modality`: the * Asserted on the painted outline rather than on `data-focus-source`: the
* attribute is what the stylesheet keys off, so asserting it would pass even * attribute is what the stylesheet keys off, so asserting it would pass even
* with the rule deleted. * with the rule deleted.
*/ */
export const KeyboardFocusRing: Story = { export const KeyboardFocusRing: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -435,7 +425,6 @@ export const KeyboardFocusRing: Story = {
export const ManyDevices: Story = { export const ManyDevices: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: Array.from({ length: 20 }, (_, i) => ({ options: Array.from({ length: 20 }, (_, i) => ({
@@ -520,7 +509,6 @@ function outlineWidth(element: HTMLElement): number {
export const OutputNotEnumerated: Story = { export const OutputNotEnumerated: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -555,7 +543,6 @@ export const OutputNotEnumerated: Story = {
export const MeterAlignsWithTheDeviceRows: Story = { export const MeterAlignsWithTheDeviceRows: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -598,7 +585,6 @@ function centre(element: Element): number {
export const KeyboardReachesEveryDevice: Story = { export const KeyboardReachesEveryDevice: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
// Enough of them that the list scrolls well past its own height, so that // Enough of them that the list scrolls well past its own height, so that
@@ -663,7 +649,6 @@ export const KeyboardReachesEveryDevice: Story = {
export const HeadingsStayWhileScrolling: Story = { export const HeadingsStayWhileScrolling: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: Array.from({ length: 20 }, (_, i) => ({ options: Array.from({ length: 20 }, (_, i) => ({
@@ -68,7 +68,7 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders", () => { test("renders", () => {
const { container } = renderComponent( const { container } = renderComponent(
<TooltipProvider> <TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} /> <MediaMuteAndSwitchButton iconsAndLabels={"audio"} />
</TooltipProvider>, </TooltipProvider>,
); );
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@@ -80,11 +80,7 @@ describe("MediaMuteAndSwitchButton", () => {
enabled: boolean, enabled: boolean,
): RenderResult => { ): RenderResult => {
return renderComponent( return renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton iconsAndLabels={type} enabled={enabled} />,
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>,
); );
}; };
const renderAudioEndabled = renderLabels("audio", true); const renderAudioEndabled = renderLabels("audio", true);
@@ -111,7 +107,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn(); const onMute = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute} onMuteClick={onMute}
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
@@ -128,7 +123,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn(); const onMute = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute} onMuteClick={onMute}
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
@@ -149,7 +143,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn(); const onMute = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute} onMuteClick={onMute}
iconsAndLabels="video" iconsAndLabels="video"
enabled={true} enabled={true}
@@ -169,11 +162,7 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const requestDeviceNames = vi.fn(); const requestDeviceNames = vi.fn();
renderComponent( renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton iconsAndLabels="audio" enabled />,
title="Switcher"
iconsAndLabels="audio"
enabled
/>,
{ requestDeviceNames }, { requestDeviceNames },
); );
@@ -187,7 +176,6 @@ describe("MediaMuteAndSwitchButton", () => {
renderComponent( renderComponent(
<> <>
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled enabled
options={[ options={[
@@ -197,7 +185,6 @@ describe("MediaMuteAndSwitchButton", () => {
selectedOption="mic1" selectedOption="mic1"
/> />
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video" iconsAndLabels="video"
enabled enabled
options={[ options={[
@@ -223,7 +210,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -247,7 +233,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -276,7 +261,6 @@ describe("MediaMuteAndSwitchButton", () => {
const [selectedOption, setSelectedOption] = useState("mic1"); const [selectedOption, setSelectedOption] = useState("mic1");
return ( return (
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -336,7 +320,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onVideoBlurToggle = vi.fn(); const onVideoBlurToggle = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video" iconsAndLabels="video"
enabled={true} enabled={true}
videoBlurToggleClick={onVideoBlurToggle} videoBlurToggleClick={onVideoBlurToggle}
@@ -361,7 +344,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -385,7 +367,6 @@ describe("MediaMuteAndSwitchButton", () => {
const [selectedOption, setSelectedOption] = useState("mic1"); const [selectedOption, setSelectedOption] = useState("mic1");
return ( return (
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -442,7 +423,6 @@ describe("MediaMuteAndSwitchButton", () => {
// was asked for never becomes the selection. // was asked for never becomes the selection.
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -485,7 +465,6 @@ describe("MediaMuteAndSwitchButton", () => {
]; ];
const menu = (options: MenuOptions[]): JSX.Element => ( const menu = (options: MenuOptions[]): JSX.Element => (
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={options} options={options}
@@ -527,7 +506,6 @@ describe("MediaMuteAndSwitchButton", () => {
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<> <>
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -580,7 +558,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video" iconsAndLabels="video"
enabled={true} enabled={true}
options={[ options={[
@@ -608,7 +585,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -623,27 +599,26 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
const list = screen const list = screen
.getByRole("menuitemradio", { name: "Microphone 1" }) .getByRole("menuitemradio", { name: "Microphone 1" })
.closest("[data-focus-modality]"); .closest("[data-focus-source]");
// The menu focuses whatever the pointer is over, so focus alone says // The menu focuses whatever the pointer is over, so focus alone says
// nothing about how someone is navigating. // nothing about how someone is navigating.
expect(list).toHaveAttribute("data-focus-modality", "pointer"); expect(list).toHaveAttribute("data-focus-source", "pointer");
await user.keyboard("{ArrowDown}"); await user.keyboard("{ArrowDown}");
expect(list).toHaveAttribute("data-focus-modality", "keyboard"); expect(list).toHaveAttribute("data-focus-source", "keyboard");
await user.pointer({ await user.pointer({
target: screen.getByRole("menuitemradio", { name: "Microphone 2" }), target: screen.getByRole("menuitemradio", { name: "Microphone 2" }),
coords: { clientX: 10, clientY: 10 }, coords: { clientX: 10, clientY: 10 },
}); });
expect(list).toHaveAttribute("data-focus-modality", "pointer"); expect(list).toHaveAttribute("data-focus-source", "pointer");
}); });
test("marks the selected device with the accent fill", async () => { test("marks the selected device with the accent fill", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -670,7 +645,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -700,7 +674,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -734,7 +707,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -771,7 +743,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelectOutput = vi.fn(); const onSelectOutput = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -799,7 +770,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -821,7 +791,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -861,7 +830,6 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
+17 -57
View File
@@ -47,11 +47,6 @@ export interface MenuOptions {
} }
export interface MediaMuteAndSwitchButtonProps { export interface MediaMuteAndSwitchButtonProps {
/**
* The accessible name of the menu. Defaults to a translated name for the
* media kind. Never shown: each section carries its own heading.
*/
title?: string;
/** If the Mute button is enabled */ /** If the Mute button is enabled */
enabled?: boolean; enabled?: boolean;
/** Callback if the mute button is clicked */ /** Callback if the mute button is clicked */
@@ -63,10 +58,7 @@ export interface MediaMuteAndSwitchButtonProps {
options?: MenuOptions[]; options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */ /** The option that will currently be rendered as the selected option */
selectedOption?: string; selectedOption?: string;
/** /** Output (speaker) devices. Audio menu only. */
* Output (speaker) devices, shown as their own section above the input
* section. Audio menu only; omitted entirely for video.
*/
outputOptions?: MenuOptions[]; outputOptions?: MenuOptions[];
/** The output option currently rendered as selected */ /** The output option currently rendered as selected */
selectedOutputOption?: string; selectedOutputOption?: string;
@@ -86,33 +78,16 @@ export interface MediaMuteAndSwitchButtonProps {
const BLUR_ID = "blur"; const BLUR_ID = "blur";
/** /** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
* Stands for wherever the platform is sending audio, where it will not say.
*
* Not a device id the browser would recognise: nothing can be selected on a
* platform that lists no outputs, so this is only ever shown, never sent.
*/
const DEFAULT_OUTPUT_ID = "default"; const DEFAULT_OUTPUT_ID = "default";
/** /** Largest share of the call area's height the device list may take. */
* The share of the call area the device list may fill.
*
* The menu carries its headings and the level meter as well, and a list that
* took the whole call would hide the call it belongs to.
*/
const LIST_SHARE_OF_CALL = 0.6; const LIST_SHARE_OF_CALL = 0.6;
/** /** Smallest device list height in px, so a short call still shows more than one device. */
* The shortest the device list may be, whatever the call measures.
*
* A share alone collapses in a small call to a list that shows one device and
* gives no sign that there are others. Scrolling a short list is the better
* failure.
*/
const MIN_LIST_HEIGHT = 160; const MIN_LIST_HEIGHT = 160;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled, enabled,
busy, busy,
onMuteClick, onMuteClick,
@@ -144,31 +119,17 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
const devices = useMediaDevices(); const devices = useMediaDevices();
/** /**
* Tracks which modality moved the focus, for as long as the list is mounted. * Records on the menu whether the keyboard or the pointer moved focus, for
* * the focus ring. `:focus-visible` can't tell: Radix focuses whatever the
* - Ours to track, because Radix focuses whatever the pointer is over, so * pointer is over. Listened for on the menu, not the document, so a second
* `:focus-visible` answers for the pointer: Chromium says yes to anything * Element Call on the page doesn't answer for this one.
* after a key press, Firefox says no to programmatic focus.
* - A ref, not an effect on `menuOpen`: that state is ours and the open menu
* is Radix's, and an effect keyed on ours can run before Radix has mounted
* the content. The list existing is the honest signal.
* - On the menu, not the document: Element Call can be mounted twice in a
* host's page and this menu is portalled out of the call root, so a
* document listener would answer for the other instance too.
* - On the menu, not the list, because the first arrow key arrives while the
* menu itself holds focus — and because the blur toggle is the menu's
* child, not the list's, and has to answer to it as well.
* - In a dataset rather than state: which modality someone is using changes
* nothing that has to be rendered again.
*/ */
const trackFocusModality = useCallback( const trackFocusSource = useCallback(
(list: HTMLDivElement | null): (() => void) | undefined => { (list: HTMLDivElement | null): (() => void) | undefined => {
const menu = list?.closest<HTMLElement>('[role="menu"]'); const menu = list?.closest<HTMLElement>('[role="menu"]');
if (menu === null || menu === undefined) return; if (menu === null || menu === undefined) return;
// Each opening starts over: the modality belongs to whoever is using this const record = (source: "keyboard" | "pointer"): void => {
// menu now, not to whoever last used it. menu.dataset.focusSource = source;
const record = (modality: "keyboard" | "pointer"): void => {
menu.dataset.focusModality = modality;
}; };
record("pointer"); record("pointer");
const usedKeyboard = (): void => record("keyboard"); const usedKeyboard = (): void => record("keyboard");
@@ -253,18 +214,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
: []; : [];
let optionsButtonLabel: string; let optionsButtonLabel: string;
let defaultMenuTitle: string; let menuTitle: string;
let numberedLabel: (number: number) => string; let numberedLabel: (number: number) => string;
switch (iconsAndLabels) { switch (iconsAndLabels) {
case "video": case "video":
optionsButtonLabel = t("settings.devices.camera"); optionsButtonLabel = t("settings.devices.camera");
defaultMenuTitle = t("settings.devices.camera_source"); menuTitle = t("settings.devices.camera_source");
numberedLabel = (n): string => numberedLabel = (n): string =>
t("settings.devices.camera_numbered", { n }); t("settings.devices.camera_numbered", { n });
break; break;
case "audio": case "audio":
optionsButtonLabel = t("settings.devices.microphone"); optionsButtonLabel = t("settings.devices.microphone");
defaultMenuTitle = t("settings.devices.mic_source"); menuTitle = t("settings.devices.mic_source");
numberedLabel = (n): string => numberedLabel = (n): string =>
t("settings.devices.microphone_numbered", { n }); t("settings.devices.microphone_numbered", { n });
break; break;
@@ -394,9 +355,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{button} {button}
<Menu <Menu
className={styles.menu} className={styles.menu}
title={title ?? defaultMenuTitle} // Named for screen readers only: each section has its own heading.
// Each section carries its own heading, so the menu's own title would title={menuTitle}
// sit on top of the first one. Kept for the accessible name only.
showTitle={false} showTitle={false}
open={menuOpen} open={menuOpen}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
@@ -416,7 +376,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
} }
> >
<div <div
ref={trackFocusModality} ref={trackFocusSource}
// Transparent to assistive technology, so the menu still sees its // Transparent to assistive technology, so the menu still sees its
// items as its own children. // items as its own children.
role="none" role="none"