UNPKG

aura-glass

Version:

A comprehensive glassmorphism design system for React applications with 142+ production-ready components

216 lines (213 loc) 6.71 kB
'use client'; import { jsx, Fragment, jsxs } from 'react/jsx-runtime'; import { useRef, useCallback, useEffect } from 'react'; const DEFAULT_FOCUSABLE_SELECTOR = ['a[href]:not([disabled])', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]', 'audio[controls]', 'video[controls]', 'details>summary:first-of-type', 'details'].join(','); /** * FocusTrap component * Traps focus within a container for accessibility */ function FocusTrap({ children, active = true, restoreFocus = true, autoFocus = true, allowEscape = true, onEscape, exclude = [], lockScroll = false, focusableSelector = DEFAULT_FOCUSABLE_SELECTOR }) { const containerRef = useRef(null); const previousFocusRef = useRef(null); const sentinelStartRef = useRef(null); const sentinelEndRef = useRef(null); // Get all focusable elements within the container const getFocusableElements = useCallback(() => { if (!containerRef.current) return []; const elements = Array.from(containerRef.current.querySelectorAll(focusableSelector)); // Filter out excluded elements if (exclude.length > 0) { return elements.filter(el => { return !exclude.some(selector => el.matches(selector)); }); } return elements.filter(el => { // Check if element is visible and not hidden const style = window.getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && el.offsetParent !== null; }); }, [focusableSelector, exclude]); // Focus the first focusable element const focusFirst = useCallback(() => { const elements = getFocusableElements(); if (elements.length > 0) { elements[0].focus(); } }, [getFocusableElements]); // Focus the last focusable element const focusLast = useCallback(() => { const elements = getFocusableElements(); if (elements.length > 0) { elements[elements.length - 1].focus(); } }, [getFocusableElements]); // Handle tab key navigation const handleTabKey = useCallback(event => { if (!active || !containerRef.current) return; const focusableElements = getFocusableElements(); if (focusableElements.length === 0) return; const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const activeElement = document.activeElement; // Tab forward if (!event.shiftKey) { if (activeElement === lastElement || !containerRef.current.contains(activeElement)) { event.preventDefault(); firstElement.focus(); } } // Tab backward else { if (activeElement === firstElement || !containerRef.current.contains(activeElement)) { event.preventDefault(); lastElement.focus(); } } }, [active, getFocusableElements]); // Handle escape key const handleEscapeKey = useCallback(event => { if (!active || !allowEscape) return; if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onEscape?.(); } }, [active, allowEscape, onEscape]); // Handle sentinel focus (for screen readers) const handleSentinelFocus = useCallback(position => { if (!active) return; if (position === 'start') { focusLast(); } else { focusFirst(); } }, [active, focusFirst, focusLast]); // Lock scroll when active useEffect(() => { if (!lockScroll) return; if (active) { const scrollY = window.scrollY; const body = document.body; body.style.position = 'fixed'; body.style.top = `-${scrollY}px`; body.style.width = '100%'; body.style.overflow = 'hidden'; return () => { body.style.position = ''; body.style.top = ''; body.style.width = ''; body.style.overflow = ''; window.scrollTo(0, scrollY); }; } }, [active, lockScroll]); // Set up focus trap useEffect(() => { if (!active) return; // Store current focus if (restoreFocus) { previousFocusRef.current = document.activeElement; } // Auto focus first element if (autoFocus) { // Use setTimeout to ensure DOM is ready setTimeout(() => { focusFirst(); }, 0); } // Add event listeners const handleKeyDown = event => { if (event.key === 'Tab') { handleTabKey(event); } else if (event.key === 'Escape') { handleEscapeKey(event); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); // Restore focus if (restoreFocus && previousFocusRef.current) { previousFocusRef.current.focus(); } }; }, [active, autoFocus, restoreFocus, focusFirst, handleTabKey, handleEscapeKey]); // Handle focus outside of trap useEffect(() => { if (!active) return; const handleFocusIn = event => { const target = event.target; // Check if focus moved outside the container if (containerRef.current && !containerRef.current.contains(target)) { event.preventDefault(); event.stopPropagation(); focusFirst(); } }; // Use capture phase to intercept focus before it reaches the target document.addEventListener('focusin', handleFocusIn, true); return () => { document.removeEventListener('focusin', handleFocusIn, true); }; }, [active, focusFirst]); if (!active) { return jsx(Fragment, { children: children }); } return jsxs(Fragment, { children: [jsx("div", { ref: sentinelStartRef, tabIndex: 0, onFocus: () => handleSentinelFocus('start'), "aria-hidden": "true", style: { position: 'fixed', top: 0, left: 0, width: 1, height: 0, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', border: 0 } }), jsx("div", { ref: containerRef, "data-focus-trap": "true", children: children }), jsx("div", { ref: sentinelEndRef, tabIndex: 0, onFocus: () => handleSentinelFocus('end'), "aria-hidden": "true", style: { position: 'fixed', top: 0, left: 0, width: 1, height: 0, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', border: 0 } })] }); } export { FocusTrap }; //# sourceMappingURL=FocusTrap.js.map