diff --git a/locales/en/app.json b/locales/en/app.json index e31c7d50a..c010d5fb1 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -3,7 +3,6 @@ "user_menu": "User menu" }, "action": { - "blur_background": "Blur background", "close": "Close", "copy_link": "Copy link", "edit": "Edit", @@ -23,6 +22,12 @@ "upload_file": "Upload file" }, "analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy and our <6>Cookie Policy.", + "background_effects": { + "blur": "Blur", + "none": "None", + "numbered": "Background {{n}}", + "unavailable": "Background effects are not supported on this platform." + }, "call_ended_view": { "create_account_button": "Create account", "create_account_prompt": "<0>Why not finish by setting up a password to keep your account?<1>You'll be able to keep your name and set an avatar for use on future calls", @@ -212,6 +217,7 @@ "auto_gain_control_label": "Automatic gain control", "background_blur_header": "Background", "background_blur_label": "Blur the background of the video", + "background_effects_header": "Background effects", "bitrate_label": "Bitrate", "blur_not_supported_by_browser": "(Background blur is not supported by this device.)", "camera_header": "Camera quality", diff --git a/package.json b/package.json index c38fd68ed..8db7ed36b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@mediapipe/tasks-vision": "^0.10.18", "@playwright/test": "^1.60.0", "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-visually-hidden": "^1.0.3", "@react-spring/web": "^10.0.0", diff --git a/playwright/background-effects.spec.ts b/playwright/background-effects.spec.ts new file mode 100644 index 000000000..81d7a3ee4 --- /dev/null +++ b/playwright/background-effects.spec.ts @@ -0,0 +1,126 @@ +/* +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 { expect, test, type Locator, type Page } from "@playwright/test"; + +import { SpaHelpers } from "./spa-helpers.ts"; + +// Background effects need WebGL2, and headless Firefox on a CI runner has none, +// so it rightly offers none of them. +test.skip( + ({ browserName }) => browserName === "firefox", + "Background effects need WebGL2, which headless Firefox on CI does not have", +); + +type Colour = [number, number, number]; + +test.describe("background effects", () => { + test("pre-join preview shows the chosen effect", async ({ page }) => { + test.slow(); + await page.goto("/"); + await SpaHelpers.createCall(page, "Effect user", "Background effects"); + const preview = page.locator("video").first(); + const camera = await averageColour(preview); + + await page.getByRole("button", { name: "Camera", exact: true }).click(); + const tile = page + .getByRole("group", { name: "Background effects" }) + .getByRole("menuitemradio", { name: "Background 1" }); + await tile.click(); + await expect(tile).toHaveAttribute("aria-checked", "true"); + + // The fake camera shows no person, so the whole frame is background: the + // preview takes on the picture's colours and leaves the camera's. + const picture = await averageColour(tile.locator("img")); + // Long enough for the first effect of a session to be built. + await expect + .poll(async () => distance(await averageColour(preview), picture), { + timeout: 60_000, + }) + .toBeLessThan(distance(camera, picture) / 2); + }); +}); + +test.describe("no effect", () => { + test("draws nothing, even after blurring", async ({ page }) => { + test.setTimeout(120_000); + await countDraws(page); + await page.goto("/"); + await SpaHelpers.createCall(page, "Idle user", "No effect after blur"); + + await page.getByRole("button", { name: "Camera", exact: true }).click(); + const effects = page.getByRole("group", { name: "Background effects" }); + await effects.getByRole("menuitemradio", { name: "Blur" }).click(); + // Long enough for the first effect of a session to be built. + await expect + .poll(async () => drawsPerSecond(page), { timeout: 60_000 }) + .toBeGreaterThan(0); + const blurred = await drawsPerSecond(page); + + await effects.getByRole("menuitemradio", { name: "None" }).click(); + // A frame already on its way may still be drawn. + await expect.poll(async () => drawsPerSecond(page)).toBe(0); + test.info().annotations.push({ + type: "WebGL draws per second", + description: `blurred ${blurred}, none 0`, + }); + }); +}); + +/** Counts every WebGL draw on the page, the segmenter's and the effect's. */ +async function countDraws(page: Page): Promise { + await page.addInitScript(() => { + const counter = { draws: 0 }; + (window as unknown as { webglDraws: typeof counter }).webglDraws = counter; + for (const name of ["drawArrays", "drawElements"] as const) { + const draw = WebGL2RenderingContext.prototype[name] as ( + ...args: unknown[] + ) => void; + WebGL2RenderingContext.prototype[name] = function ( + this: WebGL2RenderingContext, + ...args: unknown[] + ): void { + counter.draws += 1; + draw.apply(this, args); + } as never; + } + }); +} + +/** WebGL draws over the next second. */ +async function drawsPerSecond(page: Page): Promise { + return page.evaluate(async () => { + const counter = (window as unknown as { webglDraws: { draws: number } }) + .webglDraws; + const before = counter.draws; + await new Promise((resolve) => setTimeout(resolve, 1000)); + return counter.draws - before; + }); +} + +/** The mean colour of a video's current frame or an image, drawn small. */ +async function averageColour(element: Locator): Promise { + return element.evaluate( + async (source: HTMLVideoElement | HTMLImageElement) => { + if (source instanceof HTMLImageElement) await source.decode(); + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 32; + const context = canvas.getContext("2d")!; + context.drawImage(source, 0, 0, 32, 32); + const { data } = context.getImageData(0, 0, 32, 32); + const sum = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) + for (let c = 0; c < 3; c++) sum[c] += data[i + c]; + const pixels = data.length / 4; + return sum.map((s) => s / pixels) as Colour; + }, + ); +} + +function distance(a: Colour, b: Colour): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb987bd30..75901a961 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.0.4 version: 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.24 + version: 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slider': specifier: ^1.1.2 version: 1.4.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) diff --git a/src/components/BackgroundEffectGrid.module.css b/src/components/BackgroundEffectGrid.module.css new file mode 100644 index 000000000..d1304cde6 --- /dev/null +++ b/src/components/BackgroundEffectGrid.module.css @@ -0,0 +1,92 @@ +/* +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. +*/ + +.grid { + display: grid; + /* A zero floor, so a long label wraps inside its tile rather than widening + the grid past the menu. */ + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-rows: 1fr; + gap: var(--cpd-space-3x); + padding: var(--cpd-space-2x) + calc(var(--cpd-space-4x) - var(--cpd-border-width-1)) 0; +} + +.tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--cpd-space-1x); + min-inline-size: 0; + padding: var(--cpd-space-3x) var(--cpd-space-2x); + /* Not hidden: that makes the tile a scroll container, which focus scrolls + into view without its border, and so without the focus ring. */ + overflow: clip; + border: var(--cpd-border-width-2) solid + var(--cpd-color-border-interactive-secondary); + border-radius: var(--cpd-space-2x); + background: none; + color: var(--cpd-color-icon-secondary); + font: inherit; + cursor: pointer; +} + +.tile:hover:not([data-disabled]), +.tile[data-highlighted] { + background: var(--cpd-color-bg-subtle-secondary); +} + +.tile[aria-checked="true"] { + border-color: var(--cpd-color-border-accent); +} + +.tile[data-disabled] { + cursor: default; + opacity: 0.55; +} + +.tile[data-disabled] .picture { + filter: grayscale(1); +} + +.swatch { + display: flex; + align-items: center; + justify-content: center; +} + +.picture { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +/* Positioned, or the picture, positioned itself, would cover it. */ +.mark { + position: relative; + display: inline-flex; + color: var(--cpd-color-icon-accent-primary); +} + +/* On a picture the mark needs a ground of its own to be seen. */ +.picture ~ .mark { + border-radius: 50%; + background: var(--cpd-color-bg-canvas-default); + box-shadow: 0 0 0 var(--cpd-border-width-2) var(--cpd-color-bg-canvas-default); +} + +.label { + max-inline-size: 100%; + font: var(--cpd-font-body-sm-medium); + color: var(--cpd-color-text-primary); + text-align: center; + overflow-wrap: anywhere; +} diff --git a/src/components/BackgroundEffectGrid.test.ts b/src/components/BackgroundEffectGrid.test.ts new file mode 100644 index 000000000..caf5168f9 --- /dev/null +++ b/src/components/BackgroundEffectGrid.test.ts @@ -0,0 +1,24 @@ +/* +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 { createRequire } from "node:module"; +import { realpathSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("BackgroundEffectGrid", () => { + // Its tiles are Radix radio items inside Compound's menu, which only works + // with Compound's own copy of Radix: a second copy has none of its context. + it("shares one Radix menu with Compound", () => { + const ours = createRequire(import.meta.url); + const compounds = createRequire( + realpathSync(ours.resolve("@vector-im/compound-web")), + ); + expect(realpathSync(ours.resolve("@radix-ui/react-dropdown-menu"))).toBe( + realpathSync(compounds.resolve("@radix-ui/react-dropdown-menu")), + ); + }); +}); diff --git a/src/components/BackgroundEffectGrid.tsx b/src/components/BackgroundEffectGrid.tsx new file mode 100644 index 000000000..8e113e907 --- /dev/null +++ b/src/components/BackgroundEffectGrid.tsx @@ -0,0 +1,170 @@ +/* +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 FC, type ReactNode, type ReactElement } from "react"; +import { + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from "@radix-ui/react-dropdown-menu"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { InlineSpinner } from "@vector-im/compound-web"; +import { + BlockIcon, + BlurIcon, + CheckCircleSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; + +import styles from "./BackgroundEffectGrid.module.css"; +import { menuIsDrawer } from "./menuIsDrawer"; + +/** One choice in the camera menu's Background effects section. */ +export interface BackgroundEffectOption { + id: string; + /** The tile's accessible name; drawn only where the tile has no picture. */ + label: string; + kind: "none" | "blur" | "image"; + /** For kind "image". */ + imageUrl?: string; +} + +interface Props { + /** Names the group, whose heading is hidden from assistive technology. */ + label: string; + heading: ReactNode; + effects: BackgroundEffectOption[]; + selected: string | undefined; + /** Undefined where effects can't run: all but no effect are disabled. */ + onSelect: ((id: string) => void) | undefined; + /** Shown on the selected tile until its effect is on screen. */ + settling?: boolean; + /** Id of the text saying what the user should know before choosing. */ + describedBy?: string; +} + +/** + * The background effects as a grid of tiles: one radio choice, walked by the + * menu's arrow keys like the device rows. + */ +export const BackgroundEffectGrid: FC = ({ + label, + heading, + effects, + selected, + onSelect, + settling = false, + describedBy, +}) => { + const choose = (id: string): void => { + if (id !== selected) onSelect?.(id); + }; + // On a phone Compound renders the menu as a drawer, outside Radix, where a + // Radix item can't mount. + const inDrawer = menuIsDrawer(); + + const tiles = effects.map((effect) => { + const checked = effect.id === selected; + const disabled = onSelect === undefined && effect.kind !== "none"; + const content = ( + + ); + return inDrawer ? ( + + ) : ( + e.preventDefault()} + > + {content} + + ); + }); + + const body = ( + <> + {heading} +
+ {tiles} +
+ + ); + return inDrawer ? ( +
+ {body} +
+ ) : ( + + {body} + + ); +}; + +function TileContent({ + effect, + checked, + settling, +}: { + effect: BackgroundEffectOption; + checked: boolean; + settling: boolean; +}): ReactElement { + return ( + <> + + {effect.kind === "image" && ( + + )} + {settling ? ( + + + + ) : checked ? ( + + ) : effect.kind === "none" ? ( + + ) : effect.kind === "blur" ? ( + + ) : null} + + {effect.kind === "image" ? ( + {effect.label} + ) : ( + {effect.label} + )} + + ); +} diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 96e010d85..573dbe7b0 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -22,6 +22,7 @@ import { MediaDevices } from "../state/MediaDevices"; import { globalScope } from "../state/ObservableScope"; import { constant } from "../state/Behavior"; import { type LayoutMode } from "../state/LayoutSwitchViewModel"; +import { shippedBackgrounds } from "../livekit/backgroundEffects"; // consts for tests const reactionIdentifier = "@user:example.com:DEVICE"; @@ -123,8 +124,18 @@ export const Default: Story = { toggleAudio: fn(), toggleVideo: fn(), toggleScreenSharing: fn(), - toggleBlur: fn(), - videoBlurEnabled: true, + selectBackgroundEffect: fn(), + backgroundEffect: "blur", + backgroundEffects: [ + { id: "none", kind: "none" }, + { id: "blur", kind: "blur" }, + ...shippedBackgrounds.map((background) => ({ + id: `image:${background.id}`, + kind: "image" as const, + imageUrl: background.imagePath, + })), + ], + backgroundEffectNotice: undefined, hangup: fn(), buttonSize: "lg", showFooter: true, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 6fdf13dda..ca64e8bde 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import { type FC, type JSX, type Ref, useMemo } from "react"; import classNames from "classnames"; +import { useTranslation } from "react-i18next"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -31,6 +32,15 @@ import { type ViewModel } from "../state/ViewModel"; import { useBehavior } from "../useBehavior"; import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel"; import { LayoutSwitch } from "../room/LayoutSwitch"; +import { type BackgroundEffectOption } from "./BackgroundEffectGrid"; + +/** A background effect on offer, which the view names. */ +export interface BackgroundEffectChoice { + /** Its option id, as `backgroundEffect` gives the one in force. */ + id: string; + kind: "none" | "blur" | "image"; + imageUrl?: string; +} export interface AudioOutputSwitcher { targetOutput: string; @@ -56,20 +66,27 @@ export interface FooterActions { toggleAudio: (() => void) | undefined; /** Also controls if the videoMute button is disabled */ toggleVideo: (() => void) | undefined; - toggleBlur: (() => void) | undefined; + /** Undefined where background effects can't be chosen. */ + selectBackgroundEffect: ((id: string) => void) | undefined; toggleScreenSharing: (() => void) | undefined; /** Also controls if the settings button is visible */ openSettings: (() => void) | undefined; /** Also controls if the hangup button is visible */ hangup: (() => void) | undefined; } + // we do not use any ? optional properties so that the vm type is including all fields. export interface FooterState { audioEnabled: boolean; audioBusy: boolean; videoEnabled: boolean; videoBusy: boolean; - videoBlurEnabled: boolean; + /** The background effect in force, as its option id. */ + backgroundEffect: string; + /** Every effect on offer, in the order they are shown. */ + backgroundEffects: BackgroundEffectChoice[]; + /** What the user is told about effects here, if anything. */ + backgroundEffectNotice: "unavailable" | undefined; showFooter: boolean; /* This is needed for WindowMode = "flat" */ @@ -117,12 +134,14 @@ export interface FooterProps { children?: JSX.Element | JSX.Element[] | false; vm: ViewModel; } + export const CallFooter: FC = ({ className, ref, children, vm, }) => { + const { t } = useTranslation(); const asOverlay = useBehavior(vm.asOverlay$); const showFooter = useBehavior(vm.showFooter$); const hideControls = useBehavior(vm.hideControls$); @@ -151,8 +170,12 @@ export const CallFooter: FC = ({ const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$); const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$); const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); - const toggleBlur = useBehavior(vm.toggleBlur$); - const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); + const backgroundEffect = useBehavior(vm.backgroundEffect$); + const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$); + const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$); + const backgroundEffects = useBackgroundEffectLabels( + useBehavior(vm.backgroundEffects$), + ); const buttonSize = useBehavior(vm.buttonSize$); const showLogo = useBehavior(vm.showLogo$); @@ -214,8 +237,14 @@ export const CallFooter: FC = ({ options={videoOptions} selectedOption={selectedVideo} onSelect={selectVideoButtonOption} - videoBlurToggleClick={toggleBlur} - videoBlurEnabled={videoBlurEnabled} + backgroundEffects={backgroundEffects} + selectedBackgroundEffect={backgroundEffect} + onSelectBackgroundEffect={selectBackgroundEffect} + backgroundEffectNotice={ + backgroundEffectNotice === "unavailable" + ? t("background_effects.unavailable") + : undefined + } />, ); } else { @@ -340,3 +369,21 @@ const TilesDebugInfo: FC = ({ generation$ }) => { const generation = useBehavior(generation$); return `Tiles generation: ${generation}`; }; + +function useBackgroundEffectLabels( + choices: BackgroundEffectChoice[], +): BackgroundEffectOption[] { + const { t } = useTranslation(); + return useMemo(() => { + let pictures = 0; + return choices.map((choice) => ({ + ...choice, + label: + choice.kind === "none" + ? t("background_effects.none") + : choice.kind === "blur" + ? t("background_effects.blur") + : t("background_effects.numbered", { n: ++pictures }), + })); + }, [choices, t]); +} diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 450482ceb..287786aae 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { BehaviorSubject } from "rxjs"; import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test"; @@ -19,6 +19,8 @@ import { createLobbyFooterViewModel, } from "./CallFooterViewModel"; import { HeaderStyle } from "../UrlParams"; +import { backgroundEffect as backgroundEffectSetting } from "../settings/settings"; +import { shippedBackgrounds } from "../livekit/backgroundEffects"; const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../Platform", () => ({ @@ -227,6 +229,8 @@ describe("createCallFooterViewModel", () => { }); describe("background effects", () => { + afterEach(() => backgroundEffectSetting.setValue("none")); + // The lobby, because in a call the whole device switcher is already // withheld on a phone, whatever the verdict says. function lobbyOn( @@ -245,17 +249,55 @@ describe("createCallFooterViewModel", () => { it("offers them on a phone whose browser can run them", () => { sdkSupportMock.mockReturnValue(true); - expect(lobbyOn("ios").toggleBlur$.value).toBeDefined(); + expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined(); }); it("offers them where the pipeline will honour them", () => { sdkSupportMock.mockReturnValue(true); - expect(lobbyOn("desktop").toggleBlur$.value).toBeDefined(); + expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined(); }); it("offers nothing where the browser itself cannot run them", () => { sdkSupportMock.mockReturnValue(false); - expect(lobbyOn("desktop").toggleBlur$.value).toBeUndefined(); + expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeUndefined(); + }); + + it("puts no effect in force where the browser cannot run them", () => { + backgroundEffectSetting.setValue("blur"); + sdkSupportMock.mockReturnValue(false); + expect(lobbyOn("desktop").backgroundEffect$.value).toBe("none"); + sdkSupportMock.mockReturnValue(true); + expect(lobbyOn("desktop").backgroundEffect$.value).toBe("blur"); + }); + + it("stores the effect chosen", () => { + sdkSupportMock.mockReturnValue(true); + const vm = lobbyOn("desktop"); + vm.selectBackgroundEffect$.value?.("image:arc"); + expect(vm.backgroundEffect$.value).toBe("image:arc"); + // What isn't an effect on offer is stored as none. + vm.selectBackgroundEffect$.value?.("image:gone"); + expect(backgroundEffectSetting.getValue()).toBe("none"); + }); + + it("offers every effect in order", () => { + sdkSupportMock.mockReturnValue(true); + expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([ + { id: "none", kind: "none" }, + { id: "blur", kind: "blur" }, + ...shippedBackgrounds.map((background) => ({ + id: `image:${background.id}`, + kind: "image", + imageUrl: background.imagePath, + })), + ]); + }); + + it("says they are unavailable where they cannot be chosen", () => { + sdkSupportMock.mockReturnValue(false); + expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBe( + "unavailable", + ); }); it("availability is the same before and during a call", () => { @@ -270,8 +312,11 @@ describe("createCallFooterViewModel", () => { /* reactionIdentifier */ undefined, { showControls: true, header: HeaderStyle.Standard }, ); - const offeredInLobby = lobby.toggleBlur$.value !== undefined; - expect(inCall.toggleBlur$.value !== undefined).toBe(offeredInLobby); + const offeredInLobby = + lobby.selectBackgroundEffect$.value !== undefined; + expect(inCall.selectBackgroundEffect$.value !== undefined).toBe( + offeredInLobby, + ); expect(offeredInLobby).toBe(supported); } }); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 9f80e4476..3df9301ae 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -9,7 +9,11 @@ import { combineLatest, map, type Observable, switchMap } from "rxjs"; import { supportsAudioOutputSelection } from "livekit-client"; import { supportsBackgroundProcessors } from "../livekit/backgroundProcessing"; -import { parseEffect } from "../livekit/backgroundEffects"; +import { + parseEffect, + serializeEffect, + shippedBackgrounds, +} from "../livekit/backgroundEffects"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { type MenuOptions } from "./MediaMuteAndSwitchButton"; @@ -24,7 +28,7 @@ import { type MuteStates } from "../state/MuteStates"; import { createStaticViewModel, type ViewModel } from "../state/ViewModel"; import { HeaderStyle } from "../UrlParams"; import { platform } from "../Platform"; -import { type FooterSnapshot } from "./CallFooter"; +import { type BackgroundEffectChoice, type FooterSnapshot } from "./CallFooter"; /** * Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors @@ -58,7 +62,7 @@ function buildMuteBehaviors( /** * Shared helper: maps MediaDevices into the audio/video device-list behaviors - * needed by FooterSnapshot (options, selection, callbacks, blur toggle). + * needed by FooterSnapshot (options, selection, callbacks, background effect). */ function buildDeviceBehaviors( scope: ObservableScope, @@ -76,8 +80,10 @@ function buildDeviceBehaviors( | "videoOptions$" | "selectedVideo$" | "selectVideoButtonOption$" - | "toggleBlur$" - | "videoBlurEnabled$" + | "backgroundEffect$" + | "selectBackgroundEffect$" + | "backgroundEffects$" + | "backgroundEffectNotice$" > { const options$ = ( available$: Behavior>, @@ -94,10 +100,9 @@ function buildDeviceBehaviors( ), ); - const blurOn$ = scope.behavior( - backgroundEffectSetting.value$.pipe( - map((raw) => parseEffect(raw).kind === "blur"), - ), + const supported = supportsBackgroundProcessors(); + const offered$ = disableSwitcher$.pipe( + map((switcherDisabled) => !switcherDisabled && supported), ); return { audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)), @@ -123,18 +128,27 @@ function buildDeviceBehaviors( mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)), ), selectVideoButtonOption$: constant(mediaDevices.videoInput.select), - toggleBlur$: scope.behavior( - combineLatest([blurOn$, disableSwitcher$]).pipe( - map(([current, switcherDisabled]) => { - return !switcherDisabled && supportsBackgroundProcessors() - ? (): void => { - backgroundEffectSetting.setValue(current ? "none" : "blur"); - } - : undefined; - }), + backgroundEffect$: scope.behavior( + backgroundEffectSetting.value$.pipe( + map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")), ), ), - videoBlurEnabled$: blurOn$, + selectBackgroundEffect$: scope.behavior( + offered$.pipe( + map((offered) => + offered + ? (id: string): void => + backgroundEffectSetting.setValue( + serializeEffect(parseEffect(id)), + ) + : undefined, + ), + ), + ), + backgroundEffects$: constant(backgroundEffectChoices()), + backgroundEffectNotice$: scope.behavior( + offered$.pipe(map((offered) => (offered ? undefined : "unavailable"))), + ), }; } @@ -285,3 +299,15 @@ export function createLobbyFooterViewModel( ...buildDeviceBehaviors(scope, mediaDevices, constant(false)), }; } + +function backgroundEffectChoices(): BackgroundEffectChoice[] { + return [ + { id: serializeEffect({ kind: "none" }), kind: "none" }, + { id: serializeEffect({ kind: "blur" }), kind: "blur" }, + ...shippedBackgrounds.map((background) => ({ + id: serializeEffect({ kind: "shipped", id: background.id }), + kind: "image" as const, + imageUrl: background.imagePath, + })), + ]; +} diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index 0a242b263..e2a470ed4 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -68,9 +68,11 @@ Please see LICENSE in the repository root for full details. } /* Sticky within its own section. Opaque and inset a border width so it doesn't - paint over the menu's outline. */ + paint over the menu's outline; raised, or the positioned tiles scroll over + it. */ .sectionHeading { position: sticky; + z-index: 1; inset-block-start: 0; background: var(--cpd-color-bg-canvas-default); margin-block-start: var(--cpd-border-width-1); @@ -95,3 +97,21 @@ Please see LICENSE in the repository root for full details. outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused); outline-offset: calc(-1 * var(--cpd-border-width-2)); } + +.notice { + display: flex; + align-items: flex-start; + gap: var(--cpd-space-3x); + margin: var(--cpd-space-2x) + calc(var(--cpd-space-4x) - var(--cpd-border-width-1)) 0; + padding: var(--cpd-space-3x); + border-radius: var(--cpd-space-2x); + background: var(--cpd-color-bg-subtle-secondary); + color: var(--cpd-color-text-primary); + font: var(--cpd-font-body-md-regular); +} + +.notice > svg { + flex-shrink: 0; + color: var(--cpd-color-icon-secondary); +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 653a56418..6cfb29352 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -11,6 +11,8 @@ import { useEffect, useState, type FC, type JSX, type ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; import styles from "./MediaMuteAndSwitchButton.module.css"; +import { type BackgroundEffectOption } from "./BackgroundEffectGrid"; +import { shippedBackgrounds } from "../livekit/backgroundEffects"; import meterStyles from "./MicrophoneLevelMeter.module.css"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { RootElementProvider } from "../RootElementContext"; @@ -59,15 +61,18 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => { }; /** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */ -const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => { +const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({ + children, + // A call's size: anything smaller makes a short device list scroll, and a + // narrower one narrows the menu. + blockSize = 720, +}) => { const [callArea, setCallArea] = useState(null); return (
{ @@ -169,6 +172,17 @@ export const VideoMute: Story = { }, }; +const effects: BackgroundEffectOption[] = [ + { id: "none", kind: "none", label: "None" }, + { id: "blur", kind: "blur", label: "Blur" }, + ...shippedBackgrounds.map((background, i) => ({ + id: `image:${background.id}`, + kind: "image" as const, + label: `Background ${i + 1}`, + imageUrl: background.imagePath, + })), +]; + export const VideoUnmute: Story = { args: { iconsAndLabels: "video", @@ -177,9 +191,147 @@ export const VideoUnmute: Story = { { label: { type: "name", name: "Camera 1" }, id: "1" }, { label: { type: "name", name: "Camera 2" }, id: "2" }, ], - videoBlurEnabled: true, - videoBlurToggleClick: fn(), selectedOption: "2", + backgroundEffects: effects, + selectedBackgroundEffect: "none", + onSelectBackgroundEffect: fn(), + }, +}; + +/** One choice among no effect, blur and the shipped images, under the cameras. */ +export const BackgroundEffects: Story = { + args: VideoUnmute.args, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + const menu = within(document.body); + const section = await menu.findByRole("group", { + name: "Background effects", + }); + const tiles = within(section).getAllByRole("menuitemradio"); + await expect(tiles.map((tile) => tile.textContent)).toEqual([ + "None", + "Blur", + "Background 1", + "Background 2", + ]); + await expect( + within(section).getByRole("menuitemradio", { checked: true }), + ).toHaveTextContent("None"); + + const cameras = menu.getByRole("group", { name: "Camera" }); + await expect( + cameras.compareDocumentPosition(section) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + const [first, second, third, fourth] = tiles.map((tile) => + tile.getBoundingClientRect(), + ); + await expect(second.top).toBe(first.top); + await expect(third.top).toBe(first.top); + await expect(fourth.top).toBeGreaterThan(first.bottom); + const frame = document.body + .querySelector("[role='menu']")! + .getBoundingClientRect(); + for (const tile of [first, second, third]) + await expect(tile.left >= frame.left && tile.right <= frame.right).toBe( + true, + ); + + await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" })); + await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur"); + await expect(menu.getByRole("menu")).toBeVisible(); + }, +}; + +/** Where effects can't run: shown, disabled and explained, except no effect. */ +export const BackgroundEffectsUnavailable: Story = { + args: { + ...VideoUnmute.args, + onSelectBackgroundEffect: undefined, + backgroundEffectNotice: + "Background effects are not supported on this platform.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + const menu = within(document.body); + const section = await menu.findByRole("group", { + name: "Background effects", + }); + await expect(section).toHaveAccessibleDescription( + "Background effects are not supported on this platform.", + ); + for (const tile of within(section).getAllByRole("menuitemradio")) + if (tile.textContent === "None") + await expect(tile).not.toHaveAttribute("aria-disabled"); + else await expect(tile).toHaveAttribute("aria-disabled", "true"); + }, +}; + +/** In a short call the effects scroll into view with the list. */ +export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = { + args: { + ...VideoUnmute.args, + // It sits with the effects, so it must fit the same share of the call. + backgroundEffectNotice: + "Background effects run slowly on this platform, which may cause your video to stutter.", + }, + decorators: [ + (Story): JSX.Element => ( + + + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + const menu = within(document.body); + const last = await menu.findByRole("menuitemradio", { + name: "Background 2", + }); + const list = document.body.querySelector( + `.${styles.deviceList}`, + )!; + await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight); + await expect(last.getBoundingClientRect().top).toBeGreaterThan( + list.getBoundingClientRect().bottom, + ); + + // Scrolled under the section's heading, the tiles pass beneath it. + const heading = [ + ...document.body.querySelectorAll( + `.${styles.sectionHeading}`, + ), + ].find((h) => h.textContent === "Background effects")!; + list.scrollTop = heading.offsetTop + 30; + await waitFor(async () => { + const box = heading.getBoundingClientRect(); + const drawn = document.elementFromPoint( + box.left + box.width / 2, + box.bottom - 2, + ); + await expect(heading.contains(drawn)).toBe(true); + }); + list.scrollTop = 0; + + for (let i = 0; i < 10 && document.activeElement !== last; i++) + await userEvent.keyboard("{ArrowDown}"); + await expect(document.activeElement).toBe(last); + const scrollport = list.getBoundingClientRect(); + const reached = last.getBoundingClientRect(); + await expect(reached.bottom).toBeLessThanOrEqual(scrollport.bottom + 1); + await expect(reached.top).toBeGreaterThanOrEqual(scrollport.top - 1); + + const callArea = canvasElement + .querySelector("[style*='block-size: 300px']")! + .getBoundingClientRect(); + const frame = document.body + .querySelector("[role='menu']")! + .getBoundingClientRect(); + await expect(frame.top).toBeGreaterThanOrEqual(callArea.top); + await expect(frame.bottom).toBeLessThanOrEqual(callArea.bottom); }, }; @@ -668,31 +820,23 @@ function overlapping(element: Element, overlays: Element[]): number { }, 0); } -/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */ -export const FocusRingCoversTheBlurToggle: Story = { - args: { - ...VideoUnmute.args, - iconsAndLabels: "video", - videoBlurEnabled: false, - videoBlurToggleClick: fn(), - }, +/** The effect tiles get the keyboard ring the device rows get. */ +export const FocusRingCoversTheEffectTiles: Story = { + args: VideoUnmute.args, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: "Camera" })); - const toggle = await within(document.body).findByRole("menuitemcheckbox", { - name: /Blur background/, + const tile = await within(document.body).findByRole("menuitemradio", { + name: "Blur", }); - // Arrow down past the cameras to the toggle. - for (let i = 0; i < 6 && document.activeElement !== toggle; i++) + for (let i = 0; i < 6 && document.activeElement !== tile; i++) await userEvent.keyboard("{ArrowDown}"); - await expect(document.activeElement).toBe(toggle); - // The same ring the device rows get. - await expect(outlineWidth(toggle)).toBeGreaterThan(0); + await expect(document.activeElement).toBe(tile); + await expect(outlineWidth(tile)).toBeGreaterThan(0); - // And the pointer takes it away, with the toggle still focused. - await userEvent.hover(toggle); - await expect(document.activeElement).toBe(toggle); - await expect(outlineWidth(toggle)).toBe(0); + await userEvent.hover(tile); + await expect(document.activeElement).toBe(tile); + await expect(outlineWidth(tile)).toBe(0); }, }; diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 4eb04cfe6..361276ff7 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -9,6 +9,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { axe } from "vitest-axe"; import { act, + fireEvent, render, screen, within, @@ -22,10 +23,18 @@ import { MediaMuteAndSwitchButton, type MenuOptions, } from "./MediaMuteAndSwitchButton"; +import { type BackgroundEffectOption } from "./BackgroundEffectGrid"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { type MediaDevices } from "../state/MediaDevices"; import { restoreAudioCapture, stubAudioCapture } from "../utils/test"; +const effects: BackgroundEffectOption[] = [ + { id: "none", kind: "none", label: "None" }, + { id: "blur", kind: "blur", label: "Blur" }, + { id: "image:arc", kind: "image", label: "Background 1", imageUrl: "" }, + { id: "image:glow", kind: "image", label: "Background 2", imageUrl: "" }, +]; + const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../Platform", () => ({ get platform(): string { @@ -335,30 +344,90 @@ describe("MediaMuteAndSwitchButton", () => { expect(mic2ItemAfter.querySelector(".rotate")).toBeNull(); }); - test("renders menu with toggle control and calls toggle callback", async () => { + test("background effects are one keyboard-operable labelled choice", async () => { const user = userEvent.setup(); - const onSelect = vi.fn(); - const onVideoBlurToggle = vi.fn(); + const onSelectBackgroundEffect = vi.fn(); const { getByRole } = renderComponent( , ); await user.click(getByRole("button", { name: "Camera" })); - - const toggle = screen.getByRole("menuitemcheckbox", { - name: "Blur background", + const section = screen.getByRole("group", { name: "Background effects" }); + within(section).getByRole("menuitemradio", { name: "None", checked: true }); + within(section).getByRole("menuitemradio", { + name: "Background 1", + checked: false, }); - expect(toggle).toBeInTheDocument(); - expect(toggle).toHaveAttribute("aria-checked", "false"); - await user.click(toggle); + const blur = within(section).getByRole("menuitemradio", { name: "Blur" }); + for (let i = 0; i < 6 && document.activeElement !== blur; i++) + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(blur); + await user.keyboard("{Enter}"); + expect(onSelectBackgroundEffect).toHaveBeenCalledWith("blur"); + expect(screen.getByRole("menu")).toBeInTheDocument(); + }); - expect(onVideoBlurToggle).toHaveBeenCalled(); + test("does not choose the effect already in force again", async () => { + const user = userEvent.setup(); + const onSelectBackgroundEffect = vi.fn(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Camera" })); + await user.click(screen.getByRole("menuitemradio", { name: "Blur" })); + expect(onSelectBackgroundEffect).not.toHaveBeenCalled(); + }); + + test("offers the background effects in a phone's drawer", async () => { + platformMock.mockReturnValue("android"); + const userAgent = vi + .spyOn(navigator, "userAgent", "get") + .mockReturnValue("Mozilla/5.0 (Linux; Android 14)"); + try { + const user = userEvent.setup(); + const onSelectBackgroundEffect = vi.fn(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Camera" })); + const section = screen.getByRole("group", { name: "Background effects" }); + // A click alone: the drawer's drag handling reads layout jsdom lacks. + fireEvent.click( + within(section).getByRole("menuitemradio", { name: "Background 2" }), + ); + expect(onSelectBackgroundEffect).toHaveBeenCalledWith("image:glow"); + } finally { + userAgent.mockRestore(); + platformMock.mockReturnValue("desktop"); + } }); test("marks the selected menu item as checked", async () => { @@ -582,7 +651,9 @@ describe("MediaMuteAndSwitchButton", () => { ]} selectedOption="cam1" onSelect={vi.fn()} - videoBlurToggleClick={vi.fn()} + backgroundEffects={effects} + selectedBackgroundEffect="none" + onSelectBackgroundEffect={vi.fn()} />, ); @@ -590,9 +661,10 @@ describe("MediaMuteAndSwitchButton", () => { screen.getByRole("menuitemradio", { name: "Camera 1", checked: true }); screen.getByRole("menuitemradio", { name: "Camera 2", checked: false }); - expect( - screen.getByRole("menuitemcheckbox", { name: "Blur background" }), - ).toBeInTheDocument(); + within(screen.getByRole("group", { name: "Background effects" })).getByRole( + "menuitemradio", + { name: "Blur" }, + ); }); test("marks focus as keyboard-driven only when the keyboard moved it", async () => { diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 78da0e12f..6d29b430e 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import { useCallback, + useId, useState, type CSSProperties, type FC, @@ -19,11 +20,11 @@ import { MenuItem, MenuTitle, RadioInput, - ToggleMenuItem, } from "@vector-im/compound-web"; import { ChevronUpIcon, ChevronDownIcon, + InfoIcon, SpinnerIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import classNames from "classnames"; @@ -40,6 +41,10 @@ import { useMediaDevices } from "../MediaDevicesContext"; import { useRootElement } from "../RootElementContext"; import { observeElementSize$ } from "../utils/elementSize"; import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter"; +import { + BackgroundEffectGrid, + type BackgroundEffectOption, +} from "./BackgroundEffectGrid"; import { menuIsDrawer } from "./menuIsDrawer"; export interface MenuOptions { @@ -65,8 +70,13 @@ export interface MediaMuteAndSwitchButtonProps { selectedOutputOption?: string; /** Picks an output device. Undefined disables the speaker section. */ onSelectOutput?: (id: string) => void; - videoBlurToggleClick?: () => void; - videoBlurEnabled?: boolean; + /** Camera menu only. None or an empty list leaves the section out. */ + backgroundEffects?: BackgroundEffectOption[]; + selectedBackgroundEffect?: string; + /** Undefined where effects can't run: all but no effect are disabled. */ + onSelectBackgroundEffect?: (id: string) => void; + /** What the user should know before choosing an effect, if anything. */ + backgroundEffectNotice?: string; /** * 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` @@ -74,8 +84,6 @@ export interface MediaMuteAndSwitchButtonProps { onSelect?: (id: string) => void; } -const BLUR_ID = "blur"; - /** Id of the placeholder "Default" row, shown when the platform lists no outputs. */ const DEFAULT_OUTPUT_ID = "default"; @@ -101,8 +109,10 @@ export const MediaMuteAndSwitchButton: FC = ({ outputOptions, selectedOutputOption, onSelectOutput, - videoBlurEnabled, - videoBlurToggleClick, + backgroundEffects, + selectedBackgroundEffect, + onSelectBackgroundEffect, + backgroundEffectNotice, onSelect, }) => { // Requested but not yet selected. Keyed by kind too, since Chrome uses @@ -120,6 +130,7 @@ export const MediaMuteAndSwitchButton: FC = ({ const isBusy = busy ?? false; const { t } = useTranslation(); const devices = useMediaDevices(); + const noticeId = useId(); /** * Records on the menu whether the keyboard or the pointer moved focus, for @@ -202,16 +213,8 @@ export const MediaMuteAndSwitchButton: FC = ({ /> ); - const toggles = - iconsAndLabels === "video" && videoBlurToggleClick !== undefined - ? [ - { - label: t("action.blur_background"), - enabled: videoBlurEnabled ?? false, - id: BLUR_ID, - }, - ] - : []; + const showEffects = + iconsAndLabels === "video" && (backgroundEffects?.length ?? 0) > 0; let optionsButtonLabel: string; let menuTitle: string; @@ -419,19 +422,30 @@ export const MediaMuteAndSwitchButton: FC = ({ )}
+ {showEffects && ( + + + + } + effects={backgroundEffects ?? []} + selected={selectedBackgroundEffect} + onSelect={onSelectBackgroundEffect} + describedBy={ + backgroundEffectNotice === undefined ? undefined : noticeId + } + /> + )} + {/* In the list, so the menu grows no taller for it. */} + {showEffects && backgroundEffectNotice !== undefined && ( +
+ + {backgroundEffectNotice} +
+ )} - {toggles.length > 0 &&
} - {toggles.map((toggle) => ( - { - videoBlurToggleClick?.(); - e.preventDefault(); - }} - checked={toggle.enabled} - key={toggle.id} - /> - ))} ); diff --git a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap index dbab3b85d..85aeee8ee 100644 --- a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap +++ b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap @@ -9,7 +9,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = ` aria-busy="false" aria-checked="false" aria-disabled="true" - aria-labelledby="_r_0_" + aria-labelledby="_r_1_" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" data-kind="primary" data-size="lg" @@ -39,7 +39,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = ` data-kind="tertiary" data-size="lg" data-state="closed" - id="radix-_r_5_" + id="radix-_r_6_" role="button" tabindex="0" type="button"