Make the cross remove by mouse, not just by keyboard

- The cross now sits beside the tile rather than inside it. Inside, the
  menu reached the item before any handler on the cross, whichever phase
  it was in: the tile was chosen, became the one in force, and the cross
  was taken away before it had acted
- That is why the keyboard worked and the mouse did not, and why the
  static stories missed it: with selection fixed by an arg, nothing
  re-rendered and the click survived
- A story with live selection covers it now: the cross removes the tile
  and does not make it the one in force on the way out

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 e3aaad8615
commit 3ac42a735b
3 changed files with 166 additions and 94 deletions
@@ -352,8 +352,20 @@ Please see LICENSE in the repository root for full details.
cursor: pointer;
}
.effectGrid .effectTile:hover .effectRemove,
.effectGrid .effectTile:focus-within .effectRemove,
.menu[data-focus-modality="keyboard"] .effectTile:focus .effectRemove {
.effectGrid .effectTileWrap:hover .effectRemove,
.effectGrid .effectTileWrap:focus-within .effectRemove {
display: flex;
}
/* Holds a tile that can be removed, so the cross can sit over it without
being inside the menu item. */
.effectTileWrap {
position: relative;
display: flex;
min-inline-size: 0;
}
.effectGrid .effectTileWrap > .effectTile {
flex: 1;
min-inline-size: 0;
}
@@ -960,3 +960,57 @@ export const AddedBackgroundsCanBeRemoved: Story = {
);
},
};
/**
* Removal where choosing a tile really changes what is in force, as it does in
* a call.
*
* The static stories cannot catch this: pressing the cross also reaches the
* tile, and with live selection that made the tile the one in force, which
* took the cross away before its own click landed. By mouse nothing happened;
* by keyboard it worked, because the keyboard never touches the cross.
*/
export const RemovingWithLiveSelection: Story = {
args: { ...AddedBackgroundsCanBeRemoved.args },
render: function WithLiveSelection(args): JSX.Element {
const [selected, setSelected] = useState("none");
const [effects, setEffects] = useState(args.backgroundEffects ?? []);
return (
<MediaMuteAndSwitchButton
{...args}
backgroundEffects={effects}
selectedBackgroundEffect={selected}
onSelectBackgroundEffect={setSelected}
onRemoveBackgroundEffect={(id): void =>
setEffects((current) => current.filter((o) => o.id !== id))
}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const body = within(document.body);
const tile = await body.findByRole("menuitemradio", {
name: "Background 4",
});
await userEvent.hover(tile);
// Beside the tile, not inside it: a control within a menu item would be
// invalid, and the item would take the click first.
const cross = tile.parentElement!.querySelector<HTMLElement>(
`.${styles.effectRemove}`,
)!;
await expect(cross).toBeVisible();
await userEvent.click(cross);
// Gone, and it did not make itself the one in force on the way out.
await waitFor(async () =>
expect(
body.queryByRole("menuitemradio", { name: "Background 4" }),
).toBeNull(),
);
const none = await body.findByRole("menuitemradio", { name: "None" });
await expect(none).toHaveAttribute("aria-checked", "true");
},
};
+97 -91
View File
@@ -488,99 +488,105 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
onRemoveBackgroundEffect !== undefined &&
selectedBackgroundEffect !== effect.id;
const tiles = list.map((effect) => (
<MenuItem
as="div"
hideChevron
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, {
[styles.effectTileSelected]: selectedBackgroundEffect === effect.id,
})}
label={effect.label}
// An image is its own label, so its name is carried for assistive
// technology alone rather than drawn over the picture.
labelProps={{
className:
effect.kind === "image"
? styles.effectLabelUnseen
: styles.effectLabel,
}}
Icon={
<span aria-hidden className={styles.effectSwatch}>
{effect.kind === "image" && (
<img
className={styles.effectThumb}
src={effect.imageUrl}
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();
const tiles = list.map((effect) => {
const tile = (
<MenuItem
as="div"
hideChevron
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);
}}
>
<CloseIcon width={16} height={16} />
</span>
)}
{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.
<CheckCircleSolidIcon
className={classNames(styles.effectCheck, {
[styles.effectCheckOnImage]: effect.kind === "image",
})}
width={20}
height={20}
/>
) : (
<>
{effect.kind === "none" && <BlockIcon width={20} height={20} />}
{effect.kind === "blur" && (
<span className={styles.effectBlurGlyph} />
)}
</>
)}
}
: undefined
}
className={classNames(styles.effectTile, {
[styles.effectTileSelected]: selectedBackgroundEffect === effect.id,
})}
label={effect.label}
// An image is its own label, so its name is carried for assistive
// technology alone rather than drawn over the picture.
labelProps={{
className:
effect.kind === "image"
? styles.effectLabelUnseen
: styles.effectLabel,
}}
Icon={
<span aria-hidden className={styles.effectSwatch}>
{effect.kind === "image" && (
<img
className={styles.effectThumb}
src={effect.imageUrl}
alt=""
/>
)}
{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.
<CheckCircleSolidIcon
className={classNames(styles.effectCheck, {
[styles.effectCheckOnImage]: effect.kind === "image",
})}
width={20}
height={20}
/>
) : (
<>
{effect.kind === "none" && (
<BlockIcon width={20} height={20} />
)}
{effect.kind === "blur" && (
<span className={styles.effectBlurGlyph} />
)}
</>
)}
</span>
}
onSelect={(e) => {
e.preventDefault();
if (effect.id === selectedBackgroundEffect) return;
onSelectBackgroundEffect?.(effect.id);
}}
key={effect.id}
role="menuitemradio"
aria-checked={selectedBackgroundEffect === effect.id}
/>
);
// The cross sits beside the item rather than inside it. Inside, its
// press and its click both reach the item first however they are
// handled, so the tile was chosen and the cross taken away before it
// could act: by mouse nothing happened, while the keyboard, which never
// touches it, worked.
return canRemove(effect) ? (
<div className={styles.effectTileWrap} key={effect.id}>
{tile}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions --
Deliberately not a control: aria-hidden, so assistive technology
never meets it, and the keyboard reaches the same action through
Delete on the tile. A role and a tab stop here would add a stop
the menu's arrow keys know nothing about. */}
<span
aria-hidden
className={styles.effectRemove}
onClick={(): void => onRemoveBackgroundEffect?.(effect.id)}
>
<CloseIcon width={16} height={16} />
</span>
}
onSelect={(e) => {
e.preventDefault();
if (effect.id === selectedBackgroundEffect) return;
onSelectBackgroundEffect?.(effect.id);
}}
key={effect.id}
role="menuitemradio"
aria-checked={selectedBackgroundEffect === effect.id}
/>
));
</div>
) : (
tile
);
});
// Adding is a command, not a choice, so it is a plain item among the tiles
// rather than another radio.
if (onAddBackgroundImage !== undefined)