├── .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 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![npm](https://img.shields.io/npm/v/use-keyboard-list-navigation)](https://www.npmjs.com/package/use-keyboard-list-navigation) [![Build Status](https://travis-ci.com/dzucconi/use-keyboard-list-navigation.svg?branch=master)](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 | --------------------------------------------------------------------------------