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 | [](#installation)
31 |
32 | ## ➤ Installation
33 |
34 | ```javascript
35 | npm i @a11y/focus-trap
36 | ```
37 |
38 |
39 |
40 | [](#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 | [](#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 | [](#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 |
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 |
34 |
35 | Hidden
36 |
37 |
38 |
39 |
40 | Hidden
41 |
42 |
43 |
44 |
45 | Hidden
46 |
47 |
48 |
49 |
50 | Hidden
51 |
52 |
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 |