UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

219 lines (218 loc) 6.99 kB
/** * Svelte actions for ClarityKit components * Provides reusable DOM interactions and behaviors */ import { getThemeManager } from './theme'; import { isBrowser, safelyAccessDOM } from './environment'; /** * Auto-theme action that applies theme management to an element * Automatically initializes theme on mount and handles theme changes * * @param node - The DOM element to apply theming to * @param options - Configuration options for theme behavior * @returns Action object with destroy method */ export function autoTheme(node, options = {}) { const { applyToElement = false, onThemeChange } = options; const themeManager = getThemeManager(); let unsubscribe = null; // Initialize theme const currentTheme = themeManager.getCurrentTheme(); if (applyToElement) { // Apply theme class to the specific element node.classList.remove('ck-theme-light', 'ck-theme-dark'); node.classList.add(`ck-theme-${currentTheme}`); } // Set up theme change listener unsubscribe = themeManager.onThemeChange((theme) => { if (applyToElement) { node.classList.remove('ck-theme-light', 'ck-theme-dark'); node.classList.add(`ck-theme-${theme}`); } if (onThemeChange) { onThemeChange(theme); } }); return { destroy() { if (unsubscribe) { unsubscribe(); } } }; } /** * Click outside action - triggers callback when clicking outside the element * Useful for modals, dropdowns, and other overlay components * * @param node - The DOM element to watch for outside clicks * @param callback - Function to call when clicking outside * @returns Action object with destroy method */ export function clickOutside(node, callback) { function handleClick(event) { if (!node.contains(event.target)) { callback(); } } if (isBrowser) { safelyAccessDOM(() => { document.addEventListener('click', handleClick, true); }); } return { destroy() { if (isBrowser) { safelyAccessDOM(() => { document.removeEventListener('click', handleClick, true); }); } } }; } /** * Focus trap action - keeps focus within the element * Useful for modals and other overlay components that need to manage focus * * @param node - The DOM element to trap focus within * @param options - Configuration options * @returns Action object with destroy method */ export function focusTrap(node, options = {}) { const { active = true, initialFocus } = options; let isActive = active; let focusableElements = []; function updateFocusableElements() { focusableElements = Array.from(node.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')); } function handleKeyDown(event) { if (!isActive || event.key !== 'Tab') return; updateFocusableElements(); if (focusableElements.length === 0) return; const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const activeElement = safelyAccessDOM(() => document.activeElement); if (event.shiftKey && activeElement === firstElement) { lastElement.focus(); event.preventDefault(); } else if (!event.shiftKey && activeElement === lastElement) { firstElement.focus(); event.preventDefault(); } } function activate() { if (!isActive) return; updateFocusableElements(); // Focus initial element if (initialFocus) { const target = typeof initialFocus === 'string' ? node.querySelector(initialFocus) : initialFocus; if (target) { target.focus(); } } else if (focusableElements.length > 0) { focusableElements[0].focus(); } } // Set up immediately if active if (isActive) { setTimeout(activate, 0); // Use setTimeout to ensure DOM is ready } node.addEventListener('keydown', handleKeyDown); return { update(newOptions) { isActive = newOptions.active ?? true; if (isActive) { activate(); } }, destroy() { node.removeEventListener('keydown', handleKeyDown); } }; } /** * Auto-resize action for textareas * Automatically adjusts textarea height based on content * * @param node - The textarea element * @param options - Configuration options * @returns Action object with update and destroy methods */ export function autoResize(node, options = {}) { const { minHeight = 0, maxHeight = 500 } = options; function resize() { node.style.height = 'auto'; const scrollHeight = node.scrollHeight; const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); node.style.height = `${newHeight}px`; } // Initial resize resize(); node.addEventListener('input', resize); return { update(newOptions) { Object.assign(options, newOptions); resize(); }, destroy() { node.removeEventListener('input', resize); } }; } /** * Portal action - renders content in a different DOM location * Useful for modals, tooltips, and other overlay components * * @param node - The element to portal * @param target - Target element or selector where content should be rendered * @returns Action object with update and destroy methods */ export function portal(node, target = 'body') { let targetElement = null; function getTargetElement() { if (typeof target === 'string') { if (!isBrowser) { return null; // Return null on server, portal won't work anyway } const element = safelyAccessDOM(() => { return document.querySelector(target); }); if (!element) { throw new Error(`Portal target "${target}" not found`); } return element; } return target; } function mount() { if (!isBrowser) return; // Skip on server targetElement = getTargetElement(); if (targetElement) { targetElement.appendChild(node); } } function unmount() { if (node.parentNode) { node.parentNode.removeChild(node); } } mount(); return { update(newTarget) { unmount(); target = newTarget; mount(); }, destroy() { unmount(); } }; }