@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
JavaScript
/**
* 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;
}