/** * @author schukai GmbH */ 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"; 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 focusmanager the focus can be stored in a document, recalled and moved. * * ``` * <script type="module"> * import {FocusManager} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/source/dom/focusmanager.mjs'; * new FocusManager() * </script> * ``` * * @since 1.25.0 * @copyright schukai GmbH * @memberOf Monster.DOM * @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(); } /** * @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) { let 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) { let 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; } }