├── .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 |
--------------------------------------------------------------------------------