@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
228 lines (195 loc) • 5.21 kB
JavaScript
/**
* Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
* Node module: @schukai/monster
*
* This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
* The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
*
* For those who do not wish to adhere to the AGPLv3, a commercial license is available.
* Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
* For more information about purchasing a commercial license, please contact Volker Schukai.
*
* SPDX-License-Identifier: AGPL-3.0
*/
import { extend } from "../data/extend.mjs";
import { BaseWithOptions } from "../types/basewithoptions.mjs";
import { getGlobalObject } from "../types/global.mjs";
import { isArray } from "../types/is.mjs";
import { Stack } from "../types/stack.mjs";
import { validateInstance, validateString } from "../types/validate.mjs";
import { instanceSymbol } from "../constants.mjs";
export { FocusManager };
/**
* @private
* @type {string}
*/
const KEY_DOCUMENT = "document";
/**
* @private
* @type {string}
*/
const KEY_CONTEXT = "context";
/**
* @private
* @type {Symbol}
*/
const stackSymbol = Symbol("stack");
/**
* With the focus manager the focus can be stored in a document, recalled and moved.
*
* @license AGPLv3
* @since 1.25.0
* @copyright Volker Schukai
* @throws {Error} unsupported locale
* @summary Handle the focus
*/
class FocusManager extends BaseWithOptions {
/**
*
* @param {Object|undefined} options
*/
constructor(options) {
super(options);
validateInstance(this.getOption(KEY_DOCUMENT), HTMLDocument);
this[stackSymbol] = new Stack();
}
/**
* This method is called by the `instanceof` operator.
* @return {symbol}
* @since 2.1.0
*/
static get [instanceSymbol]() {
return Symbol.for("@schukai/monster/dom/focusmanager");
}
/**
* @property {HTMLDocument} document the document object into which the node is to be appended
*/
get defaults() {
return extend({}, super.defaults, {
[KEY_DOCUMENT]: getGlobalObject("document"),
[KEY_CONTEXT]: undefined,
});
}
/**
* Remembers the current focus on a stack.
* Several focus can be stored.
*
* @return {Monster.DOM.FocusManager}
*/
storeFocus() {
const active = this.getActive();
if (active instanceof Node) {
this[stackSymbol].push(active);
}
return this;
}
/**
* The last focus on the stack is set again
*
* @return {Monster.DOM.FocusManager}
*/
restoreFocus() {
const last = this[stackSymbol].pop();
if (last instanceof Node) {
this.focus(last);
}
return this;
}
/**
*
* @param {Node} element
* @param {boolean} preventScroll
* @throws {TypeError} value is not an instance of
* @return {Monster.DOM.FocusManager}
*/
focus(element, preventScroll) {
validateInstance(element, Node);
element.focus({
preventScroll: preventScroll ?? false,
});
return this;
}
/**
*
* @return {Element}
*/
getActive() {
return this.getOption(KEY_DOCUMENT).activeElement;
}
/**
* Select all elements that can be focused
*
* @param {string|undefined} query
* @return {array}
* @throws {TypeError} value is not an instance of
*/
getFocusable(query) {
let contextElement = this.getOption(KEY_CONTEXT);
if (contextElement === undefined) {
contextElement = this.getOption(KEY_DOCUMENT);
}
validateInstance(contextElement, Node);
if (query !== undefined) {
validateString(query);
}
return [
...contextElement.querySelectorAll(
'details, button, input, [tabindex]:not([tabindex="-1"]), select, textarea, a[href], body',
),
].filter((element) => {
if (query !== undefined && !element.matches(query)) {
return false;
}
if (element.hasAttribute("disabled")) return false;
if (element.getAttribute("aria-hidden") === "true") return false;
const rect = element.getBoundingClientRect();
if (rect.width === 0) return false;
if (rect.height === 0) return false;
return true;
});
}
/**
* @param {string} query
* @return {Monster.DOM.FocusManager}
*/
focusNext(query) {
const current = this.getActive();
const focusable = this.getFocusable(query);
if (!isArray(focusable) || focusable.length === 0) {
return this;
}
if (current instanceof Node) {
const index = focusable.indexOf(current);
if (index > -1) {
this.focus(focusable[index + 1] || focusable[0]);
} else {
this.focus(focusable[0]);
}
} else {
this.focus(focusable[0]);
}
return this;
}
/**
* @param {string} query
* @return {Monster.DOM.FocusManager}
*/
focusPrev(query) {
const current = this.getActive();
const focusable = this.getFocusable(query);
if (!isArray(focusable) || focusable.length === 0) {
return this;
}
if (current instanceof Node) {
const index = focusable.indexOf(current);
if (index > -1) {
this.focus(focusable[index - 1] || focusable[focusable.length - 1]);
} else {
this.focus(focusable[focusable.length - 1]);
}
} else {
this.focus(focusable[focusable.length - 1]);
}
return this;
}
}