mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Track the focus modality on the menu itself
- Was watched at the document, so a key pressed in another Element Call on the same page — or in the host's own page — lit this menu's ring. Element Call can be mounted more than once, and the menu is portalled out of the root. - Watched on the menu instead, which is the element that holds focus. - A callback ref, not an effect on `menuOpen`: that state is ours and the open menu is Radix's, and they do not commit together, so the effect could run before there was anything to attach to. - Recorded on the menu rather than in state, so every item it can focus answers to it — the camera menu's blur toggle kept the browser's own ring otherwise, which follows the pointer. - Nothing re-renders when the modality changes now, so the reset folds in with the listeners and the second effect on that state goes. - Comments moved to the code they describe; three unrelated ones had stacked up on one declaration.
This commit is contained in:
@@ -109,14 +109,19 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
/* Radix focuses whatever the pointer is over, so the browser's own ring marks
|
/* Radix focuses whatever the pointer is over, so the browser's own ring marks
|
||||||
the item under the mouse. It cannot distinguish the two modalities here, so
|
the item under the mouse. It cannot distinguish the two modalities here, so
|
||||||
it is suppressed and replaced by one that can. */
|
it is suppressed and replaced by one that can.
|
||||||
.deviceList [role="menuitemradio"]:focus,
|
|
||||||
.deviceList [role="menuitemradio"]:focus-visible {
|
Every kind of item the menu can focus, not only the device rows: the camera
|
||||||
|
menu's blur toggle is a checkbox item and a child of the menu rather than of
|
||||||
|
the list, and left out it kept the browser's own ring and followed the
|
||||||
|
pointer with it. */
|
||||||
|
.menu [role^="menuitem"]:focus,
|
||||||
|
.menu [role^="menuitem"]:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shown only when the keyboard is what moved the focus. */
|
/* Shown only when the keyboard is what moved the focus. */
|
||||||
.deviceList[data-focus-modality="keyboard"] [role="menuitemradio"]:focus {
|
.menu[data-focus-modality="keyboard"] [role^="menuitem"]:focus {
|
||||||
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
|
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
|
||||||
outline-offset: calc(-1 * var(--cpd-border-width-2));
|
outline-offset: calc(-1 * var(--cpd-border-width-2));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,3 +374,89 @@ function overlapping(element: Element, overlays: Element[]): number {
|
|||||||
return Math.max(worst, shared);
|
return Math.max(worst, shared);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The focus ring belongs to the keyboard. Radix focuses whatever the pointer is
|
||||||
|
* over, so a ring that followed focus alone would trail the mouse.
|
||||||
|
*
|
||||||
|
* Asserted on the painted outline rather than on `data-focus-modality`: the
|
||||||
|
* attribute is what the stylesheet keys off, so asserting it would pass even
|
||||||
|
* with the rule deleted.
|
||||||
|
*/
|
||||||
|
export const KeyboardFocusRing: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||||
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||||
|
],
|
||||||
|
selectedOption: "mic1",
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||||
|
const menu = within(document.body);
|
||||||
|
const first = await menu.findByRole("menuitemradio", {
|
||||||
|
name: "Microphone 1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opened by pointer: no ring, even though Radix has moved focus.
|
||||||
|
await expect(outlineWidth(first)).toBe(0);
|
||||||
|
|
||||||
|
await userEvent.keyboard("{ArrowDown}");
|
||||||
|
const focused = document.activeElement as HTMLElement;
|
||||||
|
await expect(focused).toHaveRole("menuitemradio");
|
||||||
|
await expect(outlineWidth(focused)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// And the pointer takes it away again.
|
||||||
|
await userEvent.hover(first);
|
||||||
|
await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The painted outline width, in pixels, however the stylesheet spells it. */
|
||||||
|
function outlineWidth(element: HTMLElement): number {
|
||||||
|
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
||||||
|
if (outlineStyle === "none") return 0;
|
||||||
|
return Number.parseFloat(outlineWidth) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The camera menu's blur toggle, which the keyboard reaches after the cameras.
|
||||||
|
*
|
||||||
|
* It is a checkbox item and a child of the menu rather than of the device list,
|
||||||
|
* so a focus ring hung on the list alone left it with the browser's own —
|
||||||
|
* which follows the pointer, and is what the ring exists to replace.
|
||||||
|
*/
|
||||||
|
export const FocusRingCoversTheBlurToggle: Story = {
|
||||||
|
args: {
|
||||||
|
...VideoUnmute.args,
|
||||||
|
iconsAndLabels: "video",
|
||||||
|
videoBlurEnabled: false,
|
||||||
|
videoBlurToggleClick: fn(),
|
||||||
|
},
|
||||||
|
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/,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Arrowed down past the cameras to the toggle, which is the last thing in
|
||||||
|
// the menu.
|
||||||
|
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
|
||||||
|
await userEvent.keyboard("{ArrowDown}");
|
||||||
|
await expect(document.activeElement).toBe(toggle);
|
||||||
|
// The same ring the device rows get.
|
||||||
|
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// And the pointer takes it away again, with the toggle still focused — so
|
||||||
|
// there is something to light up and it is not lit.
|
||||||
|
await userEvent.hover(toggle);
|
||||||
|
await expect(document.activeElement).toBe(toggle);
|
||||||
|
await expect(outlineWidth(toggle)).toBe(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -139,39 +139,57 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
const isBusy = busy ?? false;
|
const isBusy = busy ?? false;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
// Only while the menu is open, so nothing holds a second capture of the
|
|
||||||
// microphone for the length of a call.
|
|
||||||
// The menu is portalled outside the call root, so nothing in the stylesheets
|
|
||||||
// can size it against the call. Measure the call area rather than the window,
|
|
||||||
// or the menu is wrong wherever Element Call is not the whole page. Measured
|
|
||||||
// when the menu opens: it is short-lived enough not to need watching.
|
|
||||||
// Radix focuses whatever the pointer is over, so the browser's own
|
// Radix focuses whatever the pointer is over, so the browser's own
|
||||||
// :focus-visible cannot tell us whether a person is navigating by keyboard:
|
// :focus-visible cannot tell us whether a person is navigating by keyboard:
|
||||||
// Chromium answers yes to everything after any key press, Firefox answers no
|
// Chromium answers yes to everything after any key press, Firefox answers no
|
||||||
// to programmatic focus. Track it ourselves and let the styling follow.
|
// to programmatic focus. Track it ourselves and let the styling follow.
|
||||||
const [focusModality, setFocusModality] = useState<"keyboard" | "pointer">(
|
/**
|
||||||
"pointer",
|
* Tracks which modality moved the focus, for as long as the list is mounted.
|
||||||
|
*
|
||||||
|
* A ref rather than an effect on `menuOpen`: that state is ours, the open
|
||||||
|
* menu is Radix's, and the two do not commit together — an effect keyed on
|
||||||
|
* ours can run before Radix has mounted the content, with nothing to attach
|
||||||
|
* to. The list existing is the honest signal that the menu is open.
|
||||||
|
*
|
||||||
|
* Recorded on the menu rather than held in state, because every item the menu
|
||||||
|
* can focus has to answer to it — the device rows and the camera menu's blur
|
||||||
|
* toggle, which is the menu's child and not the list's — and because which
|
||||||
|
* modality someone is using changes nothing that has to be rendered again.
|
||||||
|
*/
|
||||||
|
const trackFocusModality = useCallback(
|
||||||
|
(list: HTMLDivElement | null): (() => void) | undefined => {
|
||||||
|
// Watched on the menu, not on the document. Element Call can be mounted
|
||||||
|
// more than once in a host's page, and the menu is portalled out of the
|
||||||
|
// call root, so a document listener would also answer for a key pressed
|
||||||
|
// in the other instance, or in the host's own page. The menu rather than
|
||||||
|
// the list, because the first arrow key arrives while the menu itself
|
||||||
|
// holds focus, above anything we render.
|
||||||
|
const menu = list?.closest<HTMLElement>('[role="menu"]');
|
||||||
|
if (menu === null || menu === undefined) return;
|
||||||
|
// Each opening starts over: the modality belongs to whoever is using this
|
||||||
|
// menu now, not to whoever last used it.
|
||||||
|
const record = (modality: "keyboard" | "pointer"): void => {
|
||||||
|
menu.dataset.focusModality = modality;
|
||||||
|
};
|
||||||
|
record("pointer");
|
||||||
|
const usedKeyboard = (): void => record("keyboard");
|
||||||
|
const usedPointer = (): void => record("pointer");
|
||||||
|
menu.addEventListener("keydown", usedKeyboard, true);
|
||||||
|
menu.addEventListener("pointermove", usedPointer, true);
|
||||||
|
return (): void => {
|
||||||
|
menu.removeEventListener("keydown", usedKeyboard, true);
|
||||||
|
menu.removeEventListener("pointermove", usedPointer, true);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
|
||||||
if (!menuOpen) return;
|
// The menu is portalled outside the call root, so nothing in the stylesheets
|
||||||
// Watched at the document, and only while the menu is open. Which modality
|
// can size it against the call. Measure the call area rather than the window,
|
||||||
// someone is using is not a property of any one element: the first arrow
|
// or the menu is wrong wherever Element Call is not the whole page.
|
||||||
// key arrives while the menu itself holds focus, above anything we render,
|
|
||||||
// and Radix moves focus around as the pointer travels.
|
|
||||||
const usedKeyboard = (): void => setFocusModality("keyboard");
|
|
||||||
const usedPointer = (): void => setFocusModality("pointer");
|
|
||||||
document.addEventListener("keydown", usedKeyboard, true);
|
|
||||||
document.addEventListener("pointermove", usedPointer, true);
|
|
||||||
return (): void => {
|
|
||||||
document.removeEventListener("keydown", usedKeyboard, true);
|
|
||||||
document.removeEventListener("pointermove", usedPointer, true);
|
|
||||||
};
|
|
||||||
}, [menuOpen]);
|
|
||||||
const rootElement = useRootElement();
|
const rootElement = useRootElement();
|
||||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||||
useEffect(() => {
|
|
||||||
if (menuOpen) setFocusModality("pointer");
|
|
||||||
}, [menuOpen]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
// Followed rather than measured once: a host can resize the space Element
|
// Followed rather than measured once: a host can resize the space Element
|
||||||
@@ -440,9 +458,9 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
<div
|
<div
|
||||||
// Transparent to assistive technology, so the menu still sees its
|
// Transparent to assistive technology, so the menu still sees its
|
||||||
// items as its own children.
|
// items as its own children.
|
||||||
|
ref={trackFocusModality}
|
||||||
role="none"
|
role="none"
|
||||||
className={styles.deviceList}
|
className={styles.deviceList}
|
||||||
data-focus-modality={focusModality}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--device-list-max-height":
|
"--device-list-max-height":
|
||||||
|
|||||||
Reference in New Issue
Block a user