aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
216 lines (213 loc) • 6.71 kB
JavaScript
'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