Files
Rivertalk/assets/js/ui/core/focus-trap.js
T
2026-02-12 17:10:03 -06:00

155 lines
4.1 KiB
JavaScript

// saladui/core/focus-trap.js
/**
* FocusTrap utility for SaladUI components
* Manages focus behavior for modals, popovers, and similar components
*/
class FocusTrap {
/**
* Create a focus trap for a specific element
*
* @param {HTMLElement} element - The element to trap focus within
* @param {Object} options - Focus trap options
*/
constructor(element, options = {}) {
this.element = element;
this.options = {
focusableSelector:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
...options,
};
this.previouslyFocused = null;
this.active = false;
// Bind methods that will be used as event handlers
this.handleKeyDown = this.handleKeyDown.bind(this);
}
/**
* Activate the focus trap
*/
activate() {
if (this.active) return;
// Store currently focused element to restore later
this.previouslyFocused = document.activeElement;
this.active = true;
// Set up event listener for keyboard navigation
this.element.addEventListener("keydown", this.handleKeyDown);
// Focus the first focusable element or the element itself
this.setInitialFocus();
}
/**
* Deactivate the focus trap and restore previous focus
*/
deactivate() {
if (!this.active) return;
// Remove event listeners
this.element.removeEventListener("keydown", this.handleKeyDown);
// Restore focus if possible
if (
this.previouslyFocused &&
this.previouslyFocused.focus &&
this.isElementInViewport(this.previouslyFocused)
) {
setTimeout(() => {
this.previouslyFocused.focus();
this.previouslyFocused = null;
}, 0);
}
this.active = false;
}
/**
* Set initial focus when trap is activated
*/
setInitialFocus() {
// Find all focusable elements
const focusableElements = this.getFocusableElements();
setTimeout(() => {
if (focusableElements.length > 0) {
// Look for an element with autofocus attribute first
const autoFocusEl = this.element.querySelector("[autofocus]");
const initialFocusEl = autoFocusEl || focusableElements[0];
initialFocusEl.focus();
} else {
// If no focusable elements, make the element itself focusable
this.element.setAttribute("tabindex", "-1");
this.element.focus();
}
}, 50); // Small delay to ensure DOM is ready
}
/**
* Handle keydown events for tab trapping and escape handling
*/
handleKeyDown(event) {
// Handle Tab key for focus trapping
if (event.key === "Tab") {
const focusableElements = this.getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
// Create focus loop
if (!event.shiftKey && activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
} else if (event.shiftKey && activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
}
}
/**
* Get all focusable elements within the trap
*/
getFocusableElements() {
return Array.from(
this.element.querySelectorAll(this.options.focusableSelector),
);
}
/**
* Check if an element is currently visible in the viewport
*/
isElementInViewport(element) {
if (!element || !document.body.contains(element)) {
return false;
}
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* Clean up all references when no longer needed
*/
destroy() {
this.deactivate();
this.element = null;
this.options = null;
this.previouslyFocused = null;
}
}
export default FocusTrap;