mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Keep keyboard shortcuts within Element Call's root
The call's shortcuts were listened for on the window and allowed whenever focus was inside the standalone app's `#root` — which, for a component embedded in a host, is the host's own root, or nothing. So m, v and space fired while the user typed in the host's composer, and two Element Calls on one page both answered every key. Listen on the element Element Call treats as its root instead (the body standalone, so nothing changes there), and judge whether a key press is spoken for by what has focus — a dialog or a text field — rather than by where it sits in the DOM, since the modals are now portalled to that same root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
608f107f8d
commit
bc58aed0d7
@@ -18,6 +18,7 @@ import {
|
|||||||
ReactionsRowSize,
|
ReactionsRowSize,
|
||||||
} from "./reactions";
|
} from "./reactions";
|
||||||
import { type Controls } from "./controls";
|
import { type Controls } from "./controls";
|
||||||
|
import { RootElementProvider } from "./RootElementContext";
|
||||||
|
|
||||||
// Test Explanation:
|
// Test Explanation:
|
||||||
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
||||||
@@ -48,10 +49,11 @@ const TestComponent: FC<TestComponentProps> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id={initialModalOpen ? "root" : undefined}>
|
<div>
|
||||||
<Button onClick={onButtonClick}>TEST</Button>
|
<Button onClick={onButtonClick}>TEST</Button>
|
||||||
</div>
|
</div>
|
||||||
{/*// modal lives outside of the root*/}
|
{/* A dialog, which is what claims key presses for itself; where it
|
||||||
|
lives in the DOM does not matter */}
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
<dialog
|
<dialog
|
||||||
open
|
open
|
||||||
@@ -165,7 +167,7 @@ test("unmuting happens in place of the default action", async () => {
|
|||||||
// container element that can be interactive and receive focus / keydown
|
// container element that can be interactive and receive focus / keydown
|
||||||
// events. <video> is kind of a weird choice, but it'll do the job.
|
// events. <video> is kind of a weird choice, but it'll do the job.
|
||||||
render(
|
render(
|
||||||
<div id="root">
|
<div>
|
||||||
<video
|
<video
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
|
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
|
||||||
@@ -210,3 +212,58 @@ test("escape button does not trigger back if sth else is focused", async () => {
|
|||||||
await user.keyboard("[Escape]");
|
await user.keyboard("[Escape]");
|
||||||
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
|
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("typing in a text field is not a shortcut", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const toggleHandRaised = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<>
|
||||||
|
<input type="text" aria-label="Name" />
|
||||||
|
<TestComponent toggleHandRaised={toggleHandRaised} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
getByRole("textbox", { name: "Name" }).focus();
|
||||||
|
await user.keyboard("h");
|
||||||
|
expect(toggleHandRaised).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element Call embedded in a host's page: the root is a container, with the
|
||||||
|
* rest of the page around it.
|
||||||
|
*/
|
||||||
|
const Embedded: FC<{ toggleHandRaised: () => void }> = ({
|
||||||
|
toggleHandRaised,
|
||||||
|
}) => {
|
||||||
|
const [root, setRoot] = useState<HTMLDivElement | null>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button>Host button</button>
|
||||||
|
<div ref={setRoot} data-testid="root">
|
||||||
|
{root !== null && (
|
||||||
|
<RootElementProvider value={root}>
|
||||||
|
<TestComponent toggleHandRaised={toggleHandRaised} />
|
||||||
|
</RootElementProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("only hears keys pressed within its root element", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const toggleHandRaised = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<Embedded toggleHandRaised={toggleHandRaised} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Focus on the host's page: none of Element Call's business
|
||||||
|
getByRole("button", { name: "Host button" }).focus();
|
||||||
|
await user.keyboard("h");
|
||||||
|
expect(toggleHandRaised).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Focus inside the root: a shortcut
|
||||||
|
getByRole("button", { name: "TEST" }).focus();
|
||||||
|
await user.keyboard("h");
|
||||||
|
expect(toggleHandRaised).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCallback, useMemo, useRef } from "react";
|
|||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { useEventTarget } from "./useEvents";
|
import { useEventTarget } from "./useEvents";
|
||||||
|
import { useRootElement } from "./RootElementContext";
|
||||||
import {
|
import {
|
||||||
type ReactionOption,
|
type ReactionOption,
|
||||||
ReactionSet,
|
ReactionSet,
|
||||||
@@ -16,28 +17,37 @@ import {
|
|||||||
} from "./reactions";
|
} from "./reactions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether focus is in the same part of the tree as the given
|
* Whether what has focus is something a key press belongs to, rather than
|
||||||
* element (specifically, if the element or an ancestor of it is focused).
|
* being free for a shortcut: a dialog (the settings, the invite modal, the
|
||||||
|
* reaction picker), or anything the user types into.
|
||||||
|
*
|
||||||
|
* Judged by what the focused element is, not by where it sits in the DOM:
|
||||||
|
* Element Call's modals are portalled to whatever it treats as its root, which
|
||||||
|
* is the body for the standalone app but a container inside the host's page
|
||||||
|
* for the component.
|
||||||
*/
|
*/
|
||||||
const mayReceiveKeyEvents = (): boolean => {
|
const focusIsClaimed = (): boolean => {
|
||||||
const root = document.getElementById("root");
|
const active = document.activeElement;
|
||||||
if (root === null) {
|
if (active === null || active === document.body) return false;
|
||||||
logger.warn(
|
if (active.closest("dialog, [role='dialog']") !== null) return true;
|
||||||
"[mayReceiveKeyEvents] Root element not found, always allow keyboard shortcuts (m,v,esc...)",
|
return isTextEntry(active);
|
||||||
);
|
};
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const focusElement = document.activeElement;
|
|
||||||
const nothingInFocus = focusElement === null;
|
|
||||||
const focusOnBody = focusElement === document.body;
|
|
||||||
const noPrimaryFocus =
|
|
||||||
nothingInFocus || root.contains(focusElement) || focusOnBody;
|
|
||||||
|
|
||||||
logger.warn(
|
const textInputTypes = new Set([
|
||||||
`[mayReceiveKeyEvents] nothingInFocus ${nothingInFocus}, focusOnBody ${focusOnBody}, noPrimaryFocus ${noPrimaryFocus}`,
|
"text",
|
||||||
);
|
"search",
|
||||||
// Only if we do not have a primary focus we allow keyboard shortcut events.
|
"email",
|
||||||
return noPrimaryFocus;
|
"url",
|
||||||
|
"password",
|
||||||
|
"number",
|
||||||
|
"tel",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isTextEntry = (element: Element): boolean => {
|
||||||
|
if (element instanceof HTMLTextAreaElement) return true;
|
||||||
|
if (element instanceof HTMLInputElement)
|
||||||
|
return textInputTypes.has(element.type);
|
||||||
|
return element instanceof HTMLElement && element.isContentEditable;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,10 +64,15 @@ const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This hook sets up gloabl keyboard shortcuts. It will filter for keyboard presses that should be ignored due to user
|
* Sets up the call's keyboard shortcuts.
|
||||||
* currently focussing on a modal.
|
*
|
||||||
* This is achieved by using the fact, that all modal inputs are outside the #root element and use react portals to get rendered.
|
* They are listened for on the element Element Call treats as its root — the
|
||||||
* The following shortcuts are auspported (optional):
|
* page, standalone, or the container a host mounted it in — so that a key
|
||||||
|
* pressed anywhere else on a host's page is none of Element Call's business,
|
||||||
|
* and two Element Calls on one page each only hear their own. Key presses that
|
||||||
|
* belong to something else — a dialog, a text field — are left alone.
|
||||||
|
*
|
||||||
|
* The following shortcuts are supported (optional):
|
||||||
* @param toggleAudio - triggered on (m)
|
* @param toggleAudio - triggered on (m)
|
||||||
* @param toggleVideo - triggered on (v)
|
* @param toggleVideo - triggered on (v)
|
||||||
* @param setAudioEnabled - push to talk behavior controlled via (space)
|
* @param setAudioEnabled - push to talk behavior controlled via (space)
|
||||||
@@ -76,17 +91,19 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
toggleHandRaised: (() => void) | null,
|
toggleHandRaised: (() => void) | null,
|
||||||
): void {
|
): void {
|
||||||
const spacebarHeld = useRef(false);
|
const spacebarHeld = useRef(false);
|
||||||
|
const rootElement = useRootElement();
|
||||||
|
|
||||||
// These event handlers are set on the window because we want users to be able
|
// Listened for on the root rather than on what has focus, so that the user
|
||||||
// to trigger them without going to the trouble of focusing something
|
// need not focus anything in particular for a shortcut to work: a key pressed
|
||||||
|
// with nothing focused reaches the body, which is the root standalone.
|
||||||
|
|
||||||
useEventTarget(
|
useEventTarget(
|
||||||
window,
|
rootElement,
|
||||||
"keydown",
|
"keydown",
|
||||||
useCallback(
|
useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
logger.info("Keydown event", event);
|
logger.info("Keydown event", event);
|
||||||
if (!mayReceiveKeyEvents()) return;
|
if (focusIsClaimed()) return;
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -121,18 +138,18 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
toggleHandRaised,
|
toggleHandRaised,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Because this is set on the window, to prevent shortcuts from activating
|
// Because this is set on an ancestor, to prevent shortcuts from activating
|
||||||
// another event callback at the same time, we need to preventDefault
|
// another event callback at the same time, we need to preventDefault
|
||||||
// *before* child elements receive the event by using capture mode
|
// *before* child elements receive the event by using capture mode
|
||||||
useMemo(() => ({ capture: true }), []),
|
useMemo(() => ({ capture: true }), []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEventTarget(
|
useEventTarget(
|
||||||
window,
|
rootElement,
|
||||||
"keyup",
|
"keyup",
|
||||||
useCallback(
|
useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return;
|
if (focusIsClaimed() || !mayReceiveSpaceKeyEvents()) return;
|
||||||
if (event.key === " ") {
|
if (event.key === " ") {
|
||||||
spacebarHeld.current = false;
|
spacebarHeld.current = false;
|
||||||
setAudioEnabled?.(false);
|
setAudioEnabled?.(false);
|
||||||
@@ -142,6 +159,7 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Losing the window is what releases a held spacebar, wherever we are in it
|
||||||
useEventTarget(
|
useEventTarget(
|
||||||
window,
|
window,
|
||||||
"blur",
|
"blur",
|
||||||
|
|||||||
Reference in New Issue
Block a user