UNPKG

focus-trap-lite

Version:

Lightweight (≤2kB) focus trapping utility for implementing accessible keyboard navigation constraints in modal dialogs, sidebars, and other contained UI components.

140 lines (121 loc) 3.57 kB
const focusTrapStack = [] /** * Initializes focus trap with custom container and selector * @param {Object} [container] - Container element to scope the focus trap * @param {string} [selector] - CSS selector for focusable elements (optional) * @param {Object} [options] - Options for focus trap * @param {HTMLElement|string} [options.firstFocusableElement] - The first element to focus (or selector). * @param {boolean} [options.focus] - Whether to focus on the first element on initialization. Default false. * @returns {void} * * @description When called with single parameter: * - Default selector: 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])' */ export function initFocusTrap(element, selector, options) { const trapId = Symbol('focusTrap') focusTrapStack.push(trapId) let root = element || document let query = selector || 'a[href], button, input, textarea, select, details, [tabindex]' let first let last let active = false const getInitial = () => { let target = first if (options?.firstFocusableElement) { if (typeof options.firstFocusableElement === 'string') { const found = root.querySelector(options.firstFocusableElement) if (found) { target = found } } else if ( typeof options.firstFocusableElement === 'object' && root.contains(options.firstFocusableElement) ) { target = options.firstFocusableElement } } return target } const scan = () => { const list = Array.from(root.querySelectorAll(query)).filter((element) => { if (element.disabled === true) return false if (element.tabIndex === -1) return false if ( element.offsetWidth === 0 && element.offsetHeight === 0 && element.getClientRects().length === 0 ) return false if ( globalThis.getComputedStyle(element).visibility === 'hidden' || globalThis.getComputedStyle(element).display === 'none' ) return false return true }) first = list[0] last = list[list.length - 1] if (!first || !last || !document.contains(first)) { destroy() } } const init = () => { scan() if (options?.focus) { const target = getInitial() if (target) target.focus() } } const onKey = (event) => { if ( focusTrapStack.length > 0 && focusTrapStack[focusTrapStack.length - 1] !== trapId ) { return } if (event.key === 'Tab') { scan() if (!first || !last) { destroy() return } const activeElement = document.activeElement const isLoseFocus = !root.contains(activeElement) if (event.shiftKey) { if (activeElement === first || isLoseFocus) { last.focus() event.preventDefault() } } else if (activeElement === last || isLoseFocus) { first.focus() event.preventDefault() } } else if (event.key === 'Escape') { destroy() } } function destroy() { if (active) { const index = focusTrapStack.indexOf(trapId) if (index !== -1) { focusTrapStack.splice(index, 1) } active = false first = undefined last = undefined query = undefined root = undefined document.removeEventListener('keydown', onKey) } } if (!active) { document.addEventListener('keydown', onKey) active = true } init() return { destroy, container: root, } }