mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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:
@@ -352,8 +352,20 @@ Please see LICENSE in the repository root for full details.
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.effectGrid .effectTile:hover .effectRemove,
|
.effectGrid .effectTileWrap:hover .effectRemove,
|
||||||
.effectGrid .effectTile:focus-within .effectRemove,
|
.effectGrid .effectTileWrap:focus-within .effectRemove {
|
||||||
.menu[data-focus-modality="keyboard"] .effectTile:focus .effectRemove {
|
|
||||||
display: flex;
|
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");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -488,99 +488,105 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
onRemoveBackgroundEffect !== undefined &&
|
onRemoveBackgroundEffect !== undefined &&
|
||||||
selectedBackgroundEffect !== effect.id;
|
selectedBackgroundEffect !== effect.id;
|
||||||
|
|
||||||
const tiles = list.map((effect) => (
|
const tiles = list.map((effect) => {
|
||||||
<MenuItem
|
const tile = (
|
||||||
as="div"
|
<MenuItem
|
||||||
hideChevron
|
as="div"
|
||||||
disabled={unavailable && effect.kind !== "none"}
|
hideChevron
|
||||||
// The cross is drawn, not focusable: a control inside a menu item is
|
disabled={unavailable && effect.kind !== "none"}
|
||||||
// invalid, and the item is what the arrow keys walk. Delete reaches
|
// The cross is drawn, not focusable: a control inside a menu item is
|
||||||
// the same action from the keyboard, announced by aria-keyshortcuts.
|
// invalid, and the item is what the arrow keys walk. Delete reaches
|
||||||
aria-keyshortcuts={canRemove(effect) ? "Delete" : undefined}
|
// the same action from the keyboard, announced by aria-keyshortcuts.
|
||||||
onKeyDown={
|
aria-keyshortcuts={canRemove(effect) ? "Delete" : undefined}
|
||||||
canRemove(effect)
|
onKeyDown={
|
||||||
? (e: React.KeyboardEvent): void => {
|
canRemove(effect)
|
||||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
? (e: React.KeyboardEvent): void => {
|
||||||
e.preventDefault();
|
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||||
onRemoveBackgroundEffect?.(effect.id);
|
e.preventDefault();
|
||||||
}
|
|
||||||
: 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();
|
|
||||||
onRemoveBackgroundEffect?.(effect.id);
|
onRemoveBackgroundEffect?.(effect.id);
|
||||||
}}
|
}
|
||||||
>
|
: undefined
|
||||||
<CloseIcon width={16} height={16} />
|
}
|
||||||
</span>
|
className={classNames(styles.effectTile, {
|
||||||
)}
|
[styles.effectTileSelected]: selectedBackgroundEffect === effect.id,
|
||||||
{selectedBackgroundEffect === effect.id ? (
|
})}
|
||||||
// The tick stands where the glyph would, and on a picture it
|
label={effect.label}
|
||||||
// carries its own ground so it reads against whatever is behind
|
// An image is its own label, so its name is carried for assistive
|
||||||
// it.
|
// technology alone rather than drawn over the picture.
|
||||||
<CheckCircleSolidIcon
|
labelProps={{
|
||||||
className={classNames(styles.effectCheck, {
|
className:
|
||||||
[styles.effectCheckOnImage]: effect.kind === "image",
|
effect.kind === "image"
|
||||||
})}
|
? styles.effectLabelUnseen
|
||||||
width={20}
|
: styles.effectLabel,
|
||||||
height={20}
|
}}
|
||||||
/>
|
Icon={
|
||||||
) : (
|
<span aria-hidden className={styles.effectSwatch}>
|
||||||
<>
|
{effect.kind === "image" && (
|
||||||
{effect.kind === "none" && <BlockIcon width={20} height={20} />}
|
<img
|
||||||
{effect.kind === "blur" && (
|
className={styles.effectThumb}
|
||||||
<span className={styles.effectBlurGlyph} />
|
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>
|
</span>
|
||||||
}
|
</div>
|
||||||
onSelect={(e) => {
|
) : (
|
||||||
e.preventDefault();
|
tile
|
||||||
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
|
// Adding is a command, not a choice, so it is a plain item among the tiles
|
||||||
// rather than another radio.
|
// rather than another radio.
|
||||||
if (onAddBackgroundImage !== undefined)
|
if (onAddBackgroundImage !== undefined)
|
||||||
|
|||||||
Reference in New Issue
Block a user