6.2 KB236 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @jest-environment jsdom
8 */
9
10import '@testing-library/jest-dom';
11import {act, render, screen} from '@testing-library/react';
12import userEvent from '@testing-library/user-event';
13import {useCallback, useState} from 'react';
14import {KeyCode, makeCommandDispatcher, Modifier} from '../KeyboardShortcuts';
15
16/* eslint-disable no-bitwise */
17
18describe('KeyboardShortcuts', () => {
19 it('handles callbacks in components', () => {
20 const [ShortcutContext, useCommand] = makeCommandDispatcher({
21 foo: [Modifier.SHIFT | Modifier.CMD | Modifier.CTRL, KeyCode.One],
22 });
23
24 function MyComponent() {
25 const [value, setValue] = useState('bad');
26 const onFoo = useCallback(() => {
27 setValue('good');
28 }, [setValue]);
29 useCommand('foo', onFoo);
30 return <div>{value}</div>;
31 }
32
33 render(
34 <ShortcutContext>
35 <MyComponent />
36 </ShortcutContext>,
37 );
38
39 expect(screen.queryByText('good')).not.toBeInTheDocument();
40 act(() => {
41 userEvent.type(document.body, '{shift}{meta}{ctrl}1');
42 });
43 expect(screen.queryByText('good')).toBeInTheDocument();
44 });
45
46 it('only triggers exactly the command requested', () => {
47 const [ShortcutContext, useCommand] = makeCommandDispatcher({
48 expected: [Modifier.CTRL | Modifier.CMD, KeyCode.Two],
49 bad0: [Modifier.NONE, KeyCode.Two],
50 bad1: [Modifier.CTRL, KeyCode.Two],
51 bad2: [Modifier.CMD, KeyCode.Two],
52 bad3: [Modifier.SHIFT, KeyCode.Two],
53 bad4: [Modifier.ALT, KeyCode.Two],
54 bad5: [Modifier.CTRL | Modifier.CMD, KeyCode.Three],
55 bad6: [Modifier.ALT | Modifier.SHIFT, KeyCode.Two],
56 bad7: [Modifier.SHIFT | Modifier.CMD | Modifier.CTRL, KeyCode.Two],
57 bad8: [Modifier.ALT | Modifier.CMD | Modifier.CTRL, KeyCode.Two],
58 });
59
60 function MyComponent() {
61 const [value, setValue] = useState('bad');
62 const onFoo = useCallback(() => {
63 setValue('good');
64 }, [setValue]);
65 const die = () => {
66 throw new Error('wrong command triggered');
67 };
68 useCommand('expected', onFoo);
69 useCommand('bad0', die);
70 useCommand('bad1', die);
71 useCommand('bad2', die);
72 useCommand('bad3', die);
73 useCommand('bad4', die);
74 useCommand('bad5', die);
75 useCommand('bad6', die);
76 useCommand('bad7', die);
77 useCommand('bad8', die);
78 return <div>{value}</div>;
79 }
80
81 render(
82 <ShortcutContext>
83 <MyComponent />
84 </ShortcutContext>,
85 );
86
87 expect(screen.queryByText('good')).not.toBeInTheDocument();
88 act(() => {
89 userEvent.type(document.body, '{ctrl}{meta}2');
90 });
91 expect(screen.queryByText('good')).toBeInTheDocument();
92 });
93
94 it("typing into input doesn't trigger commands", () => {
95 const [ShortcutContext, useCommand] = makeCommandDispatcher({
96 foo: [Modifier.SHIFT, KeyCode.D],
97 });
98
99 let value = 0;
100 function MyComponent() {
101 useCommand('foo', () => {
102 value++;
103 });
104 return (
105 <div>
106 {value}
107 <textarea data-testid="myTextArea" />
108 <input data-testid="myInput" />
109 </div>
110 );
111 }
112
113 render(
114 <ShortcutContext>
115 <MyComponent />
116 </ShortcutContext>,
117 );
118
119 expect(value).toEqual(0);
120 act(() => {
121 userEvent.type(document.body, '{shift}D');
122 });
123 expect(value).toEqual(1);
124 act(() => {
125 userEvent.type(screen.getByTestId('myTextArea'), '{shift}D');
126 });
127 expect(value).toEqual(1);
128 act(() => {
129 userEvent.type(screen.getByTestId('myInput'), '{shift}D');
130 });
131 expect(value).toEqual(1);
132 });
133
134 it('only subscribes to component listeners while mounted', () => {
135 const [ShortcutContext, useCommand] = makeCommandDispatcher({
136 bar: [Modifier.CMD, KeyCode.Four],
137 });
138
139 let value = 0;
140 let savedSetRenderChild: undefined | ((value: boolean) => unknown) = undefined;
141 function MyWrapper() {
142 const [renderChild, setRenderChild] = useState(true);
143 savedSetRenderChild = setRenderChild;
144 if (renderChild) {
145 return <MyComponent />;
146 }
147 return null;
148 }
149
150 function MyComponent() {
151 const onBar = useCallback(() => {
152 value++;
153 }, []);
154 useCommand('bar', onBar);
155 return <div>{value}</div>;
156 }
157
158 render(
159 <ShortcutContext>
160 <MyWrapper />
161 </ShortcutContext>,
162 );
163
164 expect(value).toEqual(0);
165 act(() => {
166 userEvent.type(document.body, '{meta}4');
167 });
168 expect(value).toEqual(1);
169
170 // unmount
171 act(() => {
172 (savedSetRenderChild as unknown as (value: boolean) => unknown)(false);
173 });
174
175 act(() => {
176 userEvent.type(document.body, '{meta}4');
177 });
178 // shortcut no longer does anything
179 expect(value).toEqual(1);
180 });
181
182 it('handles multiple subscribers', () => {
183 let value = 0;
184 const [ShortcutContext, useCommand] = makeCommandDispatcher({
185 boz: [Modifier.CMD, KeyCode.Five],
186 });
187
188 function MyComponent() {
189 const onBoz = useCallback(() => {
190 value++;
191 }, []);
192 useCommand('boz', onBoz);
193 return <div>{value}</div>;
194 }
195
196 render(
197 <ShortcutContext>
198 <MyComponent />
199 <MyComponent />
200 </ShortcutContext>,
201 );
202
203 expect(value).toBe(0);
204 act(() => {
205 userEvent.type(document.body, '{meta}5');
206 });
207 expect(value).toBe(2);
208 });
209
210 it('allows explicitly dispatching commands', () => {
211 const [ShortcutContext, useCommand, dispatchCommand] = makeCommandDispatcher({
212 foo: [Modifier.CMD, KeyCode.One],
213 });
214
215 function MyComponent() {
216 const [value, setValue] = useState('bad');
217 const onFoo = useCallback(() => {
218 setValue('good');
219 }, [setValue]);
220 useCommand('foo', onFoo);
221 return <div>{value}</div>;
222 }
223 render(
224 <ShortcutContext>
225 <MyComponent />
226 </ShortcutContext>,
227 );
228
229 expect(screen.queryByText('good')).not.toBeInTheDocument();
230 act(() => {
231 dispatchCommand('foo');
232 });
233 expect(screen.queryByText('good')).toBeInTheDocument();
234 });
235});
236