Add keyboard shortcuts for reaction sending.

This commit is contained in:
Half-Shot
2024-11-05 18:33:56 +00:00
parent 9ff8197987
commit 507663df87
3 changed files with 425 additions and 30 deletions

View File

@@ -1,15 +1,24 @@
import { act, render } from "@testing-library/react";
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { act, fireEvent, render } from "@testing-library/react";
import { expect, test } from "vitest";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event";
import { ReactNode } from "react";
import {
MockRoom,
MockRTCSession,
TestReactionsWrapper,
} from "../utils/testReactions";
import { ReactionToggleButton } from "./ReactionToggleButton";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { TooltipProvider } from "@vector-im/compound-web";
import { ElementCallReactionEventType } from "../reactions";
import { userEvent } from "@testing-library/user-event";
const memberUserIdAlice = "@alice:example.org";
const memberEventAlice = "$membership-alice:example.org";
@@ -24,20 +33,20 @@ function TestComponent({
}: {
rtcSession: MockRTCSession;
room: MockRoom;
}) {
}): ReactNode {
return (
<TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}>
<ReactionToggleButton
rtcSession={rtcSession as unknown as MatrixRTCSession}
client={room.client}
></ReactionToggleButton>
/>
</TestReactionsWrapper>
</TooltipProvider>
);
}
test("Can open menu", async () => {
test("Can open menu", () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole, container } = render(
@@ -47,7 +56,7 @@ test("Can open menu", async () => {
expect(container).toMatchSnapshot();
});
test("Can close menu", async () => {
test("Can close menu", () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole, container } = render(
@@ -60,7 +69,7 @@ test("Can close menu", async () => {
expect(container).toMatchSnapshot();
});
test("Can raise hand", async () => {
test("Can raise hand", () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole, getByText, container } = render(
@@ -88,7 +97,7 @@ test("Can raise hand", async () => {
expect(container).toMatchSnapshot();
});
test("Can can lower hand", async () => {
test("Can can lower hand", () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole, getByText, container } = render(
@@ -105,7 +114,7 @@ test("Can can lower hand", async () => {
expect(container).toMatchSnapshot();
});
test("Can react with emoji", async () => {
test("Can react with emoji", () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole, getByText } = render(
@@ -172,7 +181,47 @@ test("Can search for and send emoji", async () => {
]);
});
test("Can close search", async () => {
test("Can search for and send emoji with the keyboard", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByText, getByRole, getByPlaceholderText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
act(() => {
getByRole("button").click();
});
act(() => {
getByRole("button", {
name: "Search",
}).click();
});
const searchField = getByPlaceholderText("Search reactions…");
await act(async () => {
searchField.focus();
await user.keyboard("crickets");
});
expect(container).toMatchSnapshot();
act(() => {
fireEvent.keyDown(searchField, { key: "Enter" });
});
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "crickets",
emoji: "🦗",
},
],
]);
});
test("Can close search", () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole, container } = render(
@@ -193,3 +242,24 @@ test("Can close search", async () => {
});
expect(container).toMatchSnapshot();
});
test("Can close search with the escape key", () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole, container, getByPlaceholderText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
act(() => {
getByRole("button").click();
});
act(() => {
getByRole("button", {
name: "Search",
}).click();
});
const searchField = getByPlaceholderText("Search reactions…");
act(() => {
fireEvent.keyDown(searchField, { key: "Escape" });
});
expect(container).toMatchSnapshot();
});

View File

@@ -21,8 +21,12 @@ import {
ChangeEventHandler,
ComponentPropsWithoutRef,
FC,
FormEventHandler,
KeyboardEvent,
KeyboardEventHandler,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
@@ -31,6 +35,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
@@ -40,7 +45,6 @@ import {
ReactionSet,
ElementCallReactionEventType,
} from "../reactions";
import classNames from "classnames";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
@@ -64,12 +68,12 @@ const InnerButton: FC<InnerButtonProps> = ({ raised, ...props }) => {
};
export function ReactionPopupMenu({
sendRelation,
sendReaction,
toggleRaisedHand,
isHandRaised,
canReact,
}: {
sendRelation: (reaction: ReactionOption) => void;
sendReaction: (reaction: ReactionOption) => void;
toggleRaisedHand: () => void;
isHandRaised: boolean;
canReact: boolean;
@@ -94,6 +98,26 @@ export function ReactionPopupMenu({
[searchText, isSearching],
);
const onSearchKeyDown = useCallback<KeyboardEventHandler<never>>(
(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
if (!canReact) {
return;
}
if (filteredReactionSet.length !== 1) {
return;
}
sendReaction(filteredReactionSet[0]);
setIsSearching(false);
} else if (ev.key === "Escape") {
ev.preventDefault();
setIsSearching(false);
}
},
[sendReaction, filteredReactionSet, canReact, setIsSearching],
);
return (
<div className={styles.reactionPopupMenu}>
<section className={styles.handRaiseSection}>
@@ -114,15 +138,17 @@ export function ReactionPopupMenu({
<section>
{isSearching ? (
<>
<Form.Root
className={styles.searchForm}
onSubmit={(e) => e.preventDefault()}
>
<Form.Root className={styles.searchForm}>
<Search
required
value={searchText}
name="reactionSearch"
placeholder="Search reactions…"
onChange={onSearch}
onKeyDown={onSearchKeyDown}
// This is a reasonable use of autofocus, we are focusing when
// the search button is clicked (which matches the Element Web reaction picker)
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<CpdButton
@@ -144,7 +170,7 @@ export function ReactionPopupMenu({
kind="secondary"
className={styles.reactionButton}
disabled={!canReact}
onClick={() => sendRelation(reaction)}
onClick={() => sendReaction(reaction)}
>
{reaction.emoji}
</CpdButton>
@@ -287,7 +313,7 @@ export function ReactionToggleButton({
<ReactionPopupMenu
isHandRaised={isHandRaised}
canReact={canReact}
sendRelation={(reaction) => void sendRelation(reaction)}
sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={toggleRaisedHand}
/>
)}

View File

@@ -204,7 +204,7 @@ exports[`Can close search 1`] = `
<button
aria-disabled="false"
aria-expanded="true"
aria-labelledby=":rba:"
aria-labelledby=":rfg:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
@@ -232,7 +232,7 @@ exports[`Can close search 1`] = `
>
<button
aria-label="Toggle hand raised"
aria-labelledby=":rbg:"
aria-labelledby=":rfm:"
aria-pressed="false"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
@@ -253,7 +253,7 @@ exports[`Can close search 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":rd2:"
aria-labelledby=":rh8:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
@@ -268,7 +268,7 @@ exports[`Can close search 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":rd7:"
aria-labelledby=":rhd:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
@@ -283,7 +283,7 @@ exports[`Can close search 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":rdc:"
aria-labelledby=":rhi:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
@@ -298,7 +298,7 @@ exports[`Can close search 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":rdh:"
aria-labelledby=":rhn:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
@@ -313,7 +313,7 @@ exports[`Can close search 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":rdm:"
aria-labelledby=":rhs:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
@@ -328,7 +328,7 @@ exports[`Can close search 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":rdr:"
aria-labelledby=":ri1:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
@@ -343,7 +343,178 @@ exports[`Can close search 1`] = `
>
<button
aria-label="Open reactions search"
aria-labelledby=":re0:"
aria-labelledby=":ri6:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414l-3.244-3.244ZM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Z"
/>
</svg>
</button>
</li>
</menu>
</section>
</div>
</div>
`;
exports[`Can close search with the escape key 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="true"
aria-labelledby=":rii:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
/>
</svg>
</button>
<div
class="reactionPopupMenu"
>
<section
class="handRaiseSection"
>
<button
aria-label="Toggle hand raised"
aria-labelledby=":rio:"
aria-pressed="false"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
🖐️
</button>
</section>
<div
class="verticalSeperator"
/>
<section>
<menu>
<li
class="reactionPopupMenuItem"
>
<button
aria-disabled="false"
aria-labelledby=":rka:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
👍
</button>
</li>
<li
class="reactionPopupMenuItem"
>
<button
aria-disabled="false"
aria-labelledby=":rkf:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
🎉
</button>
</li>
<li
class="reactionPopupMenuItem"
>
<button
aria-disabled="false"
aria-labelledby=":rkk:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
👏
</button>
</li>
<li
class="reactionPopupMenuItem"
>
<button
aria-disabled="false"
aria-labelledby=":rkp:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
🐶
</button>
</li>
<li
class="reactionPopupMenuItem"
>
<button
aria-disabled="false"
aria-labelledby=":rku:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
🐱
</button>
</li>
<li
class="reactionPopupMenuItem"
>
<button
aria-disabled="false"
aria-labelledby=":rl3:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
💡
</button>
</li>
<li
class="reactionPopupMenuItem"
>
<button
aria-label="Open reactions search"
aria-labelledby=":rl8:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="tertiary"
data-size="lg"
@@ -784,6 +955,7 @@ exports[`Can search for and send emoji 1`] = `
id=":ra4:"
name="reactionSearch"
placeholder="Search reactions…"
required=""
type="search"
value="crickets"
/>
@@ -837,3 +1009,130 @@ exports[`Can search for and send emoji 1`] = `
</div>
</div>
`;
exports[`Can search for and send emoji with the keyboard 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="true"
aria-labelledby=":rba:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
/>
</svg>
</button>
<div
class="reactionPopupMenu"
>
<section
class="handRaiseSection"
>
<button
aria-label="Toggle hand raised"
aria-labelledby=":rbg:"
aria-pressed="false"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
🖐️
</button>
</section>
<div
class="verticalSeperator"
/>
<section>
<form
class="_root_dgy0u_24 searchForm"
>
<label
class="_label_dgy0u_67 _field_dgy0u_34 _search_qztja_17"
for=":rd0:"
>
<svg
class="_icon_qztja_46"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414l-3.244-3.244ZM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Z"
/>
</svg>
<input
class="_input_qztja_61"
id=":rd0:"
name="reactionSearch"
placeholder="Search reactions…"
required=""
type="search"
value="crickets"
/>
</label>
<button
aria-label="close search"
class="_button_i91xf_17 _has-icon_i91xf_66 _destructive_i91xf_116"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</button>
</form>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<menu>
<li
class="reactionPopupMenuItem"
>
<button
aria-disabled="false"
aria-labelledby=":rdc:"
class="_button_i91xf_17 reactionButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
🦗
</button>
</li>
</menu>
</section>
</div>
</div>
`;