├── .prettierignore ├── .eslintignore ├── .gitignore ├── tsconfig.json ├── src ├── shared │ ├── enums.ts │ ├── formatter.ts │ ├── depsAdapter.ts │ └── interfaces.ts ├── logger.ts ├── navigateTo.ts └── index.ts ├── scripts ├── createImportPkgJson.mjs ├── createRequirePkgJson.mjs ├── switchDynamicImportToRequire.mjs ├── shared │ └── replaceInFileAdapter.mjs └── appendJsFileExtToEsmImports.mjs ├── jest.config.ts ├── tsconfig-EsModules.json ├── tsconfig-CommonJs.json ├── .gitpod.yml ├── .eslintrc.json ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── LICENSE ├── test ├── components │ ├── Hysteretic Line.ts │ ├── shared │ │ └── buildingBlocks.ts │ └── 3x3x3 Cube.ts └── index.test.ts ├── README.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .** 4 | LICENSE -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | LICENSE 4 | README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .husky 3 | .eslintcache 4 | dist/ 5 | tmp/ -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "declaration": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/enums.ts: -------------------------------------------------------------------------------- 1 | const enum LogVerbosity { 2 | Off = "Off", 3 | Verbose = "Verbose", 4 | } 5 | 6 | export { LogVerbosity }; 7 | -------------------------------------------------------------------------------- /scripts/createImportPkgJson.mjs: -------------------------------------------------------------------------------- 1 | import jsonfile from "jsonfile"; 2 | 3 | jsonfile.writeFile("./dist/import/package.json", { 4 | type: "module", 5 | }); 6 | -------------------------------------------------------------------------------- /scripts/createRequirePkgJson.mjs: -------------------------------------------------------------------------------- 1 | import jsonfile from "jsonfile"; 2 | 3 | jsonfile.writeFile("./dist/require/package.json", { 4 | type: "commonjs", 5 | }); 6 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types"; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: "ts-jest", 5 | testEnvironment: "jsdom", 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /tsconfig-EsModules.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2017", 5 | "module": "ESNext", 6 | "outDir": "dist/import" 7 | }, 8 | "include": ["src/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig-CommonJs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES5", 5 | "downlevelIteration": true, 6 | "outDir": "dist/require" 7 | }, 8 | "include": ["tmp/require/src/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/formatter.ts: -------------------------------------------------------------------------------- 1 | import { prettyDOM } from "./depsAdapter"; 2 | 3 | const displayDOM = 4 | (prettyDOM as (node: Element) => string) || 5 | function (node: Element): string { 6 | return node.outerHTML; 7 | }; 8 | 9 | export { displayDOM }; 10 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | tasks: 6 | - init: npm install && npm run build 7 | 8 | 9 | -------------------------------------------------------------------------------- /scripts/switchDynamicImportToRequire.mjs: -------------------------------------------------------------------------------- 1 | import { replaceAdapter } from "./shared/replaceInFileAdapter.mjs"; 2 | 3 | (async function switchDynamicImportToRequire() { 4 | await replaceAdapter({ 5 | files: "tmp/require/**/*.ts", 6 | from: /await import\(/g, 7 | to: "require(", 8 | }); 9 | })(); 10 | -------------------------------------------------------------------------------- /scripts/shared/replaceInFileAdapter.mjs: -------------------------------------------------------------------------------- 1 | import replace from "replace-in-file"; 2 | 3 | async function replaceAdapter(options) { 4 | try { 5 | const results = await replace(options); 6 | console.log("Replacement results:", results); 7 | } catch (error) { 8 | console.error("Error occurred:", error); 9 | 10 | throw error; 11 | } 12 | } 13 | 14 | export { replaceAdapter }; 15 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { ILogger } from "./shared/interfaces"; 2 | 3 | import { displayDOM } from "./shared/formatter"; 4 | 5 | function createDefaultLogger(): ILogger { 6 | return { 7 | capturePath(path) { 8 | console.debug(path); 9 | }, 10 | captureCurrentElement(curEl) { 11 | console.debug(`${displayDOM(curEl)}`); 12 | }, 13 | }; 14 | } 15 | 16 | export { createDefaultLogger }; 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "jest"], 5 | "extends": [ 6 | "plugin:jest/recommended", 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "overrides": [ 12 | { 13 | "files": ["./scripts/**/*.{mjs}"], 14 | "extends": [ 15 | "eslint:recommended", 16 | "prettier" 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | ci: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Use Node.js 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: '12.x' 25 | - run: npm ci 26 | - run: npm run continuous-integration -------------------------------------------------------------------------------- /scripts/appendJsFileExtToEsmImports.mjs: -------------------------------------------------------------------------------- 1 | import { replaceAdapter } from "./shared/replaceInFileAdapter.mjs"; 2 | 3 | (async () => { 4 | await replaceAdapter({ 5 | files: "dist/**/*.js", 6 | from: /import {.*} from ".*";/g, 7 | to: (match) => { 8 | if (!match.includes(".js")) { 9 | const endOfImportStatement = match.indexOf('";'); 10 | 11 | return ( 12 | match.slice(0, endOfImportStatement) + 13 | ".js" + 14 | match.slice(endOfImportStatement) 15 | ); 16 | } else { 17 | return match; 18 | } 19 | }, 20 | }); 21 | })(); 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: '12.x' 21 | - run: npm ci 22 | - run: npm run build # Generate artifacts for distribution 23 | - run: npm run release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /src/shared/depsAdapter.ts: -------------------------------------------------------------------------------- 1 | import type userEvent from "@testing-library/user-event"; 2 | import type { prettyDOM } from "@testing-library/dom"; 3 | let userEventInstance: typeof userEvent = undefined; 4 | let prettyDOMInstance: typeof prettyDOM = undefined; 5 | 6 | //FYI - try harder at combining these into a single Promise if it becomes a bottleneck (be aware of breaking the "require" version when doing so) 7 | try { 8 | const result: any = await import("@testing-library/user-event"); 9 | //.default is used in the CJS build when directly getting back the default export from a call to "require" 10 | //.default.default is used in the ESM build when the "import" call wraps the underlying "require" call output with its own default export wrapping 11 | userEventInstance = result.default.default ?? result.default; 12 | } catch (e) { 13 | console.error(e); 14 | console.warn( 15 | "Unable to find @testing-library/user-event. Proceeding without it." 16 | ); //TODO - consider making these customizable via the log-level or a custom logger 17 | } 18 | 19 | export { userEventInstance as userEvent, prettyDOMInstance as prettyDOM }; 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Grunet 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 | -------------------------------------------------------------------------------- /test/components/Hysteretic Line.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAllTerminalDivs, 3 | addFocusabilityRemovalHandlers, 4 | findElementFromTextContent, 5 | addKeypressHandlers, 6 | moveFocusToEl, 7 | } from "./shared/buildingBlocks"; 8 | 9 | /** 10 | * Creates a 1d line of elements, 11 | * where up arrowing moves 2 units up, and down arrowing 1 unit down 12 | * @param container an element to inject the component content into 13 | */ 14 | function render(container: Element): void { 15 | container.innerHTML = ` 16 |
17 |
5
18 |
4
19 |
3
20 |
2
21 |
1
22 |
23 | `; 24 | 25 | setupInteractiveBehavior(container); 26 | } 27 | 28 | function setupInteractiveBehavior(container: Element) { 29 | const lineEls = getAllTerminalDivs(container); 30 | 31 | lineEls.forEach(addFocusabilityRemovalHandlers); 32 | 33 | lineEls.forEach((el) => { 34 | addKeypressHandlers(el, computeWhereToMoveFocus); 35 | }); 36 | 37 | //Initialization 38 | const whereToSetInitialFocus = "1"; 39 | const elToSetInitialFocus = findElementFromTextContent( 40 | lineEls, 41 | whereToSetInitialFocus 42 | ); 43 | 44 | moveFocusToEl(elToSetInitialFocus); 45 | //elToSetInitialFocus.setAttribute("tabindex", "0"); //For debugging in the browser 46 | } 47 | 48 | function computeWhereToMoveFocus(curEl: Element, action: string): Element { 49 | switch (action) { 50 | case "up": 51 | return curEl.previousElementSibling?.previousElementSibling; 52 | case "down": 53 | return curEl.nextElementSibling; 54 | default: 55 | return undefined; 56 | } 57 | } 58 | 59 | export { render }; 60 | -------------------------------------------------------------------------------- /test/components/shared/buildingBlocks.ts: -------------------------------------------------------------------------------- 1 | function getAllTerminalDivs(container: Element): Array { 2 | return Array.from(container.querySelectorAll("div")).filter( 3 | (el) => el.children.length === 0 4 | ); 5 | } 6 | 7 | function addFocusabilityRemovalHandlers(el: Element): void { 8 | ["focusout", "blur"].forEach((eventType) => { 9 | el.addEventListener(eventType, () => { 10 | el.removeAttribute("tabindex"); 11 | }); 12 | }); 13 | } 14 | 15 | function moveFocusToEl(el: Element): void { 16 | if (!el) { 17 | return; 18 | } 19 | 20 | //console.log("Moving focus to", el.textContent); 21 | 22 | el.setAttribute("tabindex", "0"); 23 | (el as HTMLElement).focus(); 24 | } 25 | 26 | function findElementFromTextContent( 27 | possibleEls: Array, 28 | textContent: string 29 | ): Element { 30 | return possibleEls.find((el) => el.textContent.includes(textContent)); 31 | } 32 | 33 | type FocusMoveCalculator = (curEl: Element, action: string) => Element; 34 | 35 | function addKeypressHandlers( 36 | el: Element, 37 | computeWhereToMoveFocus: FocusMoveCalculator 38 | ): void { 39 | el.addEventListener("keydown", (event) => { 40 | const action = getActionFromEvent(event); 41 | 42 | if (action && el.isSameNode(document.activeElement)) { 43 | reactToAction(el, action, computeWhereToMoveFocus); 44 | } 45 | 46 | event.preventDefault(); //For stopping the default tab/shift+tab behavior from changing focus afterwards 47 | }); 48 | } 49 | 50 | function reactToAction( 51 | curEl: Element, 52 | action: string, 53 | computeWhereToMoveFocus: FocusMoveCalculator 54 | ) { 55 | const elToMoveFocusTo = computeWhereToMoveFocus(curEl, action); 56 | 57 | moveFocusToEl(elToMoveFocusTo); 58 | } 59 | 60 | function getActionFromEvent(keyboardEvent) { 61 | const { key, shiftKey: shiftKeyPressed } = keyboardEvent; 62 | 63 | if (key === "Tab") { 64 | return shiftKeyPressed ? "shiftTab" : "tab"; 65 | } 66 | 67 | return arrowKeyToActionMap[key]; //may be undefined 68 | } 69 | 70 | const arrowKeyToActionMap = { 71 | ArrowUp: "up", 72 | ArrowDown: "down", 73 | ArrowRight: "right", 74 | ArrowLeft: "left", 75 | }; 76 | 77 | export { 78 | getAllTerminalDivs, 79 | addFocusabilityRemovalHandlers, 80 | findElementFromTextContent, 81 | addKeypressHandlers, 82 | moveFocusToEl, 83 | }; 84 | -------------------------------------------------------------------------------- /src/shared/interfaces.ts: -------------------------------------------------------------------------------- 1 | interface IKeyboardActions { 2 | navigation: INavigationActions; 3 | activation: IActivationActions; 4 | } 5 | 6 | interface INavigationActions { 7 | tab: () => Promise; 8 | shiftTab: () => Promise; 9 | arrowUp: (element: Element) => Promise; 10 | arrowRight: (element: Element) => Promise; 11 | arrowDown: (element: Element) => Promise; 12 | arrowLeft: (element: Element) => Promise; 13 | } 14 | 15 | /* Hack to export an iterable list of the interface's property names */ 16 | 17 | //None of the following would be necessary were it possible to iterate over the interface's property names directly at runtime, but there's (currently) no way to do that AFAICT (see https://stackoverflow.com/questions/43909566/get-keys-of-a-typescript-interface-as-array-of-strings) 18 | 19 | //This particular workaround of using a duplicate readonly array came from https://stackoverflow.com/a/59420158/11866924 20 | const navigationActionNames = [ 21 | "tab", 22 | "shiftTab", 23 | "arrowUp", 24 | "arrowRight", 25 | "arrowDown", 26 | "arrowLeft", 27 | ] as const; 28 | 29 | //This trick to make sure the 2 "lists of property names as string literal types" are always the same came from https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650 30 | type areDuplicateNavigationActionNamesTheSame = (< 31 | T 32 | >() => T extends keyof INavigationActions ? 1 : 2) extends < 33 | T 34 | >() => T extends typeof navigationActionNames[number] ? 1 : 2 35 | ? true 36 | : false; 37 | const unused1: areDuplicateNavigationActionNamesTheSame = true; //Should cause a type error if they aren't 38 | 39 | /* End hack */ 40 | 41 | interface IActivationActions { 42 | enter: (element: Element) => Promise; 43 | spacebar: (element: Element) => Promise; 44 | } 45 | 46 | /* Hack to export an iterable list of the interface's property names */ 47 | 48 | //None of the following would be necessary were it possible to iterate over the interface's property names directly at runtime, but there's (currently) no way to do that AFAICT (see https://stackoverflow.com/questions/43909566/get-keys-of-a-typescript-interface-as-array-of-strings) 49 | 50 | //This particular workaround of using a duplicate readonly array came from https://stackoverflow.com/a/59420158/11866924 51 | const activationActionNames = ["enter", "spacebar"] as const; 52 | 53 | //This trick to make sure the 2 "lists of property names as string literal types" are always the same came from https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650 54 | type areDuplicateActivationActionNamesTheSame = (< 55 | T 56 | >() => T extends keyof IActivationActions ? 1 : 2) extends < 57 | T 58 | >() => T extends typeof activationActionNames[number] ? 1 : 2 59 | ? true 60 | : false; 61 | const unused2: areDuplicateActivationActionNamesTheSame = true; //Should cause a type error if they aren't 62 | 63 | /* End hack */ 64 | 65 | interface ILogger { 66 | capturePath: (path: Array) => void; 67 | captureCurrentElement: (curEl: Element) => void; 68 | } 69 | 70 | export { 71 | IKeyboardActions, 72 | INavigationActions, 73 | navigationActionNames, 74 | IActivationActions, 75 | activationActionNames, 76 | ILogger, 77 | }; 78 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | //Test helpers 2 | import { expect, test, jest, beforeEach } from "@jest/globals"; 3 | import { getByText } from "@testing-library/dom"; 4 | 5 | //Test components 6 | import { render as render3dCube } from "./components/3x3x3 Cube"; 7 | import { render as renderHystereticLine } from "./components/Hysteretic Line"; 8 | 9 | //Code under test 10 | import keyboardOnlyUserEvent from "../dist/require/index"; 11 | //keyboardOnlyUserEvent.setLogVerbosity("Verbose"); //Uncomment to run all tests with logging on 12 | 13 | const rootContainer = document.body; 14 | 15 | const globalSpies = { 16 | console: { 17 | log: jest.spyOn(console, "log"), 18 | }, 19 | }; 20 | 21 | beforeEach(() => { 22 | rootContainer.innerHTML = ""; //Incomplete workaround for Jest not allowing a way to reset JSDOM between tests in the same file (see https://github.com/facebook/jest/issues/1224) 23 | 24 | jest.clearAllMocks(); //Avoids spies remembering usage data between tests 25 | }); 26 | 27 | test("Starting at one corner of the cube, it can navigate to the other corner", async () => { 28 | //ARRANGE 29 | render3dCube(rootContainer); //Should start focus at the 1,1,1 corner 30 | const targetEl = getByText(rootContainer, "3,3,3"); 31 | 32 | //ACT 33 | await keyboardOnlyUserEvent.navigateTo(targetEl); 34 | 35 | //ASSERT 36 | expect(document.activeElement).toEqual(targetEl); 37 | }); 38 | 39 | test("When given an unfocusable target, it throws an error", async () => { 40 | //ARRANGE 41 | render3dCube(rootContainer); //Focus should stay trapped inside the cube 42 | 43 | const unfocusableTargetEl = document.createElement("div"); 44 | rootContainer.appendChild(unfocusableTargetEl); 45 | 46 | //"ACT" (actual execution happens during the assert phase) 47 | async function navigateToUnfocusableEl() { 48 | await keyboardOnlyUserEvent.navigateTo(unfocusableTargetEl); 49 | } 50 | 51 | //ASSERT 52 | await expect(navigateToUnfocusableEl).rejects 53 | .toThrowErrorMatchingInlineSnapshot(` 54 | "Unable to navigate to 55 | 56 |
57 | 58 | using only the keyboard" 59 | `); 60 | }); 61 | 62 | test("Even when the focus management is hysteretic, it still finds the target", async () => { 63 | //ARRANGE 64 | renderHystereticLine(rootContainer); //Should start focus at the "1" at the bottom 65 | const targetEl = getByText(rootContainer, "2"); 66 | 67 | //ACT 68 | await keyboardOnlyUserEvent.navigateTo(targetEl); 69 | 70 | //ASSERT 71 | expect(document.activeElement).toEqual(targetEl); 72 | }); 73 | 74 | test("When given a keyboard navigable target, it can activate the target's Enter key press handling", async () => { 75 | //ARRANGE 76 | const enterButton = document.createElement("button"); 77 | enterButton.addEventListener("click", () => { 78 | console.log("Enter pressed"); 79 | }); 80 | 81 | rootContainer.appendChild(enterButton); 82 | 83 | //ACT 84 | await keyboardOnlyUserEvent.navigateToAndPressEnter(enterButton); 85 | 86 | //ASSERT 87 | expect(globalSpies.console.log).toHaveBeenCalledWith("Enter pressed"); 88 | }); 89 | 90 | test("When given a keyboard navigable target, it can activate the target's Spacebar press handling", async () => { 91 | //ARRANGE 92 | const spacebarButton = document.createElement("button"); 93 | spacebarButton.addEventListener("click", () => { 94 | console.log("Spacebar pressed"); 95 | }); 96 | 97 | rootContainer.appendChild(spacebarButton); 98 | 99 | //ACT 100 | await keyboardOnlyUserEvent.navigateToAndPressSpacebar(spacebarButton); 101 | 102 | //ASSERT 103 | expect(globalSpies.console.log).toHaveBeenCalledWith("Spacebar pressed"); 104 | }); 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keyboard Testing Library 2 | 3 | An extension of [Testing Library](https://testing-library.com/) that helps simulate keyboard-only users' behaviors 4 | 5 | ## The Problem 6 | 7 | UI test suites are normally written from the perspective of mouse or touch users. This then misses out on the experiences of keyboard users, making their experiences more susceptible to bugs and regressions. 8 | 9 | ## This Solution 10 | 11 | This library composes a few primitive user interactions (e.g. simulated key presses) into higher-level actions (e.g. simulating navigation to an element via the keyboard, and then pressing the Enter key to activate it). 12 | 13 | These actions can then be used to conditionally transform an ordinary click-based test suite into a keyboard-oriented one, with minimal changes. 14 | 15 | ## Installation 16 | 17 | Install the library as a dev dependency. You may notice a few peer dependency warnings crop up when you do that. 18 | 19 | ``` 20 | npm install --save-dev keyboard-testing-library 21 | ``` 22 | 23 | If you don't need to provide your own custom handling for key press simulation to use instead of the default shims Testing Library provides, install the following as dev dependencies 24 | 25 | ``` 26 | npm install --save-dev @testing-library/user-event 27 | npm install --save-dev @testing-library/dom 28 | ``` 29 | 30 | If you do need to customize them, pay attention to [this section](#using-your-own-keypress-simulators) below. 31 | 32 | ## Getting Started 33 | 34 | The details will vary based on the tools you're using, but the core idea is captured in the following [Jest](https://jestjs.io/)-specific snippet, which you'll want to add someplace that will run before each of your test suites (in Jest's case, within a [setupFilesAfterEnv](https://jestjs.io/docs/configuration#setupfilesafterenv-array) script) 35 | 36 | ``` 37 | import userEvent from "@testing-library/user-event"; 38 | import keyboardOnlyUserEvent from "keyboard-testing-library/dist/require"; //The "dist/require" is due to Jest's lack of understanding of package export maps as of this writing (see https://github.com/facebook/jest/issues/9771 for up-to-date info on this topic) 39 | ... 40 | if (process.env["USE_KEYBOARD"]) { 41 | jest 42 | .spyOn(userEvent, "click") 43 | .mockImplementation(keyboardOnlyUserEvent.navigateToAndPressEnter); 44 | } 45 | ``` 46 | 47 | Then before executing the tests, set the environment variable to a truthy value, for example via a npm script using the [cross-env](https://www.npmjs.com/package//cross-env) helper library 48 | 49 | ``` 50 | "scripts": { 51 | ... 52 | "test": "jest", 53 | "test-with-keyboard": "cross-env USE_KEYBOARD=1 npm test" 54 | ... 55 | } 56 | 57 | ``` 58 | 59 | ## Documentation 60 | 61 | You can find more info on the public methods of the library in the index.d.ts file installed alongside the library's source. 62 | 63 | ### Using Your Own Keypress Simulators 64 | 65 | By default, the library will use JS-based shims taken from [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) to simulate keyboard actions a user can take. 66 | 67 | However, if in your environment you have a way to more realistically simulate keyboard actions (e.g. via the Chrome DevTools Protocol) you can inject those via the `injectCustomShims` method on the default `keyboardOnlyUserEvent` export (before any of your tests start). 68 | 69 | ## Caveats 70 | 71 | - If you're using the ESM distribution, you'll need to be on Node 14.8 or higher (or more generally a runtime that supports [top-level await](https://github.com/tc39/proposal-top-level-await)) as the library will use that language feature as part of its initial setup 72 | - All of the library's keyboard simulation methods are marked as async, so if your test suite is simulating clicks with an ordinary method (as @testing-library/userEvent's "click" method did up until v14) the tests will need to be updated to be async and await on the simulated click method call 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keyboard-testing-library", 3 | "version": "0.0.0-development", 4 | "description": "An extension of Testing Library focused on simulating keyboard-only users' behaviors", 5 | "private": false, 6 | "files": [ 7 | "dist" 8 | ], 9 | "main": "dist/import/index.js", 10 | "typings": "dist/import/index.d.ts", 11 | "exports": { 12 | "import": "./dist/import/index.js", 13 | "require": "./dist/require/index.js" 14 | }, 15 | "@comments scripts": [ 16 | "The prettier and eslint wrapper scripts are duplicated in the lint-staged definition near the bottom", 17 | "The --branches argument has to be specified for semantic-release because it doesn't recognize the main branch by default, unlike a 'master' branch" 18 | ], 19 | "scripts": { 20 | "prettier": "prettier **/*", 21 | "format": "npm run prettier -- --write", 22 | "check-format": "npm run prettier -- --list-different", 23 | "eslint": "eslint --cache **/*.ts", 24 | "lint": "npm run eslint -- --fix", 25 | "check-lint": "npm run eslint", 26 | "clean": "del-cli ./dist", 27 | "prebuild-CommonJs": "cpy \"src/**/*\" \"tmp/require\" --parents && node ./scripts/switchDynamicImportToRequire.mjs", 28 | "build-CommonJs": "tsc -p ./tsconfig-CommonJs.json && node ./scripts/createRequirePkgJson.mjs", 29 | "postbuild-CommonJs": "del-cli tmp/", 30 | "build-EsModules": "tsc -p ./tsconfig-EsModules.json && node ./scripts/createImportPkgJson.mjs", 31 | "postbuild-EsModules": "node ./scripts/appendJsFileExtToEsmImports.mjs", 32 | "build-all": "npm run build-CommonJs && npm run build-EsModules", 33 | "build": "npm run clean && npm run build-all", 34 | "test": "jest", 35 | "pre-commit": "lint-staged && npm run build && npm run test", 36 | "commit": "npx cz", 37 | "continuous-integration": "npm run check-format && npm run check-lint && npm run build && npm run test", 38 | "release": "npx semantic-release --branches main beta", 39 | "postinstall": "husky install", 40 | "prepublishOnly": "pinst --disable && npm run build", 41 | "postpublish": "pinst --enable" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/Grunet/keyboard-testing-library.git" 46 | }, 47 | "keywords": [ 48 | "testing", 49 | "keyboard", 50 | "accessibility", 51 | "a11y" 52 | ], 53 | "author": "Grunet", 54 | "license": "MIT", 55 | "bugs": { 56 | "url": "https://github.com/Grunet/keyboard-testing-library/issues" 57 | }, 58 | "homepage": "https://github.com/Grunet/keyboard-testing-library#readme", 59 | "peerDependencies": { 60 | "@testing-library/dom": "^7.x", 61 | "@testing-library/user-event": "^14.x" 62 | }, 63 | "peerDependenciesMeta": { 64 | "@testing-library/dom": { 65 | "optional": true 66 | }, 67 | "@testing-library/user-event": { 68 | "optional": true 69 | } 70 | }, 71 | "devDependencies": { 72 | "@testing-library/dom": "^7.30.3", 73 | "@testing-library/user-event": "^14.0.4", 74 | "@typescript-eslint/eslint-plugin": "^4.15.0", 75 | "@typescript-eslint/parser": "^4.15.0", 76 | "commitizen": "^4.2.3", 77 | "cpy-cli": "^3.1.1", 78 | "cz-conventional-changelog": "^3.3.0", 79 | "del-cli": "^3.0.1", 80 | "eslint": "^7.20.0", 81 | "eslint-config-prettier": "^8.1.0", 82 | "eslint-plugin-jest": "^24.1.4", 83 | "husky": "^5.0.9", 84 | "jest": "^26.6.3", 85 | "jsonfile": "^6.1.0", 86 | "lint-staged": "^10.5.4", 87 | "pinst": "^2.1.4", 88 | "prettier": "2.2.1", 89 | "replace-in-file": "^6.2.0", 90 | "semantic-release": "^17.3.9", 91 | "ts-jest": "^26.5.1", 92 | "ts-node": "^9.1.1", 93 | "typescript": "^4.5.5" 94 | }, 95 | "config": { 96 | "commitizen": { 97 | "path": "./node_modules/cz-conventional-changelog" 98 | } 99 | }, 100 | "husky": { 101 | "hooks": { 102 | "pre-commit": "npm run pre-commit" 103 | } 104 | }, 105 | "lint-staged": { 106 | "**/*": "prettier --write", 107 | "*.ts": "eslint --cache --fix" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/navigateTo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | INavigationActions, 3 | navigationActionNames, 4 | ILogger, 5 | } from "./shared/interfaces"; 6 | 7 | async function navigateTo( 8 | element: Element, 9 | navigationActions: INavigationActions, 10 | logger: ILogger 11 | ): Promise { 12 | const foundElement = await findTarget( 13 | element, 14 | new KeyboardNavigationGraphAdapter(), 15 | navigationActions, 16 | logger 17 | ); 18 | 19 | return foundElement; 20 | } 21 | 22 | async function findTarget( 23 | targetEl: Element, 24 | kngService: KeyboardNavigationGraphAdapter, 25 | navigationActions: INavigationActions, 26 | logger: ILogger 27 | ): Promise { 28 | let curEl = getCurrentlyFocusedEl(); 29 | /*eslint no-constant-condition: ["error", { "checkLoops": false }] -- to allow for the infinite while loop */ 30 | while (true) { 31 | if (targetEl.isSameNode(curEl)) { 32 | return true; 33 | } 34 | 35 | const unexploredPath = kngService.findUnexploredPath(curEl); 36 | if (!unexploredPath) { 37 | //Everything from this point on has already been explored w/o finding the target 38 | return false; 39 | } 40 | logger?.capturePath(unexploredPath); 41 | 42 | const newCurEl = await followPath( 43 | curEl, 44 | unexploredPath, 45 | kngService, 46 | navigationActions 47 | ); 48 | 49 | curEl = newCurEl; 50 | logger?.captureCurrentElement(curEl); 51 | } 52 | } 53 | 54 | async function followPath( 55 | startEl: Element, 56 | pathOfActions: Array, 57 | kngService: KeyboardNavigationGraphAdapter, 58 | navigationActions: INavigationActions 59 | ): Promise { 60 | const elsOnPath: Array = [startEl]; 61 | const remainingPath = [...pathOfActions]; 62 | 63 | while (remainingPath.length > 0) { 64 | const nextAction = remainingPath.shift(); 65 | 66 | const navActionToPerform = navigationActions[nextAction]; 67 | 68 | await navActionToPerform(elsOnPath[0]); 69 | const nextEl = getCurrentlyFocusedEl(); 70 | 71 | elsOnPath.unshift(nextEl); 72 | } 73 | 74 | const lastAction = pathOfActions[pathOfActions.length - 1]; 75 | const [lastEl, secondToLastEl] = elsOnPath; 76 | 77 | if (lastAction && secondToLastEl && lastEl) { 78 | //All but the last action should've been previously recorded 79 | kngService.recordConnection(lastAction, { 80 | from: secondToLastEl, 81 | to: lastEl, 82 | }); 83 | } 84 | 85 | return lastEl; 86 | } 87 | 88 | function getCurrentlyFocusedEl() { 89 | return document.activeElement; 90 | } 91 | 92 | class KeyboardNavigationGraphAdapter { 93 | private __actionPointers: Map< 94 | Element, 95 | Map 96 | > = new Map(); 97 | 98 | recordConnection( 99 | method: keyof INavigationActions, 100 | endpoints: { from: Element; to: Element } 101 | ): void { 102 | const currentPointers = 103 | this.__actionPointers.get(endpoints.from) || new Map(); 104 | currentPointers.set(method, endpoints.to); 105 | 106 | this.__actionPointers.set(endpoints.from, currentPointers); //In case a new Map was made 107 | } 108 | 109 | findUnexploredPath( 110 | rootEl: Element 111 | ): Array | undefined { 112 | return this.__findUnexploredPath(rootEl, new Set()); 113 | } 114 | 115 | private __findUnexploredPath( 116 | rootEl: Element, 117 | alreadyExploredFromEls: Set 118 | ): Array | undefined { 119 | alreadyExploredFromEls.add(rootEl); 120 | 121 | const pointersToAdjacentEls = this.__actionPointers.get(rootEl); 122 | 123 | if (!pointersToAdjacentEls) { 124 | //Nothing has been explored yet so just try something 125 | return ["tab"]; 126 | } 127 | 128 | for (const actionName of navigationActionNames) { 129 | if (!pointersToAdjacentEls.has(actionName)) { 130 | //This direction hasn't been tried before so give it a shot 131 | return [actionName]; 132 | } 133 | } 134 | 135 | for (const [actionName, adjacentEl] of pointersToAdjacentEls) { 136 | if (alreadyExploredFromEls.has(adjacentEl)) { 137 | //This element was already checked earlier, so skip it now (to avoid the infinite recursion) 138 | continue; 139 | } 140 | 141 | const unexploredPathFromAdjacentEl = this.__findUnexploredPath( 142 | adjacentEl, 143 | alreadyExploredFromEls 144 | ); 145 | 146 | if (unexploredPathFromAdjacentEl) { 147 | //This direction has been explored before, but not fully 148 | return [actionName, ...unexploredPathFromAdjacentEl]; 149 | } 150 | } 151 | 152 | //All downstream pathways have been explored 153 | return undefined; 154 | } 155 | } 156 | 157 | export { navigateTo }; 158 | -------------------------------------------------------------------------------- /test/components/3x3x3 Cube.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAllTerminalDivs, 3 | addFocusabilityRemovalHandlers, 4 | findElementFromTextContent, 5 | addKeypressHandlers, 6 | moveFocusToEl, 7 | } from "./shared/buildingBlocks"; 8 | 9 | /** 10 | * Creates a 3d cube of elements, 11 | * where tabbing, up/down arrowing, and left/right arrowing 12 | * move focus 1 unit along each of the dimensions of the cube 13 | * 14 | * @param container an element to inject the component content into 15 | */ 16 | function render(container: HTMLElement): void { 17 | container.innerHTML = ` 18 |
19 |
20 |
21 |
22 | 1,1,1 23 |
24 |
25 | 1,1,2 26 |
27 |
28 | 1,1,3 29 |
30 |
31 |
32 |
33 | 1,2,1 34 |
35 |
36 | 1,2,2 37 |
38 |
39 | 1,2,3 40 |
41 |
42 |
43 |
44 | 1,3,1 45 |
46 |
47 | 1,3,2 48 |
49 |
50 | 1,3,3 51 |
52 |
53 |
54 |
55 |
56 |
57 | 2,1,1 58 |
59 |
60 | 2,1,2 61 |
62 |
63 | 2,1,3 64 |
65 |
66 |
67 |
68 | 2,2,1 69 |
70 |
71 | 2,2,2 72 |
73 |
74 | 2,2,3 75 |
76 |
77 |
78 |
79 | 2,3,1 80 |
81 |
82 | 2,3,2 83 |
84 |
85 | 2,3,3 86 |
87 |
88 |
89 |
90 |
91 |
92 | 3,1,1 93 |
94 |
95 | 3,1,2 96 |
97 |
98 | 3,1,3 99 |
100 |
101 |
102 |
103 | 3,2,1 104 |
105 |
106 | 3,2,2 107 |
108 |
109 | 3,2,3 110 |
111 |
112 |
113 |
114 | 3,3,1 115 |
116 |
117 | 3,3,2 118 |
119 |
120 | 3,3,3 121 |
122 |
123 |
124 |
125 | `; 126 | 127 | setupInteractiveBehavior(container); 128 | } 129 | 130 | function setupInteractiveBehavior(container: HTMLElement) { 131 | const cubeEls = getAllTerminalDivs(container); 132 | 133 | cubeEls.forEach(addFocusabilityRemovalHandlers); 134 | 135 | cubeEls.forEach((el) => { 136 | addKeypressHandlers(el, (curEl, action) => { 137 | return computeWhereToMoveFocus(cubeEls, curEl, action); 138 | }); 139 | }); 140 | 141 | //Initialization 142 | const whereToSetInitialFocus = "1,1,1"; 143 | const elToSetInitialFocus = findElementFromTextContent( 144 | cubeEls, 145 | whereToSetInitialFocus 146 | ); 147 | 148 | moveFocusToEl(elToSetInitialFocus); 149 | //elToSetInitialFocus.setAttribute("tabindex", "0"); //For debugging in the browser 150 | } 151 | 152 | function computeWhereToMoveFocus( 153 | cubeEls: Array, 154 | curEl: Element, 155 | action: string 156 | ) { 157 | const curCoordinates = getCoordsOfEl(curEl); 158 | const coordChangeFn = actionToCoordChangeMap[action]; 159 | 160 | const newCoordinates = { ...curCoordinates }; 161 | coordChangeFn(newCoordinates); //Modification in-place 162 | 163 | const newTextContentToFind = getTextContentFromCoords(newCoordinates); 164 | 165 | const newEl = findElementFromTextContent(cubeEls, newTextContentToFind); 166 | 167 | return newEl.isSameNode(curEl) ? null : newEl; 168 | } 169 | 170 | const actionToCoordChangeMap = { 171 | tab: (coords) => { 172 | coords["tab"] = Math.min(coords["tab"] + 1, 3); 173 | }, 174 | shiftTab: (coords) => { 175 | coords["tab"] = Math.max(coords["tab"] - 1, 1); 176 | }, 177 | up: (coords) => { 178 | coords["vertical"] = Math.min(coords["vertical"] + 1, 3); 179 | }, 180 | down: (coords) => { 181 | coords["vertical"] = Math.max(coords["vertical"] - 1, 1); 182 | }, 183 | right: (coords) => { 184 | coords["horizontal"] = Math.min(coords["horizontal"] + 1, 3); 185 | }, 186 | left: (coords) => { 187 | coords["horizontal"] = Math.max(coords["horizontal"] - 1, 1); 188 | }, 189 | }; 190 | 191 | function getCoordsOfEl(el) { 192 | const [tab, vertical, horizontal] = el.textContent.split(","); 193 | 194 | return { 195 | tab: Number(tab), 196 | vertical: Number(vertical), 197 | horizontal: Number(horizontal), 198 | }; 199 | } 200 | 201 | function getTextContentFromCoords(coordinates) { 202 | const { tab, vertical, horizontal } = coordinates; 203 | 204 | return `${tab},${vertical},${horizontal}`; 205 | } 206 | 207 | export { render }; 208 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IKeyboardActions, 3 | INavigationActions, 4 | navigationActionNames, 5 | IActivationActions, 6 | activationActionNames, 7 | ILogger, 8 | } from "./shared/interfaces"; 9 | import { LogVerbosity } from "./shared/enums"; 10 | 11 | import { displayDOM } from "./shared/formatter"; 12 | import { navigateTo } from "./navigateTo"; 13 | import { createDefaultLogger } from "./logger"; 14 | 15 | //Peer dependencies 16 | import { userEvent } from "./shared/depsAdapter"; 17 | 18 | function __createKeyboardOnlyUserEvent() { 19 | let logger: ILogger = undefined; 20 | 21 | const navigationActions = __getDefaultNavigationActions(); 22 | const activationActions = __getDefaultActivationActions(); 23 | 24 | return { 25 | /** 26 | * Adjusts the verbosity of the logs emitted by the code 27 | * @param logVerbosity The desired verbosity 28 | */ 29 | setLogVerbosity(logVerbosity: `${LogVerbosity}`) { 30 | switch (logVerbosity) { 31 | case LogVerbosity.Off: 32 | logger = undefined; 33 | break; 34 | case LogVerbosity.Verbose: 35 | logger = __createVerboseLogger(); 36 | break; 37 | } 38 | }, 39 | /** 40 | * Allows for you to use your own implementations of each simulated keyboard action, replacing the defaults the library comes with 41 | * @param customKeyboardActions An object whose keys are the names of the specific keyboard actions you want to override, 42 | * and whose values are (async) functions that provide an alternate implementation of that action 43 | */ 44 | injectCustomShims( 45 | customKeyboardActions: 46 | | Partial 47 | | Partial 48 | ) { 49 | for (const navActionName of navigationActionNames) { 50 | const customAction = customKeyboardActions[navActionName]; 51 | if (customAction) { 52 | (navigationActions[navActionName] as any) = customAction; //TS doesn't realize the exact type from the union type on either side of the assignment is going to be the same, hence the "any" crutch 53 | } 54 | } 55 | 56 | for (const activActionName of activationActionNames) { 57 | const customAction = customKeyboardActions[activActionName]; 58 | if (customAction) { 59 | (activationActions[activActionName] as any) = customAction; //TS doesn't realize the exact type from the union type on either side of the assignment is going to be the same, hence the "any" crutch 60 | } 61 | } 62 | }, 63 | /** 64 | * Attempts to navigate to the element only using keyboard actions 65 | * 66 | * Throws an error if it's unable to get to the element 67 | * 68 | * @param element A reference to the DOM element to navigate to 69 | */ 70 | async navigateTo(element: Element) { 71 | await __navigateToAndThrowIfNotFound(element, navigationActions, logger); 72 | }, 73 | /** 74 | * Attempts to navigate to the element only using keyboard actions, then activate it by simulating an Enter key press 75 | * 76 | * Throws an error if it's unable to get to the element 77 | * 78 | * @param element A reference to the DOM element to navigate to 79 | */ 80 | async navigateToAndPressEnter(element: Element) { 81 | await __navigateToAndThrowIfNotFound(element, navigationActions, logger); 82 | 83 | await activationActions.enter(element); 84 | }, 85 | /** 86 | * Attempts to navigate to the element only using keyboard actions, then activate it by simulating a Spacebar press 87 | * 88 | * Throws an error if it's unable to get to the element 89 | * 90 | * @param element A reference to the DOM element to navigate to 91 | */ 92 | async navigateToAndPressSpacebar(element: Element) { 93 | await __navigateToAndThrowIfNotFound(element, navigationActions, logger); 94 | 95 | await activationActions.spacebar(element); 96 | }, 97 | }; 98 | } 99 | 100 | async function __navigateToAndThrowIfNotFound( 101 | element: Element, 102 | navigationActions: INavigationActions, 103 | logger: ILogger 104 | ) { 105 | const foundElement = await navigateTo(element, navigationActions, logger); 106 | 107 | if (!foundElement) { 108 | throw new Error( 109 | `Unable to navigate to \n\n ${displayDOM( 110 | element 111 | )} \n\n using only the keyboard` 112 | ); 113 | } 114 | } 115 | 116 | function __getDefaultNavigationActions(): INavigationActions { 117 | const defaultNavigationActions = { ...testingLibShims.navigation }; 118 | 119 | return __createProxyToDetectUndefinedActions(defaultNavigationActions); 120 | } 121 | 122 | function __getDefaultActivationActions(): IActivationActions { 123 | const defaultActivationActions = { ...testingLibShims.activation }; 124 | 125 | return __createProxyToDetectUndefinedActions(defaultActivationActions); 126 | } 127 | 128 | function __createProxyToDetectUndefinedActions< 129 | T extends Record 130 | >(obj: T) { 131 | return new Proxy(obj, { 132 | get: function (target, prop) { 133 | if (typeof prop !== "string") { 134 | //Avoids downstream issues stemming from TS's extra caution that the property might also be a number or symbol, which it shouldn't be in practice 135 | throw new Error( 136 | "Only string-keyed objects are allowed to be proxied to detect undefined actions" 137 | ); 138 | } 139 | 140 | const value = target[prop]; 141 | 142 | if (!value) { 143 | throw new Error( 144 | `The "${String( 145 | prop 146 | )}" action couldn't be found. Did you install the necessary peer dependencies? Are you setting custom dependencies correctly?` 147 | ); 148 | } 149 | 150 | return value; 151 | }, 152 | }); 153 | } 154 | 155 | function __createVerboseLogger(): ILogger { 156 | return createDefaultLogger(); 157 | } 158 | 159 | const testingLibShims: IKeyboardActions = { 160 | navigation: { 161 | tab: 162 | userEvent && 163 | (async () => { 164 | await userEvent.tab(); 165 | }), 166 | shiftTab: 167 | userEvent && 168 | (async () => { 169 | await userEvent.tab({ shift: true }); 170 | }), 171 | arrowUp: 172 | userEvent && 173 | (async () => { 174 | await userEvent.keyboard("{ArrowUp}"); 175 | }), 176 | arrowRight: 177 | userEvent && 178 | (async () => { 179 | await userEvent.keyboard("{ArrowRight}"); 180 | }), 181 | arrowDown: 182 | userEvent && 183 | (async () => { 184 | await userEvent.keyboard("{ArrowDown}"); 185 | }), 186 | arrowLeft: 187 | userEvent && 188 | (async () => { 189 | await userEvent.keyboard("{ArrowLeft}"); 190 | }), 191 | }, 192 | activation: { 193 | enter: 194 | userEvent && 195 | (async () => { 196 | await userEvent.keyboard("{Enter}"); 197 | }), 198 | spacebar: 199 | userEvent && 200 | (async () => { 201 | await userEvent.keyboard("{ }"); 202 | }), 203 | }, 204 | }; 205 | 206 | const keyboardOnlyUserEvent = __createKeyboardOnlyUserEvent(); 207 | export default keyboardOnlyUserEvent; 208 | --------------------------------------------------------------------------------