diff --git a/locales/en/app.json b/locales/en/app.json index a852e6505..de50b6506 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -3,6 +3,7 @@ "user_menu": "User menu" }, "action": { + "add_background_image": "Add image", "blur_background": "Blur background", "close": "Close", "copy_link": "Copy link", @@ -212,6 +213,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/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index 4faa52ee1..0d36b8fd3 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -135,3 +135,176 @@ 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)); } + +/* + * The Background effects grid. + * + * Three across matches the design and keeps a thumbnail readable at a glance; + * with no effect, blur, two shipped images and the add tile it also settles + * into a tidy three-by-three at the maximum number of images. + * + * minmax(0, 1fr) rather than 1fr: a grid track will not shrink below its + * content's minimum width by default, and a tile carrying a label is wider + * than a third of the menu, so plain 1fr lays out three full-width columns and + * clips two of them. + */ +.effectGrid { + display: grid; + /* A floor, not just a share. The menu sizes itself to its content, so + dividing whatever width the device names happen to need makes the tiles + narrower the shorter those names are; this makes the menu wide enough for + three proper tiles instead. */ + grid-template-columns: repeat(3, minmax(84px, 1fr)); + /* Every tile the same height, set by the tallest — the ones carrying a + label. Forcing a ratio instead clips the label as soon as the menu is + narrow enough that the ratio cannot hold it. */ + grid-auto-rows: 1fr; + gap: var(--cpd-space-2x); + /* The same inset a menu heading and a device row carry, so the tiles line + their left edge up with the "B" of the heading above them and with the + radio buttons above that. Matching the surrounding boxes instead puts the + tiles to the left of both, since a heading insets its own text. */ + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-3x); +} + +/* + * A menu item lays itself out as a grid of chevron, icon and label in a row. + * A tile centres its glyph with the label beneath instead, so this has to + * outweigh that rule rather than sit beside it. + * + * The tiles are a little wider than tall, as the design draws them, and hold + * that shape whatever the menu measures. + */ +.effectGrid .effectTile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + /* Glyph and label centre together as one group. Pinning the label to the + floor instead leaves the glyph riding high in the space above it. */ + justify-content: center; + /* Measured against the design: the glyph sits a quarter of the tile wide, + with about a seventh of the tile's height between its ink and the label's. + The label's own leading is part of that distance, so the step here is + smaller than the ink gap it produces. */ + gap: var(--cpd-space-1x); + block-size: auto; + min-block-size: 0; + min-inline-size: 0; + /* More room above and below than at the sides: the tile is wider than tall, + and the padding is what carries that proportion now the gap is set by the + design's spacing rather than by filling the box. */ + padding: var(--cpd-space-3x) var(--cpd-space-2x); + overflow: hidden; + border: 2px solid var(--cpd-color-border-interactive-secondary); + border-radius: var(--cpd-space-2x); +} + +/* The selection is drawn here and announced by the item's aria-checked, so it + never rests on colour alone. Matched to the specificity of the base tile + rule above, which also sets a border-color and would otherwise win. + The inset shadow thickens the frame without changing the tile's geometry, + which a wider border would, shifting every tile as the selection moves. */ +.effectGrid .effectTileSelected { + border-color: var(--cpd-color-border-accent); + box-shadow: inset 0 0 0 var(--cpd-border-width-1) + var(--cpd-color-border-accent); +} + +.effectGrid .effectTile > * { + /* A menu item spaces its icon and label for a row, with the gap carried as a + right margin on each. Stacked in a tile that margin is dead space on one + side, which shifts the glyph and the label off the tile's centre. */ + margin: 0; + min-inline-size: 0; + max-inline-size: 100%; + text-align: center; + overflow-wrap: anywhere; +} + +.effectSwatch { + flex: 0 0 auto; + inline-size: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--cpd-color-icon-secondary); +} + +/* An image tile is the picture: it fills the tile rather than sitting in a box + inside one, which is what the design draws and what makes a background + recognisable at this size. */ +.effectThumb { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +/* Blur has no icon in the design system, and a hatch reads as "softened" in a + way a glyph does not. */ +.effectBlurGlyph { + /* Smaller than the glyph beside it, and than the design's own hatch measures. + The design's is an icon whose strokes shorten towards the corners, so it + leaves them empty and reads lighter; a uniform hatch fills its box and + reads heavier at the same size. */ + inline-size: 18px; + block-size: 18px; + border-radius: var(--cpd-space-1x); + background-image: repeating-linear-gradient( + 135deg, + var(--cpd-color-icon-secondary) 0 1.2px, + transparent 1.2px 3.15px + ); +} + +/* The tick marking the tile in force, standing where the glyph would. + Positioned, so it paints above the image, which is positioned itself and + would otherwise cover anything left in normal flow. */ +.effectCheck { + position: relative; + color: var(--cpd-color-icon-accent-primary); +} + +/* On a picture it needs its own ground: a tick alone disappears into whatever + the image happens to be behind it. */ +.effectCheckOnImage { + 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); +} + +/* A menu item's label is sized for a row; in a tile the design sets it a step + smaller, against the glyph above it. */ +.effectLabel { + font: var(--cpd-font-body-sm-medium); + /* A menu item labels itself in the secondary colour, which reads as muted + under a glyph the design draws at full strength. */ + color: var(--cpd-color-text-primary); +} + +/* Named for assistive technology, not drawn: an image labels itself, and the + design gives the add tile a plus alone. */ +.effectLabelUnseen { + position: absolute; + inline-size: 1px; + block-size: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* + * Unavailable. Opacity alone is not enough on an image tile: a washed-out + * photograph still reads as a photograph you could pick. Draining the colour + * as well says plainly that there is nothing to choose here. + */ +.effectGrid .effectTile[aria-disabled="true"] { + opacity: 0.55; +} + +.effectGrid .effectTile[aria-disabled="true"] .effectThumb { + filter: grayscale(1); +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 474240cb0..b4b77c337 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -768,3 +768,142 @@ export const FocusRingCoversTheBlurToggle: Story = { await expect(outlineWidth(toggle)).toBe(0); }, }; + +/** + * Stand-in background art, drawn here rather than imported, so these stories + * carry no asset of their own. The shipped images do not exist yet. + */ +const swatch = (from: string, to: string): string => + `data:image/svg+xml;utf8,${encodeURIComponent( + `` + + `` + + `` + + `` + + ``, + )}`; + +const backgroundEffects = [ + { id: "none", label: "None", kind: "none" as const }, + { id: "blur", label: "Blur", kind: "blur" as const }, + { + id: "indoor", + label: "Indoor", + kind: "image" as const, + imageUrl: swatch("#d8c9a8", "#8a6f4a"), + }, + { + id: "outdoor", + label: "Outdoor", + kind: "image" as const, + imageUrl: swatch("#9fd0e8", "#2f6f4f"), + }, +]; + +/** + * The camera menu's Background effects section: no effect, blur, the shipped + * images, and the tile for adding your own. + */ +export const BackgroundEffects: Story = { + args: { + title: "Camera", + iconsAndLabels: "video", + enabled: true, + options: [ + { label: { type: "name", name: "Camera 1" }, id: "1" }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, + ], + selectedOption: "1", + onSelect: fn(), + backgroundEffects, + selectedBackgroundEffect: "none", + onSelectBackgroundEffect: fn(), + onAddBackgroundImage: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + + const blur = await within(document.body).findByRole("menuitemradio", { + name: "Blur", + }); + await userEvent.click(blur); + await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur"); + }, +}; + +/** An image is in force, so the grid marks it rather than no effect. */ +export const BackgroundImageChosen: Story = { + args: { + ...BackgroundEffects.args, + selectedBackgroundEffect: "outdoor", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + + const chosen = await within(document.body).findByRole("menuitemradio", { + name: "Outdoor", + }); + // The selection is announced, not only drawn. + await expect(chosen).toHaveAttribute("aria-checked", "true"); + }, +}; + +/** + * Where the browser or device cannot run background processing. The section + * keeps its shape and its tiles, and none of them can be chosen. + */ +export const BackgroundEffectsUnavailable: Story = { + args: { + ...BackgroundEffects.args, + onSelectBackgroundEffect: undefined, + onAddBackgroundImage: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + + const blur = await within(document.body).findByRole("menuitemradio", { + name: "Blur", + }); + await waitFor(() => expect(blur).toHaveAttribute("aria-disabled", "true")); + + // No effect needs no background processing, so it stays choosable. + const none = await within(document.body).findByRole("menuitemradio", { + name: "None", + }); + await expect(none).not.toHaveAttribute("aria-disabled", "true"); + }, +}; + +/** + * A device name long enough to set the menu's width, so the tiles are seen at + * the widest the menu gets rather than only at the narrowest. + */ +export const BackgroundEffectsWithALongDeviceName: Story = { + args: { + ...BackgroundEffects.args, + options: [ + { + label: { + type: "name", + name: "Logitech BRIO 4K Ultra HD Pro Business Webcam (046d:085e)", + }, + id: "1", + }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, + ], + selectedOption: "1", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + + const none = await within(document.body).findByRole("menuitemradio", { + name: "None", + }); + // Three tiles to a row however wide the name makes the menu. + const grid = none.parentElement!; + await expect(getComputedStyle(grid).gridTemplateColumns.split(" ")).toHaveLength(3); + }, +}; diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 836c76789..610d440bc 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -25,6 +25,9 @@ import { ChevronUpIcon, ChevronDownIcon, SpinnerIcon, + BlockIcon, + PlusIcon, + CheckCircleSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; @@ -47,6 +50,16 @@ export interface MenuOptions { id: string; } +/** One choice in the camera menu's Background effects section. */ +export interface BackgroundEffectOption { + id: string; + /** Shown under the tile, and the item's accessible name. */ + label: string; + kind: "none" | "blur" | "image"; + /** The thumbnail, for kind "image". Blur and no effect draw their own. */ + imageUrl?: string; +} + export interface MediaMuteAndSwitchButtonProps { /** * The accessible name of the menu. Defaults to a translated name for the @@ -78,6 +91,21 @@ export interface MediaMuteAndSwitchButtonProps { onSelectOutput?: (id: string) => void; videoBlurToggleClick?: () => void; videoBlurEnabled?: boolean; + /** + * Background effects, shown as a grid of tiles under the camera list. Camera + * menu only; omitted entirely for audio. An empty list leaves the section out. + */ + backgroundEffects?: BackgroundEffectOption[]; + /** The effect currently in force. */ + selectedBackgroundEffect?: string; + /** + * Called when an effect is chosen. Undefined means no effect can be chosen + * here, and the section renders disabled, so the menu keeps the same shape + * wherever background processing is unavailable. + */ + onSelectBackgroundEffect?: (id: string) => void; + /** Called when the add tile is chosen. Omit to leave that tile out. */ + onAddBackgroundImage?: () => void; /** * 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` @@ -125,6 +153,10 @@ export const MediaMuteAndSwitchButton: FC = ({ onSelectOutput, videoBlurEnabled, videoBlurToggleClick, + backgroundEffects, + selectedBackgroundEffect, + onSelectBackgroundEffect, + onAddBackgroundImage, onSelect, }) => { // Which device we have asked for but not yet been given. Carries the kind as @@ -409,6 +441,101 @@ export const MediaMuteAndSwitchButton: FC = ({ )); }; + // The camera menu's Background effects section. Tiles rather than rows, but + // menuitemradio like the device list above, so the menu stays one keyboard + // model throughout and the selection is announced rather than only drawn. + const effectTiles = (): ReactElement[] => { + const list = backgroundEffects ?? []; + // Shown but not choosable where background processing is unavailable, so + // the menu keeps the same shape on every platform. No effect is the + // exception: it needs no processing, so it stays choosable and stays the + // one in force. + const unavailable = onSelectBackgroundEffect === undefined; + const tiles = list.map((effect) => ( + + {effect.kind === "image" && ( + + )} + {selectedBackgroundEffect === effect.id ? ( + // The tick stands where the glyph would, and on a picture it + // carries its own ground so it reads against whatever is behind + // it. + + ) : ( + <> + {effect.kind === "none" && } + {effect.kind === "blur" && ( + + )} + + )} + + } + onSelect={(e) => { + e.preventDefault(); + if (effect.id === selectedBackgroundEffect) return; + onSelectBackgroundEffect?.(effect.id); + }} + key={effect.id} + role="menuitemradio" + aria-checked={selectedBackgroundEffect === effect.id} + /> + )); + // Adding is a command, not a choice, so it is a plain item among the tiles + // rather than another radio. + if (onAddBackgroundImage !== undefined) + tiles.push( + + + + } + onSelect={(e) => { + e.preventDefault(); + onAddBackgroundImage(); + }} + key="add-background-image" + />, + ); + return tiles; + }; + return (
= ({ )}
+ {iconsAndLabels === "video" && + backgroundEffects !== undefined && + backgroundEffects.length > 0 && ( + <> + +
+
+ +
+
+ {effectTiles()} +
+
+ + )} {toggles.length > 0 &&
} {toggles.map((toggle) => (