├── .browserslistrc ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── assets ├── demo.gif ├── demo.mov ├── dialog.gif └── dialog.mov ├── blueprint.json ├── blueprint.md ├── karma.conf.js ├── package-lock.json ├── package.json ├── pre-build.js ├── readme ├── api.md ├── installation.md └── usage.md ├── rollup.config.js ├── src ├── demo │ ├── index.html │ └── main.ts ├── lib │ ├── debounce.ts │ ├── focus-trap.ts │ ├── focusable.ts │ ├── index.ts │ └── shadow.ts └── test │ ├── focusable.test.ts │ └── shadow.test.ts ├── tsconfig.build.json ├── tsconfig.json ├── tslint.json └── typings.d.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 Chrome versions 2 | last 2 Safari versions 3 | last 2 Firefox versions -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main Workflow 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run: 7 | name: Run 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@master 14 | 15 | - name: Set Node.js 10.x 16 | uses: actions/setup-node@master 17 | with: 18 | node-version: 10.x 19 | 20 | - name: Cache 21 | uses: actions/cache@preview 22 | id: cache 23 | with: 24 | path: node_modules 25 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-node- 28 | 29 | - name: Install 30 | if: steps.cache.outputs.cache-hit != 'true' 31 | run: npm ci 32 | 33 | - name: Test 34 | run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | .DS_Store 4 | ec2-user-key-pair.pem 5 | /tmp 6 | env.json 7 | 8 | # compiled output 9 | /dist 10 | 11 | # dependencies 12 | /node_modules 13 | /functions/node_modules 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | 31 | # misc 32 | /.sass-cache 33 | /connect.lock 34 | /coverage/* 35 | /libpeerconnection.log 36 | npm-debug.log 37 | testem.log 38 | logfile 39 | 40 | # e2e 41 | /e2e/*.js 42 | /e2e/*.map 43 | 44 | #System Files 45 | .DS_Store 46 | Thumbs.db 47 | dump.rdb 48 | 49 | /compiled/ 50 | /.idea/ 51 | /.cache/ 52 | /.vscode/ 53 | *.log 54 | /logs/ 55 | npm-debug.log* 56 | /lib-cov/ 57 | /coverage/ 58 | /.nyc_output/ 59 | /.grunt/ 60 | *.7z 61 | *.dmg 62 | *.gz 63 | *.iso 64 | *.jar 65 | *.rar 66 | *.tar 67 | *.zip 68 | .tgz 69 | .env 70 | .DS_Store? 71 | ._* 72 | .Spotlight-V100 73 | .Trashes 74 | ehthumbs.db 75 | *.pem 76 | *.p12 77 | *.crt 78 | *.csr 79 | /node_modules/ 80 | /dist/ 81 | /documentation/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting any of the code of conduct enforcers: [Andreas Mehlsen](mailto:andmehlsen@gmail.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | You are more than welcome to contribute to `@a11y/focus-trap` in any way you please, including: 2 | 3 | * Updating documentation. 4 | * Fixing spelling and grammar 5 | * Adding tests 6 | * Fixing issues and suggesting new features 7 | * Blogging, tweeting, and creating tutorials about `@a11y/focus-trap` 8 | * Reaching out to [@andreasmehlsen](https://twitter.com/andreasmehlsen) on Twitter 9 | * Submit an issue or a pull request -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2018 Andreas Mehlsen andmehlsen@gmail.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

@a11y/focus-trap

2 | 3 |

4 | Downloads per month 5 | NPM Version 6 | Dependencies 7 | Contributors 8 | Published on webcomponents.org 9 |

10 | 11 | 12 |

13 | A lightweight web component that traps focus within a DOM node
14 | A focus trap ensures that tab and shift + tab keys will cycle through the focus trap's tabbable elements but not leave the focus trap. This is great for making accessible modals. Go here to see a demo https://appnest-demo.firebaseapp.com/focus-trap/. 15 |

16 | 17 |
18 | 19 | 20 |

21 | 22 |

23 | 24 | * Does one things very very well - it traps the focus! 25 | * Pierces through the shadow roots when looking for focusable elements. 26 | * Works right out of the box (just add it to your markup) 27 | * Created using only vanilla js - no dependencies and framework agnostic! 28 | 29 | 30 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#installation) 31 | 32 | ## ➤ Installation 33 | 34 | ```javascript 35 | npm i @a11y/focus-trap 36 | ``` 37 | 38 | 39 | 40 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#usage) 41 | 42 | ## ➤ Usage 43 | 44 | Import `@a11y/focus-trap` somewhere in your code and you're ready to go! Simply add the focus trap to your `html` and it'll be working without any more effort from your part. 45 | 46 | ```html 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ``` 55 | 56 | 57 | 58 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#api) 59 | 60 | ## ➤ API 61 | 62 | The `focus-trap` element implements the following interface. 63 | 64 | ```typescript 65 | interface IFocusTrap { 66 | // Returns whether or not the focus trap is inactive. 67 | inactive: boolean; 68 | 69 | // Returns whether the focus trap currently has focus. 70 | readonly focused: boolean; 71 | 72 | // Focuses the first focusable element in the focus trap. 73 | focusFirstElement: (() => void); 74 | 75 | // Focuses the last focusable element in the focus trap. 76 | focusLastElement: (() => void); 77 | 78 | // Returns a list of the focusable children found within the element. 79 | getFocusableElements: (() => HTMLElement[]); 80 | } 81 | ``` 82 | 83 | 84 | 85 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#license) 86 | 87 | ## ➤ License 88 | 89 | Licensed under [MIT](https://opensource.org/licenses/MIT). 90 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/focus-trap/efe549115288362d2aaae9b7a4780cc4539d097d/assets/demo.gif -------------------------------------------------------------------------------- /assets/demo.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/focus-trap/efe549115288362d2aaae9b7a4780cc4539d097d/assets/demo.mov -------------------------------------------------------------------------------- /assets/dialog.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/focus-trap/efe549115288362d2aaae9b7a4780cc4539d097d/assets/dialog.gif -------------------------------------------------------------------------------- /assets/dialog.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/focus-trap/efe549115288362d2aaae9b7a4780cc4539d097d/assets/dialog.mov -------------------------------------------------------------------------------- /blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line": "rainbow", 3 | "text": "A focus trap ensures that tab and shift + tab keys will cycle through the focus trap's tabbable elements but not leave the focus trap. This is great for making accessible modals.", 4 | "demo": "https://appnest-demo.firebaseapp.com/focus-trap/", 5 | "ids": { 6 | "npm": "@a11y/focus-trap", 7 | "github": "andreasbm/focus-trap", 8 | "webcomponents": "@a11y/focus-trap" 9 | }, 10 | "bullets": [ 11 | "Does one things very very well - it traps the focus!", 12 | "Pierces through the shadow roots when looking for focusable elements.", 13 | "Works right out of the box (just add it to your markup)", 14 | "Created using only vanilla js - no dependencies and framework agnostic!" 15 | ] 16 | } -------------------------------------------------------------------------------- /blueprint.md: -------------------------------------------------------------------------------- 1 | {{ template:title }} 2 | 3 | {{ template:badges }} 4 | 5 | {{ template:description }} 6 | 7 |

8 | 9 |

10 | 11 | {{ bullets }} 12 | 13 | {{ load:readme/installation.md }} 14 | 15 | {{ load:readme/usage.md }} 16 | 17 | {{ load:readme/api.md }} 18 | 19 | {{ template:license }} 20 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const {defaultResolvePlugins, defaultKarmaConfig} = require("@appnest/web-config"); 2 | 3 | module.exports = (config) => { 4 | config.set({ 5 | ...defaultKarmaConfig({ 6 | rollupPlugins: defaultResolvePlugins() 7 | }), 8 | basePath: "src", 9 | logLevel: config.LOG_INFO 10 | }); 11 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@a11y/focus-trap", 3 | "version": "1.0.5", 4 | "license": "MIT", 5 | "module": "index.js", 6 | "author": "Andreas Mehlsen", 7 | "description": "A lightweight web component that traps focus within a DOM node", 8 | "bugs": { 9 | "url": "https://github.com/andreasbm/focus-trap/issues" 10 | }, 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "homepage": "https://github.com/andreasbm/focus-trap#readme", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/andreasbm/focus-trap.git" 18 | }, 19 | "keywords": [ 20 | "custom", 21 | "elements", 22 | "web", 23 | "component", 24 | "custom element", 25 | "web component", 26 | "focus", 27 | "accessibility", 28 | "dialog", 29 | "focus trap", 30 | "trap" 31 | ], 32 | "main": "index.js", 33 | "types": "index.d.ts", 34 | "scripts": { 35 | "ncu": "ncu -u -a && npm update && npm install", 36 | "test": "karma start", 37 | "b:dev": "rollup -c --environment NODE_ENV:dev", 38 | "b:prod": "rollup -c --environment NODE_ENV:prod", 39 | "s:dev": "rollup -c --watch --environment NODE_ENV:dev", 40 | "s:prod": "rollup -c --watch --environment NODE_ENV:prod", 41 | "s": "npm run s:dev", 42 | "start": "npm run s", 43 | "b:lib": "node pre-build.js && tsc -p tsconfig.build.json", 44 | "readme": "node node_modules/.bin/readme generate", 45 | "postversion": "npm run readme && npm run b:lib", 46 | "publish:patch": "np patch --contents=dist --no-cleanup", 47 | "publish:minor": "np minor --contents=dist --no-cleanup", 48 | "publish:major": "np major --contents=dist --no-cleanup" 49 | }, 50 | "devDependencies": { 51 | "@appnest/readme": "^1.2.5", 52 | "@appnest/web-config": "0.4.39" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pre-build.js: -------------------------------------------------------------------------------- 1 | const rimraf = require("rimraf"); 2 | const path = require("path"); 3 | const fs = require("fs-extra"); 4 | const outLib = "dist"; 5 | 6 | // TODO: Run "tsc -p tsconfig.build.json" from this script and rename it to "build". 7 | 8 | async function preBuild () { 9 | await cleanLib(); 10 | copySync("./package.json", `./${outLib}/package.json`); 11 | copySync("./README.md", `./${outLib}/README.md`); 12 | } 13 | 14 | function cleanLib () { 15 | return new Promise(res => { 16 | rimraf(outLib, res); 17 | }); 18 | } 19 | 20 | function copySync (src, dest) { 21 | fs.copySync(path.resolve(__dirname, src), path.resolve(__dirname, dest)); 22 | } 23 | 24 | preBuild().then(_ => { 25 | console.log(">> Prebuild completed"); 26 | }); 27 | -------------------------------------------------------------------------------- /readme/api.md: -------------------------------------------------------------------------------- 1 | ## API 2 | 3 | The `focus-trap` element implements the following interface. 4 | 5 | ```typescript 6 | interface IFocusTrap { 7 | // Returns whether or not the focus trap is inactive. 8 | inactive: boolean; 9 | 10 | // Returns whether the focus trap currently has focus. 11 | readonly focused: boolean; 12 | 13 | // Focuses the first focusable element in the focus trap. 14 | focusFirstElement: (() => void); 15 | 16 | // Focuses the last focusable element in the focus trap. 17 | focusLastElement: (() => void); 18 | 19 | // Returns a list of the focusable children found within the element. 20 | getFocusableElements: (() => HTMLElement[]); 21 | } 22 | ``` 23 | -------------------------------------------------------------------------------- /readme/installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ```javascript 4 | npm i {{ ids.npm }} 5 | ``` 6 | -------------------------------------------------------------------------------- /readme/usage.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | Import `@a11y/focus-trap` somewhere in your code and you're ready to go! Simply add the focus trap to your `html` and it'll be working without any more effort from your part. 4 | 5 | ```html 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ``` 14 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { 2 | defaultExternals, 3 | defaultOutputConfig, 4 | defaultPlugins, 5 | defaultProdPlugins, 6 | defaultServePlugins, 7 | isLibrary, 8 | isProd, 9 | isServe 10 | } from "@appnest/web-config"; 11 | import path from "path"; 12 | import pkg from "./package.json"; 13 | 14 | const folders = { 15 | src: path.resolve(__dirname, "src/demo"), 16 | dist: path.resolve(__dirname, "dist") 17 | }; 18 | 19 | const files = { 20 | main: path.join(folders.src, "main.ts"), 21 | src_index: path.join(folders.src, "index.html"), 22 | dist_index: path.join(folders.dist, "index.html") 23 | }; 24 | 25 | export default { 26 | input: { 27 | main: files.main 28 | }, 29 | output: [ 30 | defaultOutputConfig({ 31 | format: "esm", 32 | dir: folders.dist 33 | }) 34 | ], 35 | plugins: [ 36 | ...defaultPlugins({ 37 | cleanerConfig: { 38 | targets: [ 39 | folders.dist 40 | ] 41 | }, 42 | copyConfig: { 43 | resources: [ 44 | ], 45 | }, 46 | htmlTemplateConfig: { 47 | template: files.src_index, 48 | target: files.dist_index, 49 | include: /main(-.*)?\.js$/ 50 | }, 51 | importStylesConfig: { 52 | globals: ["global.scss"] 53 | } 54 | }), 55 | 56 | // Serve 57 | ...(isServe ? [ 58 | ...defaultServePlugins({ 59 | serveConfig: { 60 | port: 1341, 61 | contentBase: folders.dist 62 | }, 63 | livereloadConfig: { 64 | watch: folders.dist, 65 | port: 35731 66 | } 67 | }) 68 | ] : []), 69 | 70 | // Production 71 | ...(isProd ? [ 72 | ...defaultProdPlugins({ 73 | dist: folders.dist, 74 | minifyLitHtmlConfig: { 75 | verbose: false 76 | }, 77 | visualizerConfig: { 78 | filename: path.join(folders.dist, "stats.html") 79 | }, 80 | licenseConfig: { 81 | thirdParty: { 82 | output: path.join(folders.dist, "licenses.txt") 83 | } 84 | } 85 | }) 86 | ] : []) 87 | ], 88 | external: [ 89 | ...(isLibrary ? [ 90 | ...defaultExternals(pkg) 91 | ] : []) 92 | ], 93 | treeshake: isProd, 94 | context: "window" 95 | } 96 | -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | @a11y/focus-trap 8 | 9 | 10 | 36 | 37 | 38 | 39 |
40 |

@a11y/focus-trap

41 |

Tab around this demo and notice how your focus is trapped.

42 | 43 |

Here's some focusable parts outside the trap.

44 | 45 | 46 |

Here's a focus trap with some focusable parts.

47 | 48 |
49 | 50 |

Here's some more focusable parts outside the trap.

51 | 52 |

If you are having troubles with the focus ring being invisible in Safari or Firefox you need to activate the accessibility settings in the browser. Read more here or here if you are interested in learning more.

53 | 54 | Github 55 | 56 |
57 | 58 | 61 | 62 | 63 | 75 | 76 | -------------------------------------------------------------------------------- /src/demo/main.ts: -------------------------------------------------------------------------------- 1 | import "../lib"; 2 | 3 | -------------------------------------------------------------------------------- /src/lib/debounce.ts: -------------------------------------------------------------------------------- 1 | const timeouts = new Map(); 2 | 3 | /** 4 | * Debounces a callback. 5 | * @param cb 6 | * @param ms 7 | * @param id 8 | */ 9 | export function debounce (cb: (() => void), ms: number, id: string) { 10 | 11 | // Clear current timeout for id 12 | const timeout = timeouts.get(id); 13 | if (timeout != null) { 14 | window.clearTimeout(timeout); 15 | } 16 | 17 | // Set new timeout 18 | timeouts.set(id, window.setTimeout(() => { 19 | cb(); 20 | timeouts.delete(id); 21 | }, ms)); 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/lib/focus-trap.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from "./debounce"; 2 | import { isFocusable, isHidden } from "./focusable"; 3 | import { queryShadowRoot } from "./shadow"; 4 | 5 | export interface IFocusTrap { 6 | inactive: boolean; 7 | readonly focused: boolean; 8 | focusFirstElement: (() => void); 9 | focusLastElement: (() => void); 10 | getFocusableElements: (() => HTMLElement[]); 11 | } 12 | 13 | /** 14 | * Template for the focus trap. 15 | */ 16 | const template = document.createElement("template"); 17 | template.innerHTML = ` 18 |
19 |
20 | 21 |
22 | `; 23 | 24 | /** 25 | * Focus trap web component. 26 | * @customElement focus-trap 27 | * @slot - Default content. 28 | */ 29 | export class FocusTrap extends HTMLElement implements IFocusTrap { 30 | 31 | // Whenever one of these attributes changes we need to render the template again. 32 | static get observedAttributes () { 33 | return [ 34 | "inactive" 35 | ]; 36 | } 37 | 38 | /** 39 | * Determines whether the focus trap is active or not. 40 | * @attr 41 | */ 42 | get inactive () { 43 | return this.hasAttribute("inactive"); 44 | } 45 | 46 | set inactive (value: boolean) { 47 | value ? this.setAttribute("inactive", "") : this.removeAttribute("inactive"); 48 | } 49 | 50 | // The backup element is only used if there are no other focusable children 51 | private $backup!: HTMLElement; 52 | 53 | // The debounce id is used to distinguish this focus trap from others when debouncing 54 | private debounceId = Math.random().toString(); 55 | 56 | private $start!: HTMLElement; 57 | private $end!: HTMLElement; 58 | 59 | private _focused = false; 60 | 61 | /** 62 | * Returns whether the element currently has focus. 63 | */ 64 | get focused (): boolean { 65 | return this._focused; 66 | } 67 | 68 | /** 69 | * Attaches the shadow root. 70 | */ 71 | constructor () { 72 | super(); 73 | 74 | const shadow = this.attachShadow({mode: "open"}); 75 | shadow.appendChild(template.content.cloneNode(true)); 76 | 77 | this.$backup = shadow.querySelector("#backup")!; 78 | this.$start = shadow.querySelector("#start")!; 79 | this.$end = shadow.querySelector("#end")!; 80 | 81 | this.focusLastElement = this.focusLastElement.bind(this); 82 | this.focusFirstElement = this.focusFirstElement.bind(this); 83 | this.onFocusIn = this.onFocusIn.bind(this); 84 | this.onFocusOut = this.onFocusOut.bind(this); 85 | } 86 | 87 | /** 88 | * Hooks up the element. 89 | */ 90 | connectedCallback () { 91 | this.$start.addEventListener("focus", this.focusLastElement); 92 | this.$end.addEventListener("focus", this.focusFirstElement); 93 | 94 | // Focus out is called every time the user tabs around inside the element 95 | this.addEventListener("focusin", this.onFocusIn); 96 | this.addEventListener("focusout", this.onFocusOut); 97 | 98 | this.render(); 99 | } 100 | 101 | 102 | /** 103 | * Tears down the element. 104 | */ 105 | disconnectedCallback () { 106 | this.$start.removeEventListener("focus", this.focusLastElement); 107 | this.$end.removeEventListener("focus", this.focusFirstElement); 108 | this.removeEventListener("focusin", this.onFocusIn); 109 | this.removeEventListener("focusout", this.onFocusOut); 110 | } 111 | 112 | /** 113 | * When the attributes changes we need to re-render the template. 114 | */ 115 | attributeChangedCallback () { 116 | this.render(); 117 | } 118 | 119 | /** 120 | * Focuses the first focusable element in the focus trap. 121 | */ 122 | focusFirstElement () { 123 | this.trapFocus(); 124 | } 125 | 126 | /** 127 | * Focuses the last focusable element in the focus trap. 128 | */ 129 | focusLastElement () { 130 | this.trapFocus(true); 131 | } 132 | 133 | /** 134 | * Returns a list of the focusable children found within the element. 135 | */ 136 | getFocusableElements (): HTMLElement[] { 137 | return queryShadowRoot(this, isHidden, isFocusable); 138 | } 139 | 140 | /** 141 | * Focuses on either the last or first focusable element. 142 | * @param {boolean} trapToEnd 143 | */ 144 | protected trapFocus (trapToEnd?: boolean) { 145 | if (this.inactive) return; 146 | 147 | let focusableElements = this.getFocusableElements(); 148 | if (focusableElements.length > 0) { 149 | if (trapToEnd) { 150 | focusableElements[focusableElements.length - 1].focus(); 151 | } else { 152 | focusableElements[0].focus(); 153 | } 154 | 155 | this.$backup.setAttribute("tabindex", "-1"); 156 | } else { 157 | // If there are no focusable children we need to focus on the backup 158 | // to trap the focus. This is a useful behavior if the focus trap is 159 | // for example used in a dialog and we don't want the user to tab 160 | // outside the dialog even though there are no focusable children 161 | // in the dialog. 162 | this.$backup.setAttribute("tabindex", "0"); 163 | this.$backup.focus(); 164 | } 165 | } 166 | 167 | /** 168 | * When the element gains focus this function is called. 169 | */ 170 | private onFocusIn () { 171 | this.updateFocused(true); 172 | } 173 | 174 | /** 175 | * When the element looses its focus this function is called. 176 | */ 177 | private onFocusOut () { 178 | this.updateFocused(false); 179 | } 180 | 181 | /** 182 | * Updates the focused property and updates the view. 183 | * The update is debounced because the focusin and focusout out 184 | * might fire multiple times in a row. We only want to render 185 | * the element once, therefore waiting until the focus is "stable". 186 | * @param value 187 | */ 188 | private updateFocused (value: boolean) { 189 | debounce(() => { 190 | if (this.focused !== value) { 191 | this._focused = value; 192 | this.render(); 193 | } 194 | }, 0, this.debounceId); 195 | } 196 | 197 | /** 198 | * Updates the template. 199 | */ 200 | protected render () { 201 | this.$start.setAttribute("tabindex", !this.focused || this.inactive ? `-1` : `0`); 202 | this.$end.setAttribute("tabindex", !this.focused || this.inactive ? `-1` : `0`); 203 | this.focused ? this.setAttribute("focused", "") : this.removeAttribute("focused"); 204 | } 205 | } 206 | 207 | window.customElements.define("focus-trap", FocusTrap); -------------------------------------------------------------------------------- /src/lib/focusable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns whether the element is hidden. 3 | * @param $elem 4 | */ 5 | export function isHidden ($elem: HTMLElement): boolean { 6 | return $elem.hasAttribute("hidden") 7 | || ($elem.hasAttribute("aria-hidden") && $elem.getAttribute("aria-hidden") !== "false") 8 | 9 | // A quick and dirty way to check whether the element is hidden. 10 | // For a more fine-grained check we could use "window.getComputedStyle" but we don't because of bad performance. 11 | // If the element has visibility set to "hidden" or "collapse", display set to "none" or opacity set to "0" through CSS 12 | // we won't be able to catch it here. We accept it due to the huge performance benefits. 13 | || $elem.style.display === `none` 14 | || $elem.style.opacity === `0` 15 | || $elem.style.visibility === `hidden` 16 | || $elem.style.visibility === `collapse`; 17 | 18 | // If offsetParent is null we can assume that the element is hidden 19 | // https://stackoverflow.com/questions/306305/what-would-make-offsetparent-null 20 | //|| $elem.offsetParent == null; 21 | } 22 | 23 | /** 24 | * Returns whether the element is disabled. 25 | * @param $elem 26 | */ 27 | export function isDisabled ($elem: HTMLElement): boolean { 28 | return $elem.hasAttribute("disabled") 29 | || ($elem.hasAttribute("aria-disabled") && $elem.getAttribute("aria-disabled") !== "false"); 30 | } 31 | 32 | /** 33 | * Determines whether an element is focusable. 34 | * Read more here: https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus/1600194#1600194 35 | * Or here: https://stackoverflow.com/questions/18261595/how-to-check-if-a-dom-element-is-focusable 36 | * @param $elem 37 | */ 38 | export function isFocusable ($elem: HTMLElement): boolean { 39 | 40 | // Discard elements that are removed from the tab order. 41 | if ($elem.getAttribute("tabindex") === "-1" || isHidden($elem) || isDisabled($elem)) { 42 | return false; 43 | } 44 | 45 | return ( 46 | 47 | // At this point we know that the element can have focus (eg. won't be -1) if the tabindex attribute exists 48 | $elem.hasAttribute("tabindex") 49 | 50 | // Anchor tags or area tags with a href set 51 | || ($elem instanceof HTMLAnchorElement || $elem instanceof HTMLAreaElement) && $elem.hasAttribute("href") 52 | 53 | // Form elements which are not disabled 54 | || ($elem instanceof HTMLButtonElement 55 | || $elem instanceof HTMLInputElement 56 | || $elem instanceof HTMLTextAreaElement 57 | || $elem instanceof HTMLSelectElement) 58 | 59 | // IFrames 60 | || $elem instanceof HTMLIFrameElement 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./shadow"; 2 | export * from "./focusable"; 3 | export * from "./focus-trap"; 4 | -------------------------------------------------------------------------------- /src/lib/shadow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Traverses the slots of the open shadowroots and returns all children matching the query. 3 | * We need to traverse each child-depth one at a time because if an element should be skipped 4 | * (for example because it is hidden) we need to skip all of it's children. If we use querySelectorAll("*") 5 | * the information of whether the children is within a hidden parent is lost. 6 | * @param {ShadowRoot | HTMLElement} root 7 | * @param skipNode 8 | * @param isMatch 9 | * @param {number} maxDepth 10 | * @param {number} depth 11 | * @returns {HTMLElement[]} 12 | */ 13 | export function queryShadowRoot (root: ShadowRoot | HTMLElement, 14 | skipNode: (($elem: HTMLElement) => boolean), 15 | isMatch: (($elem: HTMLElement) => boolean), 16 | maxDepth: number = 20, 17 | depth: number = 0): HTMLElement[] { 18 | let matches: HTMLElement[] = []; 19 | 20 | // If the depth is above the max depth, abort the searching here. 21 | if (depth >= maxDepth) { 22 | return matches; 23 | } 24 | 25 | // Traverses a slot element 26 | const traverseSlot = ($slot: HTMLSlotElement) => { 27 | 28 | // Only check nodes that are of the type Node.ELEMENT_NODE 29 | // Read more here https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType 30 | const assignedNodes = $slot.assignedNodes().filter(node => node.nodeType === 1); 31 | if (assignedNodes.length > 0) { 32 | const $slotParent = assignedNodes[0].parentElement!; 33 | return queryShadowRoot($slotParent, skipNode, isMatch, maxDepth, depth + 1); 34 | } 35 | 36 | return []; 37 | }; 38 | 39 | // Go through each child and continue the traversing if necessary 40 | // Even though the typing says that children can't be undefined, Edge 15 sometimes gives an undefined value. 41 | // Therefore we fallback to an empty array if it is undefined. 42 | const children = Array.from(root.children || []); 43 | for (const $child of children) { 44 | 45 | // Check if the element and its descendants should be skipped 46 | if (skipNode($child)) { 47 | continue; 48 | } 49 | 50 | // If the element matches we always add it 51 | if (isMatch($child)) { 52 | matches.push($child); 53 | } 54 | 55 | if ($child.shadowRoot != null) { 56 | 57 | // If the element has a shadow root we need to traverse it 58 | matches.push(...queryShadowRoot($child.shadowRoot, skipNode, isMatch, maxDepth, depth + 1)); 59 | 60 | } else if ($child.tagName === "SLOT") { 61 | 62 | // If the child is a slot we need to traverse each assigned node 63 | matches.push(...traverseSlot($child)); 64 | 65 | } else { 66 | 67 | // Traverse the children of the element 68 | matches.push(...queryShadowRoot($child, skipNode, isMatch, maxDepth, depth + 1)); 69 | } 70 | } 71 | 72 | return matches; 73 | } 74 | -------------------------------------------------------------------------------- /src/test/focusable.test.ts: -------------------------------------------------------------------------------- 1 | import { isFocusable } from "../lib/focusable"; 2 | 3 | const expect = chai.expect; 4 | 5 | const testElements: {tag: string, focusable: boolean, attributes?: {[key: string]: string}}[] = [ 6 | 7 | // Elements 8 | {tag: "div", focusable: true, attributes: {tabindex: "0"}}, 9 | {tag: "div", focusable: false}, 10 | {tag: "div", focusable: false, attributes: {tabindex: "0", "aria-disabled": ""}}, 11 | {tag: "div", focusable: true, attributes: {tabindex: "0", "aria-disabled": "false"}}, 12 | {tag: "div", focusable: false, attributes: {tabindex: "0", "aria-hidden": ""}}, 13 | {tag: "div", focusable: true, attributes: {tabindex: "0", "aria-hidden": "false"}}, 14 | {tag: "div", focusable: false, attributes: {tabindex: "0", hidden: ""}}, 15 | {tag: "div", focusable: false, attributes: {tabindex: "0", style: "display: none"}}, 16 | {tag: "div", focusable: false, attributes: {tabindex: "0", style: "visibility: hidden"}}, 17 | {tag: "div", focusable: false, attributes: {tabindex: "0", style: "visibility: collapse"}}, 18 | {tag: "div", focusable: false, attributes: {tabindex: "0", style: "opacity: 0"}}, 19 | 20 | // Links 21 | {tag: "a", focusable: true, attributes: {href: "#"}}, 22 | {tag: "a", focusable: false}, 23 | 24 | // Form elements 25 | {tag: "input", focusable: true}, 26 | {tag: "textarea", focusable: true}, 27 | {tag: "button", focusable: true}, 28 | {tag: "select", focusable: true}, 29 | {tag: "button", focusable: false, attributes: {disabled: ""}}, 30 | {tag: "input", focusable: false, attributes: {disabled: ""}}, 31 | {tag: "textarea", focusable: false, attributes: {disabled: ""}}, 32 | {tag: "button", focusable: false, attributes: {disabled: ""}}, 33 | {tag: "select", focusable: false, attributes: {disabled: ""}}, 34 | {tag: "button", focusable: false, attributes: {"aria-hidden": ""}}, 35 | {tag: "button", focusable: false, attributes: {hidden: ""}}, 36 | 37 | // IFrames 38 | {tag: "iframe", focusable: true} 39 | ]; 40 | 41 | describe("focusable", () => { 42 | beforeEach(async () => { 43 | }); 44 | after(() => { 45 | }); 46 | 47 | it("[isFocusable] - should correctly determine whether an element is focusable", async () => { 48 | for (const elem of testElements) { 49 | const $elem = document.createElement(elem.tag); 50 | if (elem.attributes) { 51 | for (const [key, value] of Object.entries(elem.attributes)) { 52 | $elem.setAttribute(key, value); 53 | } 54 | } 55 | expect(isFocusable($elem)).to.be.equal(elem.focusable); 56 | } 57 | }); 58 | }); 59 | 60 | -------------------------------------------------------------------------------- /src/test/shadow.test.ts: -------------------------------------------------------------------------------- 1 | import { FocusTrap } from "../lib/focus-trap"; 2 | import "../lib/index"; 3 | 4 | const expect = chai.expect; 5 | 6 | const rootTemplate = document.createElement("template"); 7 | rootTemplate.innerHTML = ` 8 | 9 | 10 | 11 | 12 | 13 | 14 | Focusable 15 | 16 | 17 | 18 | Focusble 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Focusable 29 | 30 | 31 | 32 | 33 | 38 | 39 | 40 | Hidden 41 | 42 | 43 | 48 | 53 | 54 | 55 | Hidden 56 | 57 | 58 | 59 | 60 | 61 | 62 | `; 63 | 64 | const template = document.createElement("template"); 65 | template.innerHTML = ` 66 | 67 | Focusable 68 |
Focusable
69 | 70 | 71 | 76 | 77 | 78 | 79 | `; 80 | 81 | const ELEMENT_WITH_FOCUSABLE_CHILDREN_COUNT = 11; 82 | const FOCUSABLE_CHILDREN_PER_ELEMENT_WITH_FOCUSABLE_CHILDREN = 6; 83 | const FOCUSABLE_LEAF_NODE_COUNT = 3; 84 | 85 | class FocusTrapRoot extends HTMLElement { 86 | 87 | $focusTrap!: FocusTrap; 88 | 89 | constructor () { 90 | super(); 91 | 92 | const shadow = this.attachShadow({mode: "open"}); 93 | shadow.appendChild(rootTemplate.content.cloneNode(true)); 94 | } 95 | 96 | connectedCallback () { 97 | this.$focusTrap = this.shadowRoot!.querySelector("focus-trap")!; 98 | } 99 | } 100 | 101 | class ElementWithFocusableChildren extends HTMLElement { 102 | constructor () { 103 | super(); 104 | 105 | const shadow = this.attachShadow({mode: "open"}); 106 | shadow.appendChild(template.content.cloneNode(true)); 107 | } 108 | } 109 | 110 | customElements.define("focus-trap-root", FocusTrapRoot); 111 | customElements.define("element-with-focusable-children", ElementWithFocusableChildren); 112 | 113 | describe("shadow", () => { 114 | 115 | let $root: FocusTrapRoot; 116 | 117 | beforeEach(async () => { 118 | $root = new FocusTrapRoot(); 119 | document.body.appendChild($root); 120 | }); 121 | after(() => { 122 | while (document.body.firstChild) { 123 | (document.body.firstChild).remove(); 124 | } 125 | }); 126 | 127 | it("[shadow] - should traverse nested shadow roots and find all focusable children", async () => { 128 | console.log($root.$focusTrap.getFocusableElements()); 129 | expect($root.$focusTrap.getFocusableElements().length).to.be.equal((ELEMENT_WITH_FOCUSABLE_CHILDREN_COUNT * FOCUSABLE_CHILDREN_PER_ELEMENT_WITH_FOCUSABLE_CHILDREN) + FOCUSABLE_LEAF_NODE_COUNT); 130 | }); 131 | }); 132 | 133 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | "target": "es2017", 7 | "importHelpers": true, 8 | "lib": [ 9 | "es2015.promise", 10 | "dom", 11 | "es7", 12 | "es6", 13 | "es2017", 14 | "es2017.object", 15 | "es2015.proxy", 16 | "esnext" 17 | ] 18 | }, 19 | "include": [ 20 | "src/lib/**/*" 21 | ] 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json" 3 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tslint.json" 3 | } -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | --------------------------------------------------------------------------------