Skip to content

Commit

Permalink
refactor: add FocusTrapController for trapping focus within a node (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
vursen authored Dec 8, 2021
1 parent 996e4ec commit 9c7a908
Show file tree
Hide file tree
Showing 5 changed files with 414 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/component-base/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { DirMixin } from './src/dir-mixin.js';
export { DisabledMixin } from './src/disabled-mixin.js';
export { ElementMixin } from './src/element-mixin.js';
export { FocusMixin } from './src/focus-mixin.js';
export { FocusTrapController } from './src/focus-trap-controller.js';
export { KeyboardMixin } from './src/keyboard-mixin.js';
export { SlotMixin } from './src/slot-mixin.js';
export { TabindexMixin } from './src/tabindex-mixin.js';
1 change: 1 addition & 0 deletions packages/component-base/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { DirMixin } from './src/dir-mixin.js';
export { DisabledMixin } from './src/disabled-mixin.js';
export { ElementMixin } from './src/element-mixin.js';
export { FocusMixin } from './src/focus-mixin.js';
export { FocusTrapController } from './src/focus-trap-controller.js';
export { KeyboardMixin } from './src/keyboard-mixin.js';
export { SlotMixin } from './src/slot-mixin.js';
export { TabindexMixin } from './src/tabindex-mixin.js';
39 changes: 39 additions & 0 deletions packages/component-base/src/focus-trap-controller.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license
* Copyright (c) 2021 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { ReactiveController } from 'lit';

/**
* A controller for trapping focus within a DOM node.
*/
declare class FocusTrapController implements ReactiveController {
constructor(node: HTMLElement);

hostConnected(): void;

hostDisconnected(): void;

/**
* The controller host element.
*/
host: HTMLElement;

/**
* Activates a focus trap for a DOM node that will prevent focus from escaping the node.
* The trap can be deactivated with the `.releaseFocus()` method.
*
* If focus is initially outside the trap, the method will move focus inside,
* on the first focusable element of the trap in the tab order.
* The first focusable element can be the trap node itself if it is focusable
* and comes first in the tab order.
*/
trapFocus(trapNode: HTMLElement): void;

/**
* Deactivates the focus trap set with the `.trapFocus()` method
* so that it becomes possible to tab outside the trap node.
*/
releaseFocus(): void;
}
139 changes: 139 additions & 0 deletions packages/component-base/src/focus-trap-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* @license
* Copyright (c) 2021 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { getFocusableElements, isElementFocused } from './focus-utils.js';

/**
* A controller for trapping focus within a DOM node.
*/
export class FocusTrapController {
/**
* @param {HTMLElement} host
*/
constructor(host) {
/**
* The controller host element.
*
* @type {HTMLElement}
*/
this.host = host;

/**
* A node for trapping focus in.
*
* @type {HTMLElement | null}
* @private
*/
this.__trapNode = null;

this.__onKeyDown = this.__onKeyDown.bind(this);
}

hostConnected() {
document.addEventListener('keydown', this.__onKeyDown);
}

hostDisconnected() {
document.removeEventListener('keydown', this.__onKeyDown);
}

/**
* Activates a focus trap for a DOM node that will prevent focus from escaping the node.
* The trap can be deactivated with the `.releaseFocus()` method.
*
* If focus is initially outside the trap, the method will move focus inside,
* on the first focusable element of the trap in the tab order.
* The first focusable element can be the trap node itself if it is focusable
* and comes first in the tab order.
*
* If there are no focusable elements, the method will throw an exception
* and the trap will not be set.
*
* @param {HTMLElement} trapNode
*/
trapFocus(trapNode) {
this.__trapNode = trapNode;

if (this.__focusableElements.length === 0) {
this.__trapNode = null;
throw new Error('The trap node should have at least one focusable descendant or be focusable itself.');
}

if (this.__focusedElementIndex === -1) {
this.__focusableElements[0].focus();
}
}

/**
* Deactivates the focus trap set with the `.trapFocus()` method
* so that it becomes possible to tab outside the trap node.
*/
releaseFocus() {
this.__trapNode = null;
}

/**
* A `keydown` event handler that manages tabbing navigation when the trap is enabled.
*
* - Moves focus to the next focusable element of the trap on `Tab` press.
* When no next element to focus, the method moves focus to the first focusable element.
* - Moves focus to the prev focusable element of the trap on `Shift+Tab` press.
* When no prev element to focus, the method moves focus to the last focusable element.
*
* @param {KeyboardEvent} event
* @private
*/
__onKeyDown(event) {
if (!this.__trapNode) {
return;
}

if (event.key === 'Tab') {
event.preventDefault();

const backward = event.shiftKey;
this.__focusNextElement(backward);
}
}

/**
* - Moves focus to the next focusable element if `backward === false`.
* When no next element to focus, the method moves focus to the first focusable element.
* - Moves focus to the prev focusable element if `backward === true`.
* When no prev element to focus the method moves focus to the last focusable element.
*
* If no focusable elements, the method returns immediately.
*
* @param {boolean} backward
* @private
*/
__focusNextElement(backward = false) {
const focusableElements = this.__focusableElements;
const step = backward ? -1 : 1;
const currentIndex = this.__focusedElementIndex;
const nextIndex = (focusableElements.length + currentIndex + step) % focusableElements.length;
focusableElements[nextIndex].focus();
}

/**
* An array of tab-ordered focusable elements inside the trap node.
*
* @return {HTMLElement[]}
* @private
*/
get __focusableElements() {
return getFocusableElements(this.__trapNode);
}

/**
* The index of the element inside the trap node that currently has focus.
*
* @return {HTMLElement | undefined}
* @private
*/
get __focusedElementIndex() {
return this.__focusableElements.findIndex(isElementFocused);
}
}
Loading

0 comments on commit 9c7a908

Please sign in to comment.