addons/components/__tests__/KeyboardShortcuts.test.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 *
b69ab317 * @jest-environment jsdom
b69ab318 */
b69ab319
b69ab3110import '@testing-library/jest-dom';
b69ab3111import {act, render, screen} from '@testing-library/react';
b69ab3112import userEvent from '@testing-library/user-event';
b69ab3113import {useCallback, useState} from 'react';
b69ab3114import {KeyCode, makeCommandDispatcher, Modifier} from '../KeyboardShortcuts';
b69ab3115
b69ab3116/* eslint-disable no-bitwise */
b69ab3117
b69ab3118describe('KeyboardShortcuts', () => {
b69ab3119 it('handles callbacks in components', () => {
b69ab3120 const [ShortcutContext, useCommand] = makeCommandDispatcher({
b69ab3121 foo: [Modifier.SHIFT | Modifier.CMD | Modifier.CTRL, KeyCode.One],
b69ab3122 });
b69ab3123
b69ab3124 function MyComponent() {
b69ab3125 const [value, setValue] = useState('bad');
b69ab3126 const onFoo = useCallback(() => {
b69ab3127 setValue('good');
b69ab3128 }, [setValue]);
b69ab3129 useCommand('foo', onFoo);
b69ab3130 return <div>{value}</div>;
b69ab3131 }
b69ab3132
b69ab3133 render(
b69ab3134 <ShortcutContext>
b69ab3135 <MyComponent />
b69ab3136 </ShortcutContext>,
b69ab3137 );
b69ab3138
b69ab3139 expect(screen.queryByText('good')).not.toBeInTheDocument();
b69ab3140 act(() => {
b69ab3141 userEvent.type(document.body, '{shift}{meta}{ctrl}1');
b69ab3142 });
b69ab3143 expect(screen.queryByText('good')).toBeInTheDocument();
b69ab3144 });
b69ab3145
b69ab3146 it('only triggers exactly the command requested', () => {
b69ab3147 const [ShortcutContext, useCommand] = makeCommandDispatcher({
b69ab3148 expected: [Modifier.CTRL | Modifier.CMD, KeyCode.Two],
b69ab3149 bad0: [Modifier.NONE, KeyCode.Two],
b69ab3150 bad1: [Modifier.CTRL, KeyCode.Two],
b69ab3151 bad2: [Modifier.CMD, KeyCode.Two],
b69ab3152 bad3: [Modifier.SHIFT, KeyCode.Two],
b69ab3153 bad4: [Modifier.ALT, KeyCode.Two],
b69ab3154 bad5: [Modifier.CTRL | Modifier.CMD, KeyCode.Three],
b69ab3155 bad6: [Modifier.ALT | Modifier.SHIFT, KeyCode.Two],
b69ab3156 bad7: [Modifier.SHIFT | Modifier.CMD | Modifier.CTRL, KeyCode.Two],
b69ab3157 bad8: [Modifier.ALT | Modifier.CMD | Modifier.CTRL, KeyCode.Two],
b69ab3158 });
b69ab3159
b69ab3160 function MyComponent() {
b69ab3161 const [value, setValue] = useState('bad');
b69ab3162 const onFoo = useCallback(() => {
b69ab3163 setValue('good');
b69ab3164 }, [setValue]);
b69ab3165 const die = () => {
b69ab3166 throw new Error('wrong command triggered');
b69ab3167 };
b69ab3168 useCommand('expected', onFoo);
b69ab3169 useCommand('bad0', die);
b69ab3170 useCommand('bad1', die);
b69ab3171 useCommand('bad2', die);
b69ab3172 useCommand('bad3', die);
b69ab3173 useCommand('bad4', die);
b69ab3174 useCommand('bad5', die);
b69ab3175 useCommand('bad6', die);
b69ab3176 useCommand('bad7', die);
b69ab3177 useCommand('bad8', die);
b69ab3178 return <div>{value}</div>;
b69ab3179 }
b69ab3180
b69ab3181 render(
b69ab3182 <ShortcutContext>
b69ab3183 <MyComponent />
b69ab3184 </ShortcutContext>,
b69ab3185 );
b69ab3186
b69ab3187 expect(screen.queryByText('good')).not.toBeInTheDocument();
b69ab3188 act(() => {
b69ab3189 userEvent.type(document.body, '{ctrl}{meta}2');
b69ab3190 });
b69ab3191 expect(screen.queryByText('good')).toBeInTheDocument();
b69ab3192 });
b69ab3193
b69ab3194 it("typing into input doesn't trigger commands", () => {
b69ab3195 const [ShortcutContext, useCommand] = makeCommandDispatcher({
b69ab3196 foo: [Modifier.SHIFT, KeyCode.D],
b69ab3197 });
b69ab3198
b69ab3199 let value = 0;
b69ab31100 function MyComponent() {
b69ab31101 useCommand('foo', () => {
b69ab31102 value++;
b69ab31103 });
b69ab31104 return (
b69ab31105 <div>
b69ab31106 {value}
b69ab31107 <textarea data-testid="myTextArea" />
b69ab31108 <input data-testid="myInput" />
b69ab31109 </div>
b69ab31110 );
b69ab31111 }
b69ab31112
b69ab31113 render(
b69ab31114 <ShortcutContext>
b69ab31115 <MyComponent />
b69ab31116 </ShortcutContext>,
b69ab31117 );
b69ab31118
b69ab31119 expect(value).toEqual(0);
b69ab31120 act(() => {
b69ab31121 userEvent.type(document.body, '{shift}D');
b69ab31122 });
b69ab31123 expect(value).toEqual(1);
b69ab31124 act(() => {
b69ab31125 userEvent.type(screen.getByTestId('myTextArea'), '{shift}D');
b69ab31126 });
b69ab31127 expect(value).toEqual(1);
b69ab31128 act(() => {
b69ab31129 userEvent.type(screen.getByTestId('myInput'), '{shift}D');
b69ab31130 });
b69ab31131 expect(value).toEqual(1);
b69ab31132 });
b69ab31133
b69ab31134 it('only subscribes to component listeners while mounted', () => {
b69ab31135 const [ShortcutContext, useCommand] = makeCommandDispatcher({
b69ab31136 bar: [Modifier.CMD, KeyCode.Four],
b69ab31137 });
b69ab31138
b69ab31139 let value = 0;
b69ab31140 let savedSetRenderChild: undefined | ((value: boolean) => unknown) = undefined;
b69ab31141 function MyWrapper() {
b69ab31142 const [renderChild, setRenderChild] = useState(true);
b69ab31143 savedSetRenderChild = setRenderChild;
b69ab31144 if (renderChild) {
b69ab31145 return <MyComponent />;
b69ab31146 }
b69ab31147 return null;
b69ab31148 }
b69ab31149
b69ab31150 function MyComponent() {
b69ab31151 const onBar = useCallback(() => {
b69ab31152 value++;
b69ab31153 }, []);
b69ab31154 useCommand('bar', onBar);
b69ab31155 return <div>{value}</div>;
b69ab31156 }
b69ab31157
b69ab31158 render(
b69ab31159 <ShortcutContext>
b69ab31160 <MyWrapper />
b69ab31161 </ShortcutContext>,
b69ab31162 );
b69ab31163
b69ab31164 expect(value).toEqual(0);
b69ab31165 act(() => {
b69ab31166 userEvent.type(document.body, '{meta}4');
b69ab31167 });
b69ab31168 expect(value).toEqual(1);
b69ab31169
b69ab31170 // unmount
b69ab31171 act(() => {
b69ab31172 (savedSetRenderChild as unknown as (value: boolean) => unknown)(false);
b69ab31173 });
b69ab31174
b69ab31175 act(() => {
b69ab31176 userEvent.type(document.body, '{meta}4');
b69ab31177 });
b69ab31178 // shortcut no longer does anything
b69ab31179 expect(value).toEqual(1);
b69ab31180 });
b69ab31181
b69ab31182 it('handles multiple subscribers', () => {
b69ab31183 let value = 0;
b69ab31184 const [ShortcutContext, useCommand] = makeCommandDispatcher({
b69ab31185 boz: [Modifier.CMD, KeyCode.Five],
b69ab31186 });
b69ab31187
b69ab31188 function MyComponent() {
b69ab31189 const onBoz = useCallback(() => {
b69ab31190 value++;
b69ab31191 }, []);
b69ab31192 useCommand('boz', onBoz);
b69ab31193 return <div>{value}</div>;
b69ab31194 }
b69ab31195
b69ab31196 render(
b69ab31197 <ShortcutContext>
b69ab31198 <MyComponent />
b69ab31199 <MyComponent />
b69ab31200 </ShortcutContext>,
b69ab31201 );
b69ab31202
b69ab31203 expect(value).toBe(0);
b69ab31204 act(() => {
b69ab31205 userEvent.type(document.body, '{meta}5');
b69ab31206 });
b69ab31207 expect(value).toBe(2);
b69ab31208 });
b69ab31209
b69ab31210 it('allows explicitly dispatching commands', () => {
b69ab31211 const [ShortcutContext, useCommand, dispatchCommand] = makeCommandDispatcher({
b69ab31212 foo: [Modifier.CMD, KeyCode.One],
b69ab31213 });
b69ab31214
b69ab31215 function MyComponent() {
b69ab31216 const [value, setValue] = useState('bad');
b69ab31217 const onFoo = useCallback(() => {
b69ab31218 setValue('good');
b69ab31219 }, [setValue]);
b69ab31220 useCommand('foo', onFoo);
b69ab31221 return <div>{value}</div>;
b69ab31222 }
b69ab31223 render(
b69ab31224 <ShortcutContext>
b69ab31225 <MyComponent />
b69ab31226 </ShortcutContext>,
b69ab31227 );
b69ab31228
b69ab31229 expect(screen.queryByText('good')).not.toBeInTheDocument();
b69ab31230 act(() => {
b69ab31231 dispatchCommand('foo');
b69ab31232 });
b69ab31233 expect(screen.queryByText('good')).toBeInTheDocument();
b69ab31234 });
b69ab31235});