UNPKG

@senka-ai/ui

Version:

A modern, type-safe Svelte 5 UI component library with full theme support, accessibility standards, and robust state management patterns

391 lines (390 loc) 11.3 kB
/** * Focus management utilities for consistent focus handling across components * * These utilities provide standardized patterns for managing focus states, * focus trapping, and keyboard navigation in interactive components. * * Uses .svelte.ts extension to support Svelte 5 runes */ /** * Enhanced focus state management with event handling */ export function useFocusManagement(options = {}) { let focused = $state(false); let element = $state(null); const { onFocus, onBlur, disabled = false, autoFocus = false } = options; function handleFocus(event) { if (disabled) return; focused = true; onFocus?.(event); } function handleBlur(event) { if (disabled) return; focused = false; onBlur?.(event); } function focus() { if (element && !disabled) { element.focus(); } } function blur() { if (element && !disabled) { element.blur(); } } // Auto-focus on mount if requested $effect(() => { if (autoFocus && element && !disabled) { element.focus(); } }); return { focused: () => focused, setElement: (el) => { element = el; }, handleFocus, handleBlur, focus, blur, disabled: () => disabled, }; } /** * Focus trap utility for modals and dropdowns */ export function useFocusTrap(enabled = true) { let container = $state(null); let firstFocusable = $state(null); let lastFocusable = $state(null); let previouslyFocused = $state(null); const focusableSelector = [ 'button:not([disabled])', '[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"]):not([disabled])', '[contenteditable="true"]', ].join(','); function updateFocusableElements() { if (!container) return; const focusableElements = Array.from(container.querySelectorAll(focusableSelector)).filter((el) => { const isDisabled = 'disabled' in el && el.disabled; return !isDisabled && el.tabIndex !== -1; }); firstFocusable = focusableElements[0] || null; lastFocusable = focusableElements[focusableElements.length - 1] || null; } function handleKeyDown(event) { if (!enabled || event.key !== 'Tab') return; updateFocusableElements(); if (!firstFocusable && !lastFocusable) return; if (event.shiftKey) { // Shift + Tab if (document.activeElement === firstFocusable) { event.preventDefault(); lastFocusable?.focus(); } } else { // Tab if (document.activeElement === lastFocusable) { event.preventDefault(); firstFocusable?.focus(); } } } function activate() { if (!enabled || !container) return; previouslyFocused = document.activeElement; updateFocusableElements(); // Focus first focusable element if (firstFocusable) { firstFocusable.focus(); } document.addEventListener('keydown', handleKeyDown); } function deactivate() { document.removeEventListener('keydown', handleKeyDown); // Restore previous focus if (previouslyFocused && document.contains(previouslyFocused)) { previouslyFocused.focus(); } } // Reactive effect to manage trap state $effect(() => { if (enabled) { activate(); } else { deactivate(); } // Cleanup on destroy return () => deactivate(); }); return { setContainer: (el) => { container = el; }, activate, deactivate, updateFocusableElements, }; } /** * Keyboard navigation utilities for lists and menus */ export function useKeyboardNavigation(options) { let activeIndex = $state(0); const { items, onSelect, onEscape, loop = true, orientation = 'vertical' } = options; function setActiveIndex(index) { if (items.length === 0) return; if (loop) { activeIndex = ((index % items.length) + items.length) % items.length; } else { activeIndex = Math.max(0, Math.min(index, items.length - 1)); } } function moveNext() { setActiveIndex(activeIndex + 1); } function movePrevious() { setActiveIndex(activeIndex - 1); } function selectCurrent() { const currentItem = items[activeIndex]; if (currentItem !== undefined) { onSelect?.(currentItem, activeIndex); } } function handleKeyDown(event) { const keys = orientation === 'vertical' ? { next: 'ArrowDown', previous: 'ArrowUp' } : { next: 'ArrowRight', previous: 'ArrowLeft' }; switch (event.key) { case keys.next: event.preventDefault(); moveNext(); break; case keys.previous: event.preventDefault(); movePrevious(); break; case 'Enter': case ' ': event.preventDefault(); selectCurrent(); break; case 'Escape': event.preventDefault(); onEscape?.(); break; case 'Home': event.preventDefault(); setActiveIndex(0); break; case 'End': event.preventDefault(); setActiveIndex(items.length - 1); break; } } return { activeIndex: () => activeIndex, setActiveIndex, moveNext, movePrevious, selectCurrent, handleKeyDown, }; } /** * Focus visible utility for better UX * Only shows focus rings when navigating via keyboard */ export function useFocusVisible() { let focusVisible = $state(false); let hadKeyboardEvent = $state(false); function handleKeyDown(event) { if (event.metaKey || event.altKey || event.ctrlKey) return; hadKeyboardEvent = true; } function handlePointerDown() { hadKeyboardEvent = false; } function handleFocus() { focusVisible = hadKeyboardEvent; } function handleBlur() { focusVisible = false; } // Set up global event listeners $effect(() => { document.addEventListener('keydown', handleKeyDown, true); document.addEventListener('mousedown', handlePointerDown, true); document.addEventListener('pointerdown', handlePointerDown, true); return () => { document.removeEventListener('keydown', handleKeyDown, true); document.removeEventListener('mousedown', handlePointerDown, true); document.removeEventListener('pointerdown', handlePointerDown, true); }; }); return { focusVisible: () => focusVisible, handleFocus, handleBlur, }; } /** * Auto-focus management for components */ export function useAutoFocus(shouldAutoFocus = false, delay = 0) { let element = $state(null); $effect(() => { if (shouldAutoFocus && element) { if (delay > 0) { setTimeout(() => element?.focus(), delay); } else { element.focus(); } } }); return { setElement: (el) => { element = el; }, }; } /** * Focus restoration utility * Saves and restores focus when components mount/unmount */ export function useFocusRestore() { let previouslyFocused = $state(null); function save() { previouslyFocused = document.activeElement; } function restore() { if (previouslyFocused && document.contains(previouslyFocused)) { previouslyFocused.focus(); } } // Auto-save on mount $effect(() => { save(); // Auto-restore on destroy return () => restore(); }); return { save, restore, previouslyFocused: () => previouslyFocused, }; } /** * Click outside detection with focus management */ export function useClickOutside(callback, enabled = true) { let element = $state(null); function handleClick(event) { if (!enabled || !element) return; if (!element.contains(event.target)) { callback(); } } $effect(() => { if (enabled) { document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); } }); return { setElement: (el) => { element = el; }, }; } /** * Roving tabindex management for composite components */ export function useRovingTabIndex(items, initialIndex = 0) { let activeIndex = $state(initialIndex); function getTabIndex(index) { return index === activeIndex ? 0 : -1; } function getAriaSelected(index) { return index === activeIndex; } function setActiveIndex(index) { activeIndex = Math.max(0, Math.min(index, items.length - 1)); } return { activeIndex: () => activeIndex, setActiveIndex, getTabIndex, getAriaSelected, }; } /** * Focus management constants and utilities */ export const FocusConstants = { FOCUSABLE_SELECTORS: [ 'button:not([disabled])', '[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"]):not([disabled])', '[contenteditable="true"]', ], NAVIGATION_KEYS: ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'], ACTIVATION_KEYS: ['Enter', ' '], ESCAPE_KEYS: ['Escape'], }; /** * Utility to find all focusable elements within a container */ export function findFocusableElements(container) { return Array.from(container.querySelectorAll(FocusConstants.FOCUSABLE_SELECTORS.join(','))).filter((el) => { const isDisabled = 'disabled' in el && el.disabled; return el.offsetWidth > 0 && el.offsetHeight > 0 && !isDisabled; }); } /** * Utility to focus the first focusable element in a container */ export function focusFirstElement(container) { const focusableElements = findFocusableElements(container); const firstElement = focusableElements[0]; if (firstElement) { firstElement.focus(); return true; } return false; } /** * Utility to focus the last focusable element in a container */ export function focusLastElement(container) { const focusableElements = findFocusableElements(container); const lastElement = focusableElements[focusableElements.length - 1]; if (lastElement) { lastElement.focus(); return true; } return false; }