Let a user remove a background they added

- A cross where the tick would be, on hovering one of their own images
- Not offered on the one in force: that is what they are wearing, and
  taking it away would leave them with nothing chosen (FR-025)
- Delete or Backspace on a focused tile does the same, announced through
  aria-keyshortcuts. The cross is drawn rather than focusable: a control
  inside a menu item is invalid, and the item is what the arrows walk
- Covered by a story: the one in force offers no removal, the others say
  Delete and answer to it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-17 23:01:54 +02:00
co-authored by Claude Opus 5
parent 943cae0129
commit e3aaad8615
4 changed files with 148 additions and 1 deletions
+15 -1
View File
@@ -30,6 +30,7 @@ import {
type MenuOptions, type MenuOptions,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { import {
parseEffect,
serializeEffect, serializeEffect,
shippedBackgrounds, shippedBackgrounds,
} from "../livekit/backgroundEffects"; } from "../livekit/backgroundEffects";
@@ -170,7 +171,18 @@ export const CallFooter: FC<FooterProps> = ({
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const backgroundEffect = useBehavior(vm.backgroundEffect$); const backgroundEffect = useBehavior(vm.backgroundEffect$);
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$); const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const { added, addBackground } = useAddedBackgrounds(); const { added, addBackground, removeBackground } = useAddedBackgrounds();
const onRemoveBackgroundEffect = useCallback(
(id: string): void => {
const effect = parseEffect(id);
if (effect.kind !== "added") return;
removeBackground(effect.id).catch((e) =>
logger.warn("Could not remove that background", e),
);
},
[removeBackground],
);
const onAddBackgroundImage = useCallback( const onAddBackgroundImage = useCallback(
(file: File): void => { (file: File): void => {
@@ -212,6 +224,7 @@ export const CallFooter: FC<FooterProps> = ({
...added.map((background, i) => ({ ...added.map((background, i) => ({
id: serializeEffect({ kind: "added", id: background.id }), id: serializeEffect({ kind: "added", id: background.id }),
kind: "image" as const, kind: "image" as const,
removable: true,
label: t("action.background_effect_numbered", { label: t("action.background_effect_numbered", {
n: shippedBackgrounds.length + i + 1, n: shippedBackgrounds.length + i + 1,
}), }),
@@ -291,6 +304,7 @@ export const CallFooter: FC<FooterProps> = ({
? onAddBackgroundImage ? onAddBackgroundImage
: undefined : undefined
} }
onRemoveBackgroundEffect={onRemoveBackgroundEffect}
/>, />,
); );
} else { } else {
@@ -328,3 +328,32 @@ Please see LICENSE in the repository root for full details.
.effectGrid .effectTile[aria-disabled="true"] .effectThumb { .effectGrid .effectTile[aria-disabled="true"] .effectThumb {
filter: grayscale(1); filter: grayscale(1);
} }
/*
* The cross that removes one of the user's own backgrounds.
*
* Shown on hover, and whenever the tile has the keyboard's attention, so the
* two ways of reaching it look the same. It carries its own ground for the
* same reason the tick does: it sits on a picture nobody chose.
*/
.effectRemove {
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
display: none;
align-items: center;
justify-content: center;
inline-size: 24px;
block-size: 24px;
border-radius: 50%;
background: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-icon-primary);
cursor: pointer;
}
.effectGrid .effectTile:hover .effectRemove,
.effectGrid .effectTile:focus-within .effectRemove,
.menu[data-focus-modality="keyboard"] .effectTile:focus .effectRemove {
display: flex;
}
@@ -909,3 +909,54 @@ export const BackgroundEffectsWithALongDeviceName: Story = {
await expect(getComputedStyle(grid).gridTemplateColumns.split(" ")).toHaveLength(3); await expect(getComputedStyle(grid).gridTemplateColumns.split(" ")).toHaveLength(3);
}, },
}; };
/**
* Backgrounds the user added are theirs to remove — except the one in force,
* which is what they are wearing.
*/
export const AddedBackgroundsCanBeRemoved: Story = {
args: {
...BackgroundEffects.args,
backgroundEffects: [
...backgroundEffects,
{
id: "added:one",
label: "Background 3",
kind: "image" as const,
imageUrl: swatch("#c2410c", "#7c2d12"),
removable: true,
},
{
id: "added:two",
label: "Background 4",
kind: "image" as const,
imageUrl: swatch("#1d4ed8", "#172554"),
removable: true,
},
],
selectedBackgroundEffect: "added:one",
onRemoveBackgroundEffect: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const body = within(document.body);
// The one in force offers no removal.
const inForce = await body.findByRole("menuitemradio", {
name: "Background 3",
});
await expect(inForce).not.toHaveAttribute("aria-keyshortcuts");
// The other does, and says so, and Delete reaches it from the keyboard.
const other = await body.findByRole("menuitemradio", {
name: "Background 4",
});
await expect(other).toHaveAttribute("aria-keyshortcuts", "Delete");
other.focus();
await userEvent.keyboard("{Delete}");
await expect(args.onRemoveBackgroundEffect).toHaveBeenCalledWith(
"added:two",
);
},
};
@@ -29,6 +29,7 @@ import {
BlockIcon, BlockIcon,
PlusIcon, PlusIcon,
CheckCircleSolidIcon, CheckCircleSolidIcon,
CloseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -59,6 +60,8 @@ export interface BackgroundEffectOption {
kind: "none" | "blur" | "image"; kind: "none" | "blur" | "image";
/** The thumbnail, for kind "image". Blur and no effect draw their own. */ /** The thumbnail, for kind "image". Blur and no effect draw their own. */
imageUrl?: string; imageUrl?: string;
/** Whether this one is the user's to remove. */
removable?: boolean;
} }
export interface MediaMuteAndSwitchButtonProps { export interface MediaMuteAndSwitchButtonProps {
@@ -111,6 +114,12 @@ export interface MediaMuteAndSwitchButtonProps {
* opening it takes the focus, which would otherwise dismiss the menu. * opening it takes the focus, which would otherwise dismiss the menu.
*/ */
onAddBackgroundImage?: (file: File) => void; onAddBackgroundImage?: (file: File) => void;
/**
* Called to remove one of the user's own backgrounds. Omit to offer no
* removal. The one in force is never offered: it is what the user is
* wearing, and taking it away would leave them with nothing chosen.
*/
onRemoveBackgroundEffect?: (id: string) => void;
/** /**
* For any toggle and option this method will be called. * 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` * So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -162,6 +171,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
selectedBackgroundEffect, selectedBackgroundEffect,
onSelectBackgroundEffect, onSelectBackgroundEffect,
onAddBackgroundImage, onAddBackgroundImage,
onRemoveBackgroundEffect,
onSelect, onSelect,
}) => { }) => {
// Which device we have asked for but not yet been given. Carries the kind as // Which device we have asked for but not yet been given. Carries the kind as
@@ -471,11 +481,31 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// exception: it needs no processing, so it stays choosable and stays the // exception: it needs no processing, so it stays choosable and stays the
// one in force. // one in force.
const unavailable = onSelectBackgroundEffect === undefined; const unavailable = onSelectBackgroundEffect === undefined;
// The one in force is never offered for removal: FR-025. Everything else
// the user added is theirs to take away.
const canRemove = (effect: BackgroundEffectOption): boolean =>
effect.removable === true &&
onRemoveBackgroundEffect !== undefined &&
selectedBackgroundEffect !== effect.id;
const tiles = list.map((effect) => ( const tiles = list.map((effect) => (
<MenuItem <MenuItem
as="div" as="div"
hideChevron hideChevron
disabled={unavailable && effect.kind !== "none"} disabled={unavailable && effect.kind !== "none"}
// The cross is drawn, not focusable: a control inside a menu item is
// invalid, and the item is what the arrow keys walk. Delete reaches
// the same action from the keyboard, announced by aria-keyshortcuts.
aria-keyshortcuts={canRemove(effect) ? "Delete" : undefined}
onKeyDown={
canRemove(effect)
? (e: React.KeyboardEvent): void => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
e.preventDefault();
onRemoveBackgroundEffect?.(effect.id);
}
: undefined
}
className={classNames(styles.effectTile, { className={classNames(styles.effectTile, {
[styles.effectTileSelected]: selectedBackgroundEffect === effect.id, [styles.effectTileSelected]: selectedBackgroundEffect === effect.id,
})} })}
@@ -497,6 +527,29 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
alt="" alt=""
/> />
)} )}
{canRemove(effect) && (
// Stands where the tick would on the one in force, so the corner
// of a tile means the same thing throughout: what this tile is
// doing, or what you can do to it.
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions --
Deliberately not a control. It sits inside the swatch, which
is aria-hidden, so assistive technology never meets it; the
keyboard reaches the same action through Delete on the item
itself. Giving it a role and a tab stop would put a control
inside a menu item, which is invalid, and would add a stop the
arrow keys do not know about. */
<span
className={styles.effectRemove}
onPointerDown={(e): void => e.stopPropagation()}
onClick={(e): void => {
e.stopPropagation();
onRemoveBackgroundEffect?.(effect.id);
}}
>
<CloseIcon width={16} height={16} />
</span>
)}
{selectedBackgroundEffect === effect.id ? ( {selectedBackgroundEffect === effect.id ? (
// The tick stands where the glyph would, and on a picture it // The tick stands where the glyph would, and on a picture it
// carries its own ground so it reads against whatever is behind // carries its own ground so it reads against whatever is behind