├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demo ├── package-lock.json ├── package.json ├── public │ └── index.html └── src │ ├── App.js │ ├── index.js │ └── style.css ├── package-lock.json ├── package.json ├── src ├── useFocusTrap.js └── util.js └── test └── util.test.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | 5 | jobs: 6 | ci: 7 | name: CI 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | 13 | - name: Setup Node 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 14 17 | 18 | - name: Install dependencies 19 | run: npm ci 20 | 21 | - name: Test 22 | run: npm run test 23 | 24 | - name: Build 25 | run: npm run build 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | demo/build 107 | 108 | .parcel-* -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Lorenz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Focus Trap (`react-use-focus-trap`) 2 | 3 | [![ci](https://github.com/activenode/react-use-focus-trap/actions/workflows/ci.yml/badge.svg)](https://github.com/activenode/react-use-focus-trap/actions/workflows/ci.yml) 4 | 5 | An extremely helpful React Hook to trap the focusable elements. 6 | 7 | (The underlying Guideline: https://www.w3.org/TR/wai-aria-practices-1.1/examples/dialog-modal/dialog.html) 8 | 9 | _Improvement or feedback of any kind is **always** appreciated!_ 10 | 11 | ## The Issue with Modals 12 | 13 | When implementing Modals, people tend to forget that pro-users and users that are permanently or temporarily restricted will use other inputs than the mouse - e.g., a keyboard. 14 | 15 | ![Sad Face](https://media.giphy.com/media/3otWpthJPjNfD1xuh2/giphy.gif) 16 | 17 | Now tabbing through a modal most often leads to the focus going z- or y-wise below the Modal. Good luck finding your way back then! It's a **horror show**. 18 | That's the point where this library comes into play. Add this hook, and lock ("trap") the focus into the modal. 19 | 20 | ![Happy Face](https://media.giphy.com/media/HTLHGEXpZ3zhuqME3q/giphy.gif) 21 | 22 | ## Installation 23 | 24 | ```shell 25 | npm install react-use-focus-trap 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```jsx 31 | import React from "react"; 32 | import { useFocusTrap } from "react-use-focus-trap"; 33 | 34 | export function NiceModal() { 35 | const [trapRef] = useFocusTrap(); 36 | 37 | return ( 38 |
39 | Foobar 40 |
41 | ); 42 | } 43 | ``` 44 | 45 | ## Developing 46 | 47 | There is a demo application for easy debugging included. To start developing, simply execute these commands: 48 | 49 | ```shell 50 | npm install 51 | npm run build 52 | npm link 53 | cd demo 54 | npm install 55 | npm start 56 | ``` 57 | 58 | This opens up a dev-server with a silly modal to test your code in. 59 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-use-focus-trap-demo", 3 | "version": "1.0.0", 4 | "description": "A minimal demo to debug and test react-use-focus-trap", 5 | "private": true, 6 | "scripts": { 7 | "start": "react-scripts start", 8 | "build": "react-scripts build", 9 | "test": "react-scripts test --env=jsdom", 10 | "eject": "react-scripts eject" 11 | }, 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "react": "file:../node_modules/react", 16 | "react-dom": "^17.0.2", 17 | "react-use-focus-trap": "file:.." 18 | }, 19 | "devDependencies": { 20 | "react-scripts": "^5.0.0" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./style.css"; 3 | 4 | import { useFocusTrap } from "react-use-focus-trap"; 5 | 6 | export default function App() { 7 | const [trapRef] = useFocusTrap(); 8 | return ( 9 |
10 |
11 |
12 | 13 | 14 | 15 |
16 |

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 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 55 |
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 | --------------------------------------------------------------------------------