Skip to content

Commit

Permalink
Improve accessibility of keyboard shortcuts
Browse files Browse the repository at this point in the history
Ensure that they don't interfere with say, using spacebar to press a button, and also ensure that they won't do surprising things like scroll the page at the same time.
  • Loading branch information
robintown committed Jul 24, 2024
1 parent c74cebc commit 380f49f
Show file tree
Hide file tree
Showing 2 changed files with 107 additions and 9 deletions.
24 changes: 15 additions & 9 deletions src/useCallViewKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { RefObject, useCallback, useRef } from "react";
import { RefObject, useCallback, useMemo, useRef } from "react";

import { useEventTarget } from "./useEvents";

/**
* Determines whether focus is in the same part of the tree as the given
* element (specifically, if an ancestor or descendant of it is focused).
* element (specifically, if the element or an ancestor of it is focused).
*/
const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
const focusedElement = document.activeElement;
return (
focusedElement !== null &&
(focusedElement.contains(e) || e.contains(focusedElement))
);
return focusedElement !== null && focusedElement.contains(e);
};

export function useCallViewKeyboardShortcuts(
Expand All @@ -50,12 +47,17 @@ export function useCallViewKeyboardShortcuts(
if (!mayReceiveKeyEvents(focusElement.current)) return;

if (event.key === "m") {
event.preventDefault();
toggleMicrophoneMuted();
} else if (event.key == "v") {
event.preventDefault();
toggleLocalVideoMuted();
} else if (event.key === " " && !spacebarHeld.current) {
spacebarHeld.current = true;
setMicrophoneMuted(false);
} else if (event.key === " ") {
event.preventDefault();
if (!spacebarHeld.current) {
spacebarHeld.current = true;
setMicrophoneMuted(false);
}
}
},
[
Expand All @@ -65,6 +67,10 @@ export function useCallViewKeyboardShortcuts(
setMicrophoneMuted,
],
),
// Because this is set on the window, to prevent shortcuts from activating
// another event callback at the same time, we need to preventDefault
// *before* child elements receive the event by using capture mode
useMemo(() => ({ capture: true }), []),
);

useEventTarget(
Expand Down
92 changes: 92 additions & 0 deletions test/useCallViewKeyboardShortcuts-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { render } from "@testing-library/react";
import { FC, useRef } from "react";
import { test } from "vitest";
import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";

import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";

interface TestComponentProps {
setMicrophoneMuted: (muted: boolean) => void;
onButtonClick?: () => void;
}

const TestComponent: FC<TestComponentProps> = ({
setMicrophoneMuted,
onButtonClick = (): void => {},
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts(
ref,
() => {},
() => {},
setMicrophoneMuted,
);
return (
<div ref={ref}>
<Button onClick={onButtonClick}>I'm a button</Button>
</div>
);
};

test("spacebar unmutes", async () => {
const user = userEvent.setup();
let muted = true;
render(<TestComponent setMicrophoneMuted={(m) => (muted = m)} />);

await user.keyboard("[Space>]");
expect(muted).toBe(false);
await user.keyboard("[/Space]");
expect(muted).toBe(true);
});

test("spacebar prioritizes pressing a button", async () => {
const user = userEvent.setup();
const setMuted = vi.fn();
const onClick = vi.fn();
render(
<TestComponent setMicrophoneMuted={setMuted} onButtonClick={onClick} />,
);

await user.tab(); // Focus the button
await user.keyboard("[Space]");
expect(setMuted).not.toBeCalled();
expect(onClick).toBeCalled();
});

test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const defaultPrevented = vi.fn();
// In the real application, we mostly just want the spacebar shortcut to avoid
// scrolling the page. But to test that here in JSDOM, we need some kind of
// container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job.
render(
<video
tabIndex={0}
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
>
<TestComponent setMicrophoneMuted={() => {}} />
</video>,
);

await user.tab(); // Focus the <video>
await user.keyboard("[Space]");
expect(defaultPrevented).toBeCalledWith(true);
});

0 comments on commit 380f49f

Please sign in to comment.