UNPKG

flowbite-svelte

Version:

Flowbite components for Svelte

108 lines (107 loc) 4.18 kB
/** * Svelte action that traps focus within a DOM node and handles Escape key * @param node - The DOM node to trap focus within * @param options - Optional configuration object * @returns An action object with destroy method */ export function trapFocus(node, options = {}) { // If options is null, don't trap focus at all if (options === null) { return { update(newOptions = {}) { options = newOptions; }, destroy() { } }; } const previous = document.activeElement; // Track if we're currently closing via outside click let isClosingViaOutsideClick = false; // Create a flag to prevent re-focusing when focus is moved outside let isFocusMovedOutside = false; function focusable() { return Array.from(node.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')); } function handleKeydown(event) { if (event.key === "Tab" && options !== null) { const current = document.activeElement; const elements = focusable(); const first = elements.at(0); const last = elements.at(-1); if (event.shiftKey && current === first) { last?.focus(); event.preventDefault(); } if (!event.shiftKey && current === last) { first?.focus(); event.preventDefault(); } } else if (event.key === "Escape" && options !== null && options.onEscape) { event.preventDefault(); // Mark as closing via escape to prevent focus restoration isClosingViaOutsideClick = true; options.onEscape(); } } // Handler for when focus moves outside the trapped area function handleFocusOut(event) { // If focus is moving outside our node and not to one of our triggers if (!node.contains(event.relatedTarget) && event.relatedTarget !== previous) { isFocusMovedOutside = true; } } // Initialize the action function initialize() { // Only add event listeners if options is not null if (options !== null) { // Check if we're currently in a closing state isClosingViaOutsideClick = !!options.isClosing; // Only auto-focus if not closing from outside click if (!isClosingViaOutsideClick && !isFocusMovedOutside) { const elements = focusable(); if (elements.length > 0) { elements[0].focus(); } } node.addEventListener("keydown", handleKeydown); node.addEventListener("focusout", handleFocusOut); } } // Cleanup function function cleanup() { if (options !== null) { node.removeEventListener("keydown", handleKeydown); node.removeEventListener("focusout", handleFocusOut); // Only restore focus if not closing via outside click and focus hasn't moved outside if (!isClosingViaOutsideClick && !isFocusMovedOutside && previous) { setTimeout(() => { previous.focus({ preventScroll: true }); }, 0); } } } // Initialize on mount initialize(); // Return the action object with update and destroy methods return { update(newOptions = {}) { // Clean up existing listeners first node.removeEventListener("keydown", handleKeydown); node.removeEventListener("focusout", handleFocusOut); // Update the closing state if (newOptions && newOptions.isClosing !== undefined) { isClosingViaOutsideClick = newOptions.isClosing; } options = newOptions; // Reinitialize with new options if (options !== null) { node.addEventListener("keydown", handleKeydown); node.addEventListener("focusout", handleFocusOut); } }, destroy() { cleanup(); } }; }