155 lines
4.1 KiB
JavaScript
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;
|