├── .gitignore
├── .travis.yml
├── .vscode
└── settings.json
├── .yarn
└── releases
│ └── yarn-1.21.1.js
├── .yarnrc.yml
├── LICENSE
├── README.md
├── jest.config.js
├── package.json
├── src
├── index.ts
├── useKeyboardListNavigation.spec.ts
└── useKeyboardListNavigation.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .env
3 | node_modules
4 | dist
5 | yarn-error.log
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - 8
5 |
6 | branches:
7 | only:
8 | - master
9 |
10 | jobs:
11 | include:
12 | - stage: release
13 | node_js: lts/*
14 | script: skip
15 | deploy:
16 | provider: script
17 | skip_cleanup: true
18 | script:
19 | - npx semantic-release
20 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true
3 | }
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | yarnPath: .yarn/releases/yarn-1.21.1.js
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Damon Zucconi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # use-keyboard-list-navigation
2 |
3 | [](https://github.com/semantic-release/semantic-release) [](https://www.npmjs.com/package/use-keyboard-list-navigation) [](https://app.travis-ci.com/github/dzucconi/use-keyboard-list-navigation)
4 |
5 | ## What is this?
6 |
7 | A React hook to navigate through lists with your keyboard.
8 |
9 | ## Installation
10 |
11 | ```bash
12 | yarn add use-keyboard-list-navigation
13 | ```
14 |
15 | ## Usage
16 |
17 | ```javascript
18 | import React from "react";
19 | import { useKeyboardListNavigation } from "use-keyboard-list-navigation";
20 |
21 | const App: React.FC = () => {
22 | const list = ["one", "two", "three"];
23 |
24 | const { index, cursor, interactive, selected } = useKeyboardListNavigation({
25 | list,
26 | onEnter: console.log.bind(console),
27 | });
28 |
29 | return (
30 |
31 | {JSON.stringify({ index, cursor, interactive, selected })}
32 |
33 | );
34 | };
35 | ```
36 |
37 | ## Interface
38 |
39 | ```typescript
40 | type UseKeyboardListNavigationProps = {
41 | ref?: React.MutableRefObject | undefined;
42 | list: T[];
43 | waitForInteractive?: boolean | undefined;
44 | defaultValue?: T | undefined;
45 | bindAxis?: "vertical" | "horizontal" | "both" | undefined;
46 | onEnter({
47 | event,
48 | element,
49 | state,
50 | index,
51 | }: {
52 | event: KeyboardEvent;
53 | element: T;
54 | state: UseKeyboardListNavigationState;
55 | index: number;
56 | }): void;
57 | extractValue?(item: T): string;
58 | };
59 |
60 | const useKeyboardListNavigation: ({
61 | ref,
62 | list,
63 | waitForInteractive,
64 | defaultValue,
65 | onEnter,
66 | extractValue,
67 | }: UseKeyboardListNavigationProps) => {
68 | index: number;
69 | selected: T;
70 | cursor: number;
71 | length: number;
72 | interactive: boolean;
73 | };
74 | ```
75 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: "ts-jest",
3 | testEnvironment: "jsdom",
4 | testPathIgnorePatterns: ["/dist/"],
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-keyboard-list-navigation",
3 | "author": "Damon Zucconi",
4 | "main": "dist/index.js",
5 | "types": "dist/index.d.ts",
6 | "version": "0.0.0-development",
7 | "license": "MIT",
8 | "files": [
9 | "dist"
10 | ],
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/dzucconi/use-keyboard-list-navigation.git"
14 | },
15 | "scripts": {
16 | "build": "yarn clean && tsc -p .",
17 | "clean": "rm -rf dist",
18 | "prepublishOnly": "yarn build",
19 | "semantic-release": "semantic-release",
20 | "test": "jest"
21 | },
22 | "peerDependencies": {
23 | "react": ">=16.8.0",
24 | "react-dom": ">=16.8.0"
25 | },
26 | "devDependencies": {
27 | "@testing-library/react": "11.2.7",
28 | "@testing-library/react-hooks": "5.1.3",
29 | "@types/jest": "26.0.24",
30 | "@types/react": "17.0.47",
31 | "@types/react-dom": "17.0.17",
32 | "@types/testing-library__react-hooks": "4.0.0",
33 | "cz-conventional-changelog": "3.3.0",
34 | "jest": "26.6.3",
35 | "react": "17.0.2",
36 | "react-dom": "17.0.2",
37 | "react-test-renderer": "17.0.2",
38 | "semantic-release": "17.4.7",
39 | "ts-jest": "26.5.6",
40 | "typescript": "4.7.4"
41 | },
42 | "resolutions": {
43 | "@types/react": "17.0.47"
44 | },
45 | "config": {
46 | "commitizen": {
47 | "path": "cz-conventional-changelog"
48 | }
49 | },
50 | "dependencies": {
51 | "map-cursor-to-max": "^1.0.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useKeyboardListNavigation";
2 |
--------------------------------------------------------------------------------
/src/useKeyboardListNavigation.spec.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from "@testing-library/react-hooks";
2 | import { fireEvent } from "@testing-library/react";
3 | import { useKeyboardListNavigation } from "./useKeyboardListNavigation";
4 | import { useRef } from "react";
5 |
6 | jest.useFakeTimers();
7 |
8 | describe("useKeyboardListNavigation", () => {
9 | const list = ["first", "second", "third", "fourth"];
10 | const noop = () => {};
11 |
12 | it("selects the first element", () => {
13 | const { result } = renderHook(() =>
14 | useKeyboardListNavigation({ list, onEnter: noop })
15 | );
16 |
17 | expect(result.current.cursor).toBe(0);
18 | expect(result.current.index).toBe(0);
19 | expect(result.current.selected).toBe("first");
20 | });
21 |
22 | it("selects the defaultValue by default", () => {
23 | const { result } = renderHook(() =>
24 | useKeyboardListNavigation({ list, defaultValue: "second", onEnter: noop })
25 | );
26 |
27 | expect(result.current.cursor).toBe(1);
28 | expect(result.current.index).toBe(1);
29 | expect(result.current.selected).toBe("second");
30 | });
31 |
32 | it('selects the second element when the "ArrowDown" key is pressed', () => {
33 | const { result } = renderHook(() =>
34 | useKeyboardListNavigation({ list, onEnter: noop })
35 | );
36 |
37 | expect(result.current.cursor).toBe(0);
38 | expect(result.current.index).toBe(0);
39 | expect(result.current.selected).toBe("first");
40 |
41 | act(() => {
42 | fireEvent.keyDown(window, { key: "ArrowDown" });
43 | });
44 |
45 | expect(result.current.cursor).toBe(1);
46 | expect(result.current.index).toBe(1);
47 | expect(result.current.selected).toBe("second");
48 | });
49 |
50 | it('selects the third element when the "ArrowDown" key is pressed twice', () => {
51 | const { result } = renderHook(() =>
52 | useKeyboardListNavigation({ list, onEnter: noop })
53 | );
54 |
55 | expect(result.current.cursor).toBe(0);
56 | expect(result.current.index).toBe(0);
57 | expect(result.current.selected).toBe("first");
58 |
59 | act(() => {
60 | fireEvent.keyDown(window, { key: "ArrowDown" });
61 | fireEvent.keyDown(window, { key: "ArrowDown" });
62 | });
63 |
64 | expect(result.current.cursor).toBe(2);
65 | expect(result.current.index).toBe(2);
66 | expect(result.current.selected).toBe("third");
67 | });
68 |
69 | it('selects the last element when the "ArrowUp" key is pressed initially', () => {
70 | const { result } = renderHook(() =>
71 | useKeyboardListNavigation({ list, onEnter: noop })
72 | );
73 |
74 | expect(result.current.cursor).toBe(0);
75 | expect(result.current.index).toBe(0);
76 | expect(result.current.selected).toBe("first");
77 |
78 | act(() => {
79 | fireEvent.keyDown(window, { key: "ArrowUp" });
80 | });
81 |
82 | expect(result.current.cursor).toBe(-1);
83 | expect(result.current.index).toBe(3);
84 | expect(result.current.selected).toBe("fourth");
85 | });
86 |
87 | it('selects the last element when the "End" key is pressed no matter where in the list one is', () => {
88 | const { result } = renderHook(() =>
89 | useKeyboardListNavigation({ list, onEnter: noop })
90 | );
91 |
92 | expect(result.current.cursor).toBe(0);
93 | expect(result.current.index).toBe(0);
94 | expect(result.current.selected).toBe("first");
95 |
96 | act(() => {
97 | fireEvent.keyDown(window, { key: "End" });
98 | });
99 |
100 | expect(result.current.cursor).toBe(3);
101 | expect(result.current.index).toBe(3);
102 | expect(result.current.selected).toBe("fourth");
103 |
104 | act(() => {
105 | fireEvent.keyDown(window, { key: "ArrowUp" });
106 | fireEvent.keyDown(window, { key: "ArrowUp" });
107 | });
108 |
109 | expect(result.current.cursor).toBe(1);
110 | expect(result.current.index).toBe(1);
111 | expect(result.current.selected).toBe("second");
112 |
113 | act(() => {
114 | fireEvent.keyDown(window, { key: "End" });
115 | });
116 |
117 | expect(result.current.cursor).toBe(3);
118 | expect(result.current.index).toBe(3);
119 | expect(result.current.selected).toBe("fourth");
120 | });
121 |
122 | it('selects the first element when the "Home" key is pressed no matter where in the list one is', () => {
123 | const { result } = renderHook(() =>
124 | useKeyboardListNavigation({
125 | list,
126 | onEnter: noop,
127 | waitForInteractive: true,
128 | })
129 | );
130 |
131 | expect(result.current.cursor).toBe(0);
132 | expect(result.current.index).toBe(-1);
133 | expect(result.current.selected).toBeUndefined();
134 |
135 | act(() => {
136 | fireEvent.keyDown(window, { key: "Home" });
137 | });
138 |
139 | expect(result.current.cursor).toBe(0);
140 | expect(result.current.index).toBe(0);
141 | expect(result.current.selected).toBe("first");
142 |
143 | act(() => {
144 | fireEvent.keyDown(window, { key: "ArrowUp" });
145 | fireEvent.keyDown(window, { key: "ArrowUp" });
146 | });
147 |
148 | expect(result.current.cursor).toBe(-2);
149 | expect(result.current.index).toBe(2);
150 | expect(result.current.selected).toBe("third");
151 |
152 | act(() => {
153 | fireEvent.keyDown(window, { key: "Home" });
154 | });
155 |
156 | expect(result.current.cursor).toBe(0);
157 | expect(result.current.index).toBe(0);
158 | expect(result.current.selected).toBe("first");
159 | });
160 |
161 | it("selects the third item when the t key is pressed; then narrows down into the fifth once more is typed", () => {
162 | const { result } = renderHook(() =>
163 | useKeyboardListNavigation({
164 | list: ["first", "second", "third", "fourth", "thirteenth"],
165 | onEnter: noop,
166 | })
167 | );
168 |
169 | expect(result.current.cursor).toBe(0);
170 | expect(result.current.index).toBe(0);
171 | expect(result.current.selected).toBe("first");
172 |
173 | act(() => {
174 | fireEvent.keyDown(window, { key: "t" });
175 | });
176 |
177 | expect(result.current.cursor).toBe(2);
178 | expect(result.current.index).toBe(2);
179 | expect(result.current.selected).toBe("third");
180 |
181 | act(() => {
182 | fireEvent.keyDown(window, { key: "h" });
183 | fireEvent.keyDown(window, { key: "i" });
184 | fireEvent.keyDown(window, { key: "r" });
185 | });
186 |
187 | expect(result.current.cursor).toBe(2);
188 | expect(result.current.index).toBe(2);
189 | expect(result.current.selected).toBe("third");
190 |
191 | act(() => {
192 | fireEvent.keyDown(window, { key: "t" });
193 | });
194 |
195 | expect(result.current.cursor).toBe(4);
196 | expect(result.current.index).toBe(4);
197 | expect(result.current.selected).toBe("thirteenth");
198 | });
199 |
200 | it("selects the third item when the t key is pressed; after one second, selects the second item when the s key is pressed", () => {
201 | const { result } = renderHook(() =>
202 | useKeyboardListNavigation({
203 | list,
204 | onEnter: noop,
205 | })
206 | );
207 |
208 | expect(result.current.cursor).toBe(0);
209 | expect(result.current.index).toBe(0);
210 | expect(result.current.selected).toBe("first");
211 |
212 | act(() => {
213 | fireEvent.keyDown(window, { key: "t" });
214 | });
215 |
216 | jest.runAllTimers();
217 |
218 | expect(result.current.cursor).toBe(2);
219 | expect(result.current.index).toBe(2);
220 | expect(result.current.selected).toBe("third");
221 |
222 | act(() => {
223 | fireEvent.keyDown(window, { key: "s" });
224 | });
225 |
226 | expect(result.current.cursor).toBe(1);
227 | expect(result.current.index).toBe(1);
228 | expect(result.current.selected).toBe("second");
229 | });
230 |
231 | it('calls `onEnter` with the selected items when the "Enter" key is pressed', () => {
232 | const onEnter = jest.fn();
233 | renderHook(() => useKeyboardListNavigation({ list, onEnter }));
234 |
235 | act(() => {
236 | fireEvent.keyDown(window, { key: "Enter" });
237 | });
238 |
239 | expect(onEnter).toBeCalledTimes(1);
240 | expect(onEnter).toHaveBeenCalledWith({
241 | element: "first",
242 | event: expect.anything(),
243 | index: 0,
244 | state: { cursor: 0, interactive: false, length: 4 },
245 | });
246 |
247 | act(() => {
248 | fireEvent.keyDown(window, { key: "ArrowDown" });
249 | fireEvent.keyDown(window, { key: "ArrowDown" });
250 | });
251 |
252 | act(() => {
253 | fireEvent.keyDown(window, { key: "Enter" });
254 | });
255 |
256 | expect(onEnter).toBeCalledTimes(2);
257 | expect(onEnter).toHaveBeenLastCalledWith({
258 | element: "third",
259 | event: expect.anything(),
260 | index: 2,
261 | state: { cursor: 2, interactive: true, length: 4 },
262 | });
263 | });
264 |
265 | it("supports a focusable ref", () => {
266 | const div = document.createElement("div");
267 | const { result } = renderHook(() => {
268 | const ref = useRef(div);
269 | return useKeyboardListNavigation({ list, ref, onEnter: noop });
270 | });
271 |
272 | expect(result.current.cursor).toBe(0);
273 |
274 | act(() => {
275 | fireEvent.keyDown(window, { key: "ArrowDown" });
276 | });
277 |
278 | expect(result.current.cursor).toBe(0);
279 |
280 | act(() => {
281 | fireEvent.keyDown(div, { key: "ArrowDown" });
282 | });
283 |
284 | expect(result.current.cursor).toBe(1);
285 | });
286 |
287 | describe("waitForInteractive", () => {
288 | it("returns an invalid index until some interaction takes place", () => {
289 | const { result } = renderHook(() =>
290 | useKeyboardListNavigation({
291 | list,
292 | onEnter: noop,
293 | waitForInteractive: true,
294 | })
295 | );
296 |
297 | expect(result.current.cursor).toBe(0);
298 | expect(result.current.index).toBe(-1);
299 | expect(result.current.selected).toBeUndefined();
300 |
301 | act(() => {
302 | fireEvent.keyDown(window, { key: "ArrowDown" });
303 | });
304 |
305 | expect(result.current.cursor).toBe(0);
306 | expect(result.current.index).toBe(0);
307 | expect(result.current.selected).toEqual("first");
308 | });
309 |
310 | it("does not trigger `onEnter` if no interaction has taken place", () => {
311 | const onEnter = jest.fn();
312 | const { result } = renderHook(() =>
313 | useKeyboardListNavigation({ list, onEnter, waitForInteractive: true })
314 | );
315 |
316 | act(() => {
317 | fireEvent.keyDown(window, { key: "Enter" });
318 | });
319 |
320 | expect(onEnter).toBeCalledTimes(0);
321 |
322 | act(() => {
323 | fireEvent.keyDown(window, { key: "ArrowDown" });
324 | });
325 |
326 | act(() => {
327 | fireEvent.keyDown(window, { key: "Enter" });
328 | });
329 |
330 | expect(onEnter).toBeCalledTimes(1);
331 | });
332 | });
333 |
334 | describe("bindAxis", () => {
335 | it("supports the horizontal axis", () => {
336 | const { result } = renderHook(() =>
337 | useKeyboardListNavigation({
338 | list,
339 | onEnter: noop,
340 | bindAxis: "horizontal",
341 | })
342 | );
343 |
344 | expect(result.current.cursor).toBe(0);
345 | expect(result.current.index).toBe(0);
346 | expect(result.current.selected).toBe("first");
347 |
348 | act(() => {
349 | fireEvent.keyDown(window, { key: "ArrowRight" });
350 | });
351 |
352 | expect(result.current.cursor).toBe(1);
353 | expect(result.current.index).toBe(1);
354 | expect(result.current.selected).toBe("second");
355 |
356 | act(() => {
357 | fireEvent.keyDown(window, { key: "ArrowLeft" });
358 | });
359 |
360 | expect(result.current.cursor).toBe(0);
361 | expect(result.current.index).toBe(0);
362 | expect(result.current.selected).toBe("first");
363 | });
364 |
365 | it("supports both axes", () => {
366 | const { result } = renderHook(() =>
367 | useKeyboardListNavigation({
368 | list,
369 | onEnter: noop,
370 | bindAxis: "both",
371 | })
372 | );
373 |
374 | expect(result.current.cursor).toBe(0);
375 | expect(result.current.index).toBe(0);
376 | expect(result.current.selected).toBe("first");
377 |
378 | act(() => {
379 | fireEvent.keyDown(window, { key: "ArrowRight" });
380 | });
381 |
382 | expect(result.current.cursor).toBe(1);
383 | expect(result.current.index).toBe(1);
384 | expect(result.current.selected).toBe("second");
385 |
386 | act(() => {
387 | fireEvent.keyDown(window, { key: "ArrowUp" });
388 | });
389 |
390 | expect(result.current.cursor).toBe(0);
391 | expect(result.current.index).toBe(0);
392 | expect(result.current.selected).toBe("first");
393 | });
394 | });
395 |
396 | it("exposes a reset function that resets the state", () => {
397 | const { result } = renderHook(() =>
398 | useKeyboardListNavigation({ list, onEnter: noop })
399 | );
400 |
401 | expect(result.current.cursor).toBe(0);
402 | expect(result.current.index).toBe(0);
403 | expect(result.current.selected).toBe("first");
404 |
405 | act(() => {
406 | fireEvent.keyDown(window, { key: "ArrowDown" });
407 | fireEvent.keyDown(window, { key: "ArrowDown" });
408 | });
409 |
410 | expect(result.current.cursor).toBe(2);
411 | expect(result.current.index).toBe(2);
412 | expect(result.current.selected).toBe("third");
413 |
414 | act(() => {
415 | result.current.reset();
416 | });
417 |
418 | expect(result.current.cursor).toBe(0);
419 | expect(result.current.index).toBe(0);
420 | expect(result.current.selected).toBe("first");
421 | });
422 |
423 | it("exposes a set function that allows for updating the cursor manually", () => {
424 | const { result } = renderHook(() =>
425 | useKeyboardListNavigation({ list, onEnter: noop })
426 | );
427 |
428 | expect(result.current.cursor).toBe(0);
429 | expect(result.current.index).toBe(0);
430 | expect(result.current.selected).toBe("first");
431 |
432 | act(() => {
433 | result.current.set({ cursor: 2 });
434 | });
435 |
436 | expect(result.current.cursor).toBe(2);
437 | expect(result.current.index).toBe(2);
438 | expect(result.current.selected).toBe("third");
439 |
440 | act(() => {
441 | result.current.set({ cursor: -1 });
442 | });
443 |
444 | expect(result.current.cursor).toBe(-1);
445 | expect(result.current.index).toBe(3);
446 | expect(result.current.selected).toBe("fourth");
447 | });
448 | });
449 |
--------------------------------------------------------------------------------
/src/useKeyboardListNavigation.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useReducer, useRef } from "react";
2 | import { mapCursorToMax } from "map-cursor-to-max";
3 |
4 | export type UseKeyboardListNavigationAction =
5 | | { type: "RESET" }
6 | | { type: "INTERACT" }
7 | | { type: "PREV" }
8 | | { type: "NEXT" }
9 | | { type: "FIRST" }
10 | | { type: "LAST" }
11 | | { type: "SET"; payload: { cursor?: number; interactive?: boolean } };
12 |
13 | export type UseKeyboardListNavigationState = {
14 | cursor: number;
15 | length: number;
16 | interactive: boolean;
17 | };
18 |
19 | const reducer =
20 | (defaults: { cursor: number }) =>
21 | (
22 | state: UseKeyboardListNavigationState,
23 | action: UseKeyboardListNavigationAction
24 | ): UseKeyboardListNavigationState => {
25 | switch (action.type) {
26 | case "RESET":
27 | return { ...state, interactive: false, cursor: defaults.cursor };
28 | case "INTERACT":
29 | return { ...state, interactive: true };
30 | case "PREV":
31 | return { ...state, cursor: state.cursor - 1, interactive: true };
32 | case "NEXT":
33 | return { ...state, cursor: state.cursor + 1, interactive: true };
34 | case "FIRST":
35 | return { ...state, cursor: 0, interactive: true };
36 | case "LAST":
37 | return { ...state, cursor: state.length - 1, interactive: true };
38 | case "SET":
39 | return { ...state, ...action.payload };
40 | }
41 | };
42 |
43 | export type UseKeyboardListNavigationProps = {
44 | ref?: React.MutableRefObject;
45 | list: T[];
46 | waitForInteractive?: boolean;
47 | defaultValue?: T;
48 | bindAxis?: "vertical" | "horizontal" | "both";
49 | onEnter({
50 | event,
51 | element,
52 | state,
53 | index,
54 | }: {
55 | event: KeyboardEvent;
56 | element: T;
57 | state: UseKeyboardListNavigationState;
58 | index: number;
59 | }): void;
60 | extractValue?(item: T): string;
61 | };
62 |
63 | const IDLE_TIMEOUT_MS = 1000;
64 |
65 | export const useKeyboardListNavigation = ({
66 | ref,
67 | list,
68 | waitForInteractive = false,
69 | defaultValue,
70 | bindAxis = "vertical",
71 | onEnter,
72 | extractValue = (item) => (typeof item === "string" ? item.toLowerCase() : ""),
73 | }: UseKeyboardListNavigationProps) => {
74 | const defaultCursor = defaultValue ? list.indexOf(defaultValue) : 0;
75 | const [state, dispatch] = useReducer(reducer({ cursor: defaultCursor }), {
76 | cursor: defaultCursor,
77 | length: list.length,
78 | interactive: false,
79 | });
80 |
81 | const searchTerm = useRef("");
82 | const idleTimeout = useRef | null>(null);
83 |
84 | const index = mapCursorToMax(state.cursor, list.length);
85 |
86 | const handleKeyDown = useCallback(
87 | (event: KeyboardEvent) => {
88 | const handleUp = () => {
89 | event.preventDefault();
90 | return dispatch({ type: "PREV" });
91 | };
92 |
93 | const handleDown = () => {
94 | event.preventDefault();
95 | if (waitForInteractive && !state.interactive) {
96 | return dispatch({ type: "INTERACT" });
97 | }
98 | return dispatch({ type: "NEXT" });
99 | };
100 |
101 | switch (event.key) {
102 | case "ArrowUp": {
103 | if (bindAxis === "horizontal") return;
104 | return handleUp();
105 | }
106 | case "ArrowDown": {
107 | if (bindAxis === "horizontal") return;
108 | return handleDown();
109 | }
110 | case "ArrowLeft": {
111 | if (bindAxis === "vertical") return;
112 | return handleUp();
113 | }
114 | case "ArrowRight": {
115 | if (bindAxis === "vertical") return;
116 | return handleDown();
117 | }
118 | case "Enter": {
119 | if (waitForInteractive && !state.interactive) break;
120 | return onEnter({ event, element: list[index], state, index });
121 | }
122 | case "Home": {
123 | return dispatch({ type: "FIRST" });
124 | }
125 | case "End": {
126 | return dispatch({ type: "LAST" });
127 | }
128 | default:
129 | // Set focus based on search term
130 | if (/^[a-z0-9_-]$/i.test(event.key)) {
131 | searchTerm.current = `${searchTerm.current}${event.key}`;
132 |
133 | const node = list.find((item) =>
134 | extractValue(item).startsWith(searchTerm.current)
135 | );
136 |
137 | if (node) {
138 | dispatch({
139 | type: "SET",
140 | payload: { cursor: list.indexOf(node) },
141 | });
142 | }
143 |
144 | if (idleTimeout.current) clearTimeout(idleTimeout.current);
145 |
146 | idleTimeout.current = setTimeout(() => {
147 | searchTerm.current = "";
148 | }, IDLE_TIMEOUT_MS);
149 | }
150 |
151 | break;
152 | }
153 | },
154 | [index, list, onEnter, state, waitForInteractive, extractValue]
155 | );
156 |
157 | useEffect(() => {
158 | const el = ref?.current ?? window;
159 | el.addEventListener("keydown", handleKeyDown);
160 | return () => {
161 | el.removeEventListener("keydown", handleKeyDown);
162 | };
163 | }, [handleKeyDown, ref, idleTimeout]);
164 |
165 | useEffect(() => dispatch({ type: "RESET" }), [list.length]);
166 |
167 | const interactiveIndex =
168 | waitForInteractive && !state.interactive ? -1 : index;
169 |
170 | const reset = useCallback(() => {
171 | dispatch({ type: "RESET" });
172 | }, []);
173 |
174 | const set = useCallback(
175 | (payload: { cursor?: number; interactive?: boolean }) => {
176 | dispatch({ type: "SET", payload });
177 | },
178 | []
179 | );
180 |
181 | return {
182 | ...state,
183 | index: interactiveIndex,
184 | selected: list[interactiveIndex],
185 | reset,
186 | set,
187 | };
188 | };
189 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "lib": ["dom", "es6", "es2016", "es2017"],
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "declaration": true,
9 | "strictFunctionTypes": false,
10 | "outDir": "./dist"
11 | },
12 | "include": ["src/**/*.ts"],
13 | "exclude": ["node_modules"]
14 | }
15 |
--------------------------------------------------------------------------------