Skip to content

Commit 7562fac

Browse files
committed
feat!: enable configuration options
Expose an options object in the API to enable configuring basic behaviour.
1 parent 97c13af commit 7562fac

File tree

5 files changed

+280
-81
lines changed

5 files changed

+280
-81
lines changed

README.md

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,79 +16,141 @@ npm install @reecelucas/react-use-hotkeys
1616

1717
## Example Usage
1818

19+
```ts
20+
import useHotkeys from "@reecelucas/react-use-hotkeys";
21+
```
22+
1923
All hotkey combinations must use valid `KeyBoardEvent` `"key"` values. A full list can be found on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) and Wes Bos has created a great [interactive lookup](https://keycode.info/).
2024

2125
```jsx
2226
// Single keys
23-
useHotkeys('Escape', () => {
24-
console.log('Some action');
27+
useHotkeys("Escape", () => {
28+
console.log("Some action");
2529
});
2630

27-
useHotkeys('F7', () => {
28-
console.log('Some action');
31+
useHotkeys("F7", () => {
32+
console.log("Some action");
2933
});
3034

3135
// Modifier combinations
32-
useHotkeys('Meta+Shift+z', () => {
33-
console.log('Some action');
36+
useHotkeys("Meta+Shift+z", () => {
37+
console.log("Some action");
3438
});
3539

3640
// Key sequences
37-
useHotkeys('w s d', () => {
38-
console.log('Some action');
41+
useHotkeys("w s d", () => {
42+
console.log("Some action");
3943
});
4044

4145
useHotkeys('w " " d', () => {
4246
// space key in sequence (`w ' ' d` also works)
43-
console.log('Some action');
47+
console.log("Some action");
4448
});
4549

4650
// Multiple key combinations mapped to the same callback
47-
useHotkeys(['Control+z', 'Meta+z'], () => {
48-
console.log('Some action');
51+
useHotkeys(["Control+z", "Meta+z"], () => {
52+
console.log("Some action");
4953
});
5054

51-
useHotkeys(['a', 'Meta+z', 'w s d'], () => {
52-
console.log('Some action');
53-
})
55+
useHotkeys(["a", "Meta+z", "w s d"], () => {
56+
console.log("Some action");
57+
});
5458
```
5559

5660
The following patterns are **not** supported:
5761

5862
```jsx
5963
// Modifier keys in sequences
60-
useHotkeys('Control i d', () => {
64+
useHotkeys("Control i d", () => {
6165
console.log("I won't run!");
6266
});
6367

6468
// Modifier combinations in sequences
65-
useHotkeys('Control+z i d', () => {
69+
useHotkeys("Control+z i d", () => {
6670
console.log("I won't run!");
6771
});
6872
```
6973

70-
You can pass `AddEventListenerOptions` if you need to listen for `keydown` events in the capturing phase:
74+
If you find a use case where the API is too restrictive you can use the escape hatch to perform whatever custom logic you need:
7175

7276
```jsx
73-
useHotkeys('Escape', () => {
74-
console.log('Some action');
75-
}, true);
77+
useHotkeys("*", (event) => {
78+
console.log("I will run on every keydown event");
7679

77-
useHotkeys('Escape', () => {
78-
console.log('Some action');
79-
}, { capture: true });
80+
if (customKeyLogic(event)) {
81+
console.log("some action");
82+
}
83+
});
8084
```
8185

82-
If you find a use case where the API is too restrictive you can use the escape hatch to perform whatever custom logic you need:
86+
## Options
87+
88+
### `enabled`
89+
90+
You can disable the hook by passing `enabled: false`. When disabled the hook will stop listening for `keydown` events:
8391

8492
```jsx
85-
useHotkeys('*', event => {
86-
console.log("I will run on every keydown");
93+
useHotkeys(
94+
"Escape",
95+
() => {
96+
console.log("I won't run!");
97+
},
98+
{ enabled: false }
99+
);
100+
```
87101

88-
if (customKeyLogic(event)) {
89-
console.log("some action");
102+
### `enableOnContentEditable`
103+
104+
By default, the hook will ignore `keydown` events originating from elements with the `contenteditable` attribute, since this behaviour is normally what you want. If you want to override this behaviour you can pass `enableOnContentEditable: true`:
105+
106+
```jsx
107+
useHotkeys(
108+
"Escape",
109+
() => {
110+
console.log("Some action");
111+
},
112+
{ enableOnContentEditable: true }
113+
);
114+
```
115+
116+
### `ignoredElementWhitelist`
117+
118+
By default, the hook will ignore `keydown` events originating from `INPUT` and `TEXTAREA` elements, since this behaviour is normally what you want. If you want to override this behaviour you can use `ignoredElementWhitelist`:
119+
120+
```jsx
121+
useHotkeys(
122+
"Escape",
123+
() => {
124+
console.log("I will now run on input elements");
125+
},
126+
{ ignoredElementWhitelist: ["INPUT"] }
127+
);
128+
129+
useHotkeys(
130+
"Escape",
131+
() => {
132+
console.log("I will now run on input and textarea elements");
133+
},
134+
{ ignoredElementWhitelist: ["INPUT", "TEXTAREA"] }
135+
);
136+
```
137+
138+
### `eventListenerOptions`
139+
140+
You can pass [`AddEventListenerOptions`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#parameters) if you need to listen for `keydown` events in the capturing phase:
141+
142+
```jsx
143+
useHotkeys(
144+
"Escape",
145+
() => {
146+
console.log("I will run in the capturing phase");
147+
},
148+
{
149+
eventListenerOptions: {
150+
capture: true,
151+
},
90152
}
91-
});
153+
);
92154
```
93155

94156
## Call Signature
@@ -97,7 +159,12 @@ useHotkeys('*', event => {
97159
useHotkeys(
98160
hotkeys: string | string[],
99161
callback: (event: KeyboardEvent) => void,
100-
eventListenerOptions?: boolean | AddEventListenerOptions
162+
options?: {
163+
enabled?: boolean;
164+
enableOnContentEditable?: boolean;
165+
ignoredElementWhitelist?: ("INPUT" | "TEXTAREA")[];
166+
eventListenerOptions?: AddEventListenerOptions;
167+
}
101168
) => void;
102169
```
103170

src/__tests__/index.test.tsx

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,40 @@
11
import * as React from "react";
2-
import { render } from "@testing-library/react";
2+
import { screen, render, fireEvent } from "@testing-library/react";
33
import useHotkeys from "../index";
44

55
import fireKeydownEvent from "./helpers/fireKeydownEvent";
66

77
interface ComponentProps {
88
hotkeys: string | string[];
99
callback: jest.Mock<unknown, [unknown]>;
10+
options?: Record<string, unknown>;
1011
}
1112

13+
const Component = (props: ComponentProps) => {
14+
useHotkeys(
15+
props.hotkeys,
16+
(event) => {
17+
props.callback(event);
18+
},
19+
props.options
20+
);
21+
22+
return (
23+
<>
24+
<input type="text" data-testid="INPUT" />
25+
<textarea data-testid="TEXTAREA"></textarea>
26+
</>
27+
);
28+
};
29+
1230
const setup = (
1331
hotkeys: ComponentProps["hotkeys"],
14-
callback: ComponentProps["callback"]
32+
callback: ComponentProps["callback"],
33+
options?: ComponentProps["options"]
1534
) => {
16-
const Component = (props: ComponentProps) => {
17-
useHotkeys(props.hotkeys, (event) => {
18-
props.callback(event);
19-
});
20-
21-
return null;
22-
};
23-
24-
return render(<Component hotkeys={hotkeys} callback={callback} />);
35+
return render(
36+
<Component hotkeys={hotkeys} callback={callback} options={options} />
37+
);
2538
};
2639

2740
describe("useHotkeys: basic", () => {
@@ -501,3 +514,77 @@ describe("useHotkeys: escape hatch", () => {
501514
expect(spy).toHaveBeenCalledTimes(5);
502515
});
503516
});
517+
518+
/**
519+
* Note: `enableOnContentEditable` is not possible to test in JSDOM
520+
* since it lacks support for the `contenteditable` attribute.
521+
*/
522+
describe("useHotkeys: options", () => {
523+
describe("enabled", () => {
524+
test("callback should not be called when enabled is false", () => {
525+
const spy = jest.fn();
526+
527+
setup("z", spy, { enabled: false });
528+
expect(spy).toHaveBeenCalledTimes(0);
529+
530+
fireKeydownEvent("z");
531+
expect(spy).toHaveBeenCalledTimes(0);
532+
});
533+
534+
test("callback should be called when enabled is true", () => {
535+
const spy = jest.fn();
536+
537+
setup("z", spy, { enabled: true });
538+
expect(spy).toHaveBeenCalledTimes(0);
539+
540+
fireKeydownEvent("z");
541+
expect(spy).toHaveBeenCalledTimes(1);
542+
});
543+
544+
test("callback should be called when enabled is not defined", () => {
545+
const spy = jest.fn();
546+
547+
setup("z", spy);
548+
expect(spy).toHaveBeenCalledTimes(0);
549+
550+
fireKeydownEvent("z");
551+
expect(spy).toHaveBeenCalledTimes(1);
552+
});
553+
});
554+
555+
describe("ignoredElementWhitelist", () => {
556+
test.each(["INPUT", "TEXTAREA"])(
557+
"callback should not be called when keydown event originates from %s element",
558+
(nodeName) => {
559+
const spy = jest.fn();
560+
561+
setup("z", spy);
562+
expect(spy).toHaveBeenCalledTimes(0);
563+
564+
fireEvent.keyDown(screen.getByTestId(nodeName), { key: "z" });
565+
expect(spy).toHaveBeenCalledTimes(0);
566+
567+
// Should be called when the event does not originate from a restricted element
568+
fireKeydownEvent("z");
569+
expect(spy).toHaveBeenCalledTimes(1);
570+
}
571+
);
572+
573+
test.each(["INPUT", "TEXTAREA"])(
574+
"callback should be called when keydown event originates from %s element if it's specified in the ignoredElementWhitelist",
575+
(nodeName) => {
576+
const spy = jest.fn();
577+
578+
setup("z", spy, { ignoredElementWhitelist: [nodeName] });
579+
expect(spy).toHaveBeenCalledTimes(0);
580+
581+
fireEvent.keyDown(screen.getByTestId(nodeName), { key: "z" });
582+
expect(spy).toHaveBeenCalledTimes(1);
583+
584+
// Should also be called when event does not originate from a restricted element
585+
fireKeydownEvent("z");
586+
expect(spy).toHaveBeenCalledTimes(2);
587+
}
588+
);
589+
});
590+
});

src/helpers/ignoreKeydownEvent.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import modifierKeyPressed from "./modifierKeyPressed";
2+
3+
const ELEMENTS_TO_IGNORE = ["INPUT", "TEXTAREA"] as const;
4+
5+
export type ElementsToIgnore = typeof ELEMENTS_TO_IGNORE[number];
6+
7+
export default (
8+
event: KeyboardEvent,
9+
enableOnContentEditable?: boolean,
10+
ignoredElementWhitelist?: ElementsToIgnore[]
11+
) => {
12+
/**
13+
* Chrome autocomplete triggers `keydown` event but `event.key` will be undefined.
14+
* See https://bugs.chromium.org/p/chromium/issues/detail?id=581537.
15+
*/
16+
if (!event.key && !modifierKeyPressed(event)) {
17+
return true;
18+
}
19+
20+
const target = (event.target || {}) as HTMLElement;
21+
22+
/**
23+
* Ignore the keydown event if it originates from a `contenteditable`
24+
* element, unless the user has overridden this behaviour.
25+
*/
26+
if (target.isContentEditable && !enableOnContentEditable) {
27+
return true;
28+
}
29+
30+
/**
31+
* Ignore the keydown event if it originates from one of the
32+
* `ELEMENTS_TO_IGNORE`, unless the user has whitelisted it.
33+
*/
34+
return (
35+
ELEMENTS_TO_IGNORE.includes(target.nodeName as ElementsToIgnore) &&
36+
!ignoredElementWhitelist?.includes(target.nodeName as ElementsToIgnore)
37+
);
38+
};

0 commit comments

Comments
 (0)