17 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
18 | eiusmod tempor incididunt ut labore et dolore magna aliqua. Sagittis
19 | vitae et leo duis ut diam quam nulla porttitor. In nibh mauris cursus
20 | mattis molestie a iaculis at erat. Sodales ut eu sem integer vitae
21 | justo eget magna fermentum. Sed enim ut sem viverra aliquet eget sit
22 | amet. Nisi scelerisque eu ultrices vitae auctor eu. Facilisis gravida
23 | neque convallis a. Sodales neque sodales ut etiam sit amet nisl purus.
24 | Ornare arcu dui vivamus arcu. Neque egestas congue quisque egestas.
25 | Volutpat consequat mauris nunc congue. Ornare suspendisse sed nisi
26 | lacus sed viverra tellus in. Duis ut diam quam nulla porttitor massa
27 | id neque aliquam. Ac feugiat sed lectus vestibulum mattis ullamcorper
28 | velit sed. Viverra suspendisse potenti nullam ac. Sed risus ultricies
29 | tristique nulla aliquet enim tortor at. Arcu ac tortor dignissim
30 | convallis aenean et tortor. Pharetra massa massa ultricies mi quis
31 | hendrerit dolor magna eget. Lorem ipsum dolor sit amet consectetur
32 | adipiscing elit. Quam elementum pulvinar etiam non. Tortor dignissim
33 | convallis aenean et tortor. Proin nibh nisl condimentum id. Sagittis
34 | aliquam malesuada bibendum arcu vitae elementum curabitur. Enim ut
35 | tellus elementum sagittis vitae et leo. Risus at ultrices mi tempus
36 | imperdiet nulla malesuada pellentesque elit. Viverra accumsan in nisl
37 | nisi scelerisque eu. Mattis pellentesque id nibh tortor id aliquet.
38 | Sed vulputate mi sit amet mauris commodo quis imperdiet. Cum sociis
39 | natoque penatibus et magnis dis parturient montes nascetur. Erat velit
40 | scelerisque in dictum non. Mauris rhoncus aenean vel elit scelerisque
41 | mauris.
42 |
43 |
44 |
45 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/demo/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | import App from "./App";
5 |
6 | ReactDOM.render(, document.getElementById("root"));
7 |
--------------------------------------------------------------------------------
/demo/src/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: black;
3 | color: white;
4 | }
5 |
6 | .modal {
7 | top: 0;
8 | left: 0;
9 | position: absolute;
10 | width: 100%;
11 | height: 100%;
12 | background-color: #d8bfd8bc;
13 | display: flex;
14 | justify-content: center;
15 | align-items: center;
16 | }
17 |
18 | input:focus,
19 | button:focus {
20 | outline: 10px solid pink;
21 | }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-use-focus-trap",
3 | "version": "1.1.7",
4 | "description": "A react hook to trap the focus within a reference",
5 | "keywords": [
6 | "react",
7 | "modal",
8 | "focus",
9 | "trap",
10 | "focus-trap",
11 | "a11y",
12 | "accessibility"
13 | ],
14 | "source": "src/useFocusTrap.js",
15 | "targets": {
16 | "commonjs": {
17 | "includeNodeModules": {
18 | "react": false
19 | },
20 | "isLibrary": true,
21 | "outputFormat": "commonjs"
22 | },
23 | "esm": {
24 | "includeNodeModules": {
25 | "react": false
26 | },
27 | "outputFormat": "esmodule",
28 | "isLibrary": true
29 | }
30 | },
31 | "type": "module",
32 | "exports": {
33 | ".": {
34 | "require": "./dist/commonjs/useFocusTrap.cjs",
35 | "import": "./dist/esm/useFocusTrap.mjs"
36 | }
37 | },
38 | "scripts": {
39 | "test": "mocha -r jsdom-global/register",
40 | "build": "parcel build && npm run fixFileNamesCjs && npm run fixFileNamesMjs",
41 | "fixFileNamesCjs": "cd dist/commonjs && mv useFocusTrap.js useFocusTrap.cjs && mv useFocusTrap.js.map useFocusTrap.cjs.map",
42 | "fixFileNamesMjs": "cd dist/esm && mv useFocusTrap.js useFocusTrap.mjs && mv useFocusTrap.js.map useFocusTrap.mjs.map",
43 | "run:demo": "npm install && npm run build && npm link && cd demo && npm install && npm start"
44 | },
45 | "repository": {
46 | "type": "git",
47 | "url": "git+https://github.com/activenode/use-focus-trap.git"
48 | },
49 | "author": "David Lorenz ",
50 | "license": "MIT",
51 | "bugs": {
52 | "url": "https://github.com/activenode/use-focus-trap/issues"
53 | },
54 | "homepage": "https://github.com/activenode/use-focus-trap#readme",
55 | "peerDependencies": {
56 | "react": "^17.0.0"
57 | },
58 | "devDependencies": {
59 | "jsdom": "^19.0.0",
60 | "jsdom-global": "^3.0.2",
61 | "mocha": "^9.1.3",
62 | "parcel": "^2.7.0",
63 | "react": "^17.0.2"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/useFocusTrap.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from "react";
2 | import { getTabIndexOfNode, sortByTabIndex } from "./util.js";
3 |
4 | const focusableElementsSelector =
5 | "a[href], area[href], input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]";
6 | const TAB_KEY = 9;
7 |
8 | export function useFocusTrap() {
9 | const trapRef = useRef(null);
10 |
11 | const selectNextFocusableElem = useCallback(
12 | (
13 | sortedFocusableElems,
14 | currentIndex,
15 | shiftKeyPressed = false,
16 | skipCount = 0
17 | ) => {
18 | if (skipCount > sortedFocusableElems.length) {
19 | // this means that it ran through all of elements but non was properly focusable
20 | // hence we stop it to avoid running in an infinite loop
21 | return false;
22 | }
23 |
24 | const backwards = !!shiftKeyPressed;
25 | const maxIndex = sortedFocusableElems.length - 1;
26 |
27 | if (!currentIndex) {
28 | currentIndex =
29 | sortedFocusableElems.indexOf(document.activeElement) ?? 0;
30 | }
31 |
32 | let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1;
33 | if (nextIndex > maxIndex) {
34 | nextIndex = 0;
35 | }
36 |
37 | if (nextIndex < 0) {
38 | nextIndex = maxIndex;
39 | }
40 |
41 | const newFocusElem = sortedFocusableElems[nextIndex];
42 |
43 | newFocusElem.focus();
44 |
45 | if (document.activeElement !== newFocusElem) {
46 | // run another round
47 | selectNextFocusableElem(
48 | sortedFocusableElems,
49 | nextIndex,
50 | shiftKeyPressed,
51 | skipCount + 1
52 | );
53 | }
54 | }
55 | );
56 |
57 | // defining the trap function first
58 | const trapper = useCallback((evt) => {
59 | const trapRefElem = trapRef.current;
60 | if (trapRefElem !== null) {
61 | if (evt.which === TAB_KEY || evt.key === "Tab") {
62 | evt.preventDefault();
63 | const shiftKeyPressed = !!evt.shiftKey;
64 | let focusableElems = Array.from(
65 | trapRefElem.querySelectorAll(focusableElementsSelector)
66 | ).filter(
67 | (focusableElement) => getTabIndexOfNode(focusableElement) >= 0
68 | ); // caching this is NOT a good idea in dynamic applications - so don't!
69 | // now we need to sort it by tabIndex, highest first
70 | focusableElems = focusableElems.sort(sortByTabIndex);
71 |
72 | selectNextFocusableElem(focusableElems, undefined, shiftKeyPressed);
73 | }
74 | }
75 | // eslint-disable-next-line react-hooks/exhaustive-deps
76 | }, []);
77 |
78 | useEffect(() => {
79 | window.addEventListener("keydown", trapper);
80 |
81 | return () => {
82 | window.removeEventListener("keydown", trapper);
83 | };
84 | // eslint-disable-next-line react-hooks/exhaustive-deps
85 | }, []);
86 |
87 | return [trapRef];
88 | }
89 |
--------------------------------------------------------------------------------
/src/util.js:
--------------------------------------------------------------------------------
1 | export function convertToIntOrFallback(stringToConvert) {
2 | const parsed = parseInt(stringToConvert);
3 | return parsed ? parsed : 0;
4 | }
5 |
6 | export function sortByTabIndex(firstNode, secondNode) {
7 | const tabIndexes = [firstNode, secondNode].map((node) =>
8 | getTabIndexOfNode(node)
9 | );
10 | return tabIndexes
11 | .map((tabIndexValue) =>
12 | sanitizeTabIndexInput(tabIndexValue, Math.max(...tabIndexes))
13 | )
14 | .reduce((previousValue, currentValue) => previousValue - currentValue);
15 | }
16 |
17 | /**
18 | * Prepares a tab-index to be further processed for the tab order of the focus trap.
19 | * It can't be less than 0, because negative values can not be part of the tab order at all.
20 | * In case it's exactly 0 it actually needs to be higher than any positive (> 0) value, since tab-index=0 means "follow the system default order".
21 | * The default tab order comes _after_ special tab indexes (>0).
22 | * @param {number} tabIndex The index to sanitize
23 | * @param {number} highestPositiveTabIndex The largest number among the tab indexes from the same context
24 | * @throws An error if the tabIndex is less than 0
25 | * @returns Tha sanitized tab index
26 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex} for further information on the tabindex and its order
27 | */
28 | function sanitizeTabIndexInput(tabIndex, highestPositiveTabIndex) {
29 | if (tabIndex < 0) {
30 | throw new Error(
31 | `Unable to sort given input. A negative value is not part of the tab order: ${tabIndex}`
32 | );
33 | }
34 | // 0 based tab indexes have a higher order than positive valued indicies, thus we add 1 to the max value
35 | return tabIndex === 0 ? highestPositiveTabIndex + 1 : tabIndex;
36 | }
37 |
38 | export function getTabIndexOfNode(targetNode) {
39 | return convertToIntOrFallback(targetNode.getAttribute("tabindex"));
40 | }
41 |
--------------------------------------------------------------------------------
/test/util.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | convertToIntOrFallback,
3 | getTabIndexOfNode,
4 | sortByTabIndex,
5 | } from "../src/util.js";
6 | import assert from "assert";
7 | import { JSDOM } from "jsdom";
8 |
9 | describe("Utility functions", function () {
10 | describe("#convertToIntOrFallback", function () {
11 | it("should convert a valid string to a number", function () {
12 | assert.equal(convertToIntOrFallback("1"), 1);
13 | assert.equal(convertToIntOrFallback("1337"), 1337);
14 | });
15 | it("should return 0 for an invalid string", function () {
16 | assert.equal(convertToIntOrFallback("foobar"), 0);
17 | });
18 | it("should return 0 for type-mismatching arguments", function () {
19 | assert.equal(convertToIntOrFallback({ foo: "bar" }), 0);
20 | assert.equal(convertToIntOrFallback(null), 0);
21 | assert.equal(convertToIntOrFallback(undefined), 0);
22 | });
23 | it("should pass through numbers", function () {
24 | assert.equal(convertToIntOrFallback(0), 0);
25 | assert.equal(convertToIntOrFallback(420), 420);
26 | });
27 | });
28 |
29 | describe("#sortByTabIndex", function () {
30 | let tabIndexedNodes;
31 | beforeEach(function () {
32 | const dom = new JSDOM(
33 | `
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | `
44 | );
45 | tabIndexedNodes = dom.window.document.querySelectorAll("button");
46 | });
47 | it("should sort by tabindex value ascending", function () {
48 | const firstNode = tabIndexedNodes.item(0),
49 | secondNode = tabIndexedNodes.item(1);
50 |
51 | // Sort secondNode 1 before firstNode 2
52 | assert.equal(sortByTabIndex(firstNode, secondNode), 1);
53 | assert.equal(sortByTabIndex(secondNode, firstNode), -1);
54 | });
55 | it("should put 0 values after positive values", function () {
56 | const firstNode = tabIndexedNodes.item(0),
57 | secondNode = tabIndexedNodes.item(3);
58 |
59 | // Sort secondNode 0 after firstNode 2
60 | assert.equal(sortByTabIndex(firstNode, secondNode), -1);
61 | assert.equal(sortByTabIndex(secondNode, firstNode), 1);
62 | });
63 | it("should keep the original order of 0 values", function () {
64 | const firstNode = tabIndexedNodes.item(3),
65 | secondNode = tabIndexedNodes.item(6);
66 |
67 | // Sort firstNode before secondNode
68 | assert.equal(sortByTabIndex(firstNode, secondNode), 0);
69 | // Sort secondNode before firstNode
70 | assert.equal(sortByTabIndex(secondNode, firstNode), 0);
71 | });
72 | it("should throw an error when a negative value is passed", function () {
73 | const firstNode = tabIndexedNodes.item(0),
74 | secondNode = tabIndexedNodes.item(2);
75 |
76 | assert.throws(() => sortByTabIndex(firstNode, secondNode));
77 | assert.throws(() => sortByTabIndex(secondNode, firstNode));
78 | });
79 | it("should throw an error when a negative value is passed", function () {
80 | const sortedArray = Array.from(tabIndexedNodes)
81 | .filter((node) => node.getAttribute("tabindex") >= 0)
82 | .sort(sortByTabIndex);
83 |
84 | assert.deepEqual(sortedArray, [
85 | tabIndexedNodes.item(1),
86 | tabIndexedNodes.item(0),
87 | tabIndexedNodes.item(5),
88 | tabIndexedNodes.item(3),
89 | tabIndexedNodes.item(6),
90 | ]);
91 | });
92 | });
93 |
94 | describe("#getTabIndexOfNode", function () {
95 | let tabIndexedNodes;
96 | beforeEach(function () {
97 | const dom = new JSDOM(
98 | `
99 |
100 |
101 |
102 |
103 |
104 | foobar
105 |
106 | `
107 | );
108 | tabIndexedNodes = dom.window.document.querySelectorAll(".to-test");
109 | });
110 | it("should get the tab index as numbers of nodes", function () {
111 | const tabIndexes = Array.from(tabIndexedNodes).map((node) =>
112 | getTabIndexOfNode(node)
113 | );
114 | assert.deepEqual(tabIndexes, [2, -1, 0, 0, 0]);
115 | });
116 | });
117 | });
118 |
--------------------------------------------------------------------------------