# Svelte Common Utilities & Helpers ## Overview A collection of reusable utilities, helpers, and patterns commonly needed in Svelte 5 applications. --- ## 🎣 Custom Hooks (Reactive Utilities) ### useLocalStorage ```typescript // src/lib/hooks/useLocalStorage.svelte.ts import { browser } from '$app/environment'; export function useLocalStorage(key: string, initialValue: T) { let value = $state(initialValue); // Load from localStorage on init if (browser) { const stored = localStorage.getItem(key); if (stored) { try { value = JSON.parse(stored); } catch { value = initialValue; } } } // Sync to localStorage on change $effect(() => { if (browser) { localStorage.setItem(key, JSON.stringify(value)); } }); return { get value() { return value; }, set value(v: T) { value = v; }, reset() { value = initialValue; } }; } // Usage const theme = useLocalStorage('theme', 'light'); // theme.value = 'dark'; ``` ### useDebounce ```typescript // src/lib/hooks/useDebounce.svelte.ts export function useDebounce(getValue: () => T, delay: number = 300) { let debouncedValue = $state(getValue()); let timeout: ReturnType; $effect(() => { const currentValue = getValue(); clearTimeout(timeout); timeout = setTimeout(() => { debouncedValue = currentValue; }, delay); return () => clearTimeout(timeout); }); return { get value() { return debouncedValue; } }; } // Usage let searchQuery = $state(''); const debouncedQuery = useDebounce(() => searchQuery, 300); // Use debouncedQuery.value for API calls ``` ### useThrottle ```typescript // src/lib/hooks/useThrottle.svelte.ts export function useThrottle(getValue: () => T, limit: number = 300) { let throttledValue = $state(getValue()); let lastRun = 0; $effect(() => { const currentValue = getValue(); const now = Date.now(); if (now - lastRun >= limit) { throttledValue = currentValue; lastRun = now; } }); return { get value() { return throttledValue; } }; } ``` ### useMediaQuery ```typescript // src/lib/hooks/useMediaQuery.svelte.ts import { browser } from '$app/environment'; export function useMediaQuery(query: string) { let matches = $state(false); $effect(() => { if (!browser) return; const mediaQuery = window.matchMedia(query); matches = mediaQuery.matches; const handler = (e: MediaQueryListEvent) => { matches = e.matches; }; mediaQuery.addEventListener('change', handler); return () => mediaQuery.removeEventListener('change', handler); }); return { get matches() { return matches; } }; } // Usage const isMobile = useMediaQuery('(max-width: 768px)'); // {#if isMobile.matches}...{/if} ``` ### useOnline ```typescript // src/lib/hooks/useOnline.svelte.ts import { browser } from '$app/environment'; export function useOnline() { let isOnline = $state(browser ? navigator.onLine : true); $effect(() => { if (!browser) return; const handleOnline = () => { isOnline = true; }; const handleOffline = () => { isOnline = false; }; window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }); return { get isOnline() { return isOnline; } }; } ``` ### useClickOutside ```typescript // src/lib/hooks/useClickOutside.svelte.ts export function useClickOutside( getElement: () => HTMLElement | null, callback: () => void ) { $effect(() => { const element = getElement(); if (!element) return; const handler = (event: MouseEvent) => { if (!element.contains(event.target as Node)) { callback(); } }; document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); }); } // Usage let menuRef = $state(); let isOpen = $state(false); useClickOutside( () => menuRef, () => { isOpen = false; } ); ``` ### useIntersectionObserver ```typescript // src/lib/hooks/useIntersectionObserver.svelte.ts interface UseIntersectionOptions { threshold?: number | number[]; root?: Element | null; rootMargin?: string; } export function useIntersectionObserver( getElement: () => HTMLElement | null, options: UseIntersectionOptions = {} ) { let isIntersecting = $state(false); let entry = $state(null); $effect(() => { const element = getElement(); if (!element) return; const observer = new IntersectionObserver( ([e]) => { isIntersecting = e.isIntersecting; entry = e; }, { threshold: options.threshold ?? 0, root: options.root ?? null, rootMargin: options.rootMargin ?? '0px' } ); observer.observe(element); return () => observer.disconnect(); }); return { get isIntersecting() { return isIntersecting; }, get entry() { return entry; } }; } // Usage - Lazy loading let imageRef = $state(); const { isIntersecting } = useIntersectionObserver(() => imageRef); // Load image when isIntersecting becomes true ``` --- ## 🛠️ Utility Functions ### Class Name Utilities ```typescript // src/lib/utils/cn.ts import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // Usage cn('px-4 py-2', isActive && 'bg-blue-500', className) ``` ### Format Utilities ```typescript // src/lib/utils/format.ts // Currency formatting export function formatCurrency( amount: number, currency = 'USD', locale = 'en-US' ): string { return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount); } // Date formatting export function formatDate( date: Date | string | number, options: Intl.DateTimeFormatOptions = {}, locale = 'en-US' ): string { const d = date instanceof Date ? date : new Date(date); return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', ...options }).format(d); } // Relative time export function formatRelativeTime(date: Date | string | number): string { const d = date instanceof Date ? date : new Date(date); const now = new Date(); const diffMs = d.getTime() - now.getTime(); const diffSec = Math.round(diffMs / 1000); const diffMin = Math.round(diffSec / 60); const diffHour = Math.round(diffMin / 60); const diffDay = Math.round(diffHour / 24); const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second'); if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute'); if (Math.abs(diffHour) < 24) return rtf.format(diffHour, 'hour'); return rtf.format(diffDay, 'day'); } // Number formatting export function formatNumber( num: number, options: Intl.NumberFormatOptions = {} ): string { return new Intl.NumberFormat('en-US', options).format(num); } // Compact number (1K, 1M, etc.) export function formatCompact(num: number): string { return new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(num); } // File size export function formatFileSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } ``` ### String Utilities ```typescript // src/lib/utils/string.ts // Truncate text export function truncate(str: string, length: number, ending = '...'): string { if (str.length <= length) return str; return str.slice(0, length - ending.length) + ending; } // Slugify export function slugify(str: string): string { return str .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') .replace(/[\s_-]+/g, '-') .replace(/^-+|-+$/g, ''); } // Capitalize export function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } // Title case export function titleCase(str: string): string { return str .toLowerCase() .split(' ') .map(word => capitalize(word)) .join(' '); } // Pluralize export function pluralize( count: number, singular: string, plural?: string ): string { return count === 1 ? singular : (plural ?? `${singular}s`); } ``` ### Array Utilities ```typescript // src/lib/utils/array.ts // Group by key export function groupBy( array: T[], key: keyof T | ((item: T) => string) ): Record { return array.reduce((groups, item) => { const groupKey = typeof key === 'function' ? key(item) : String(item[key]); groups[groupKey] = [...(groups[groupKey] || []), item]; return groups; }, {} as Record); } // Unique values export function unique(array: T[]): T[] { return [...new Set(array)]; } // Unique by key export function uniqueBy(array: T[], key: keyof T): T[] { const seen = new Set(); return array.filter(item => { const k = item[key]; if (seen.has(k)) return false; seen.add(k); return true; }); } // Chunk array export function chunk(array: T[], size: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < array.length; i += size) { chunks.push(array.slice(i, i + size)); } return chunks; } // Shuffle array export function shuffle(array: T[]): T[] { const result = [...array]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } // Sort by key export function sortBy( array: T[], key: keyof T, order: 'asc' | 'desc' = 'asc' ): T[] { return [...array].sort((a, b) => { const aVal = a[key]; const bVal = b[key]; const compare = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; return order === 'asc' ? compare : -compare; }); } ``` ### Object Utilities ```typescript // src/lib/utils/object.ts // Deep clone export function deepClone(obj: T): T { return structuredClone(obj); } // Pick keys export function pick( obj: T, keys: K[] ): Pick { return keys.reduce((result, key) => { if (key in obj) result[key] = obj[key]; return result; }, {} as Pick); } // Omit keys export function omit( obj: T, keys: K[] ): Omit { const result = { ...obj }; for (const key of keys) { delete result[key]; } return result; } // Deep equal export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b) return false; if (a == null || b == null) return false; if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; return a.every((val, i) => deepEqual(val, b[i])); } if (typeof a === 'object' && typeof b === 'object') { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; return keysA.every(key => deepEqual((a as any)[key], (b as any)[key]) ); } return false; } ``` --- ## 🎯 Svelte Actions ### Focus Trap ```typescript // src/lib/actions/focusTrap.ts export function focusTrap(node: HTMLElement) { const focusableElements = node.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const first = focusableElements[0] as HTMLElement; const last = focusableElements[focusableElements.length - 1] as HTMLElement; function handleKeydown(e: KeyboardEvent) { if (e.key !== 'Tab') return; if (e.shiftKey) { if (document.activeElement === first) { last.focus(); e.preventDefault(); } } else { if (document.activeElement === last) { first.focus(); e.preventDefault(); } } } node.addEventListener('keydown', handleKeydown); first?.focus(); return { destroy() { node.removeEventListener('keydown', handleKeydown); } }; } ``` ### Tooltip ```typescript // src/lib/actions/tooltip.ts interface TooltipOptions { text: string; position?: 'top' | 'bottom' | 'left' | 'right'; } export function tooltip(node: HTMLElement, options: TooltipOptions) { let tooltipEl: HTMLDivElement | null = null; function show() { tooltipEl = document.createElement('div'); tooltipEl.className = `tooltip tooltip-${options.position ?? 'top'}`; tooltipEl.textContent = options.text; document.body.appendChild(tooltipEl); const rect = node.getBoundingClientRect(); const tipRect = tooltipEl.getBoundingClientRect(); // Position based on option switch (options.position ?? 'top') { case 'top': tooltipEl.style.left = `${rect.left + rect.width / 2 - tipRect.width / 2}px`; tooltipEl.style.top = `${rect.top - tipRect.height - 8}px`; break; case 'bottom': tooltipEl.style.left = `${rect.left + rect.width / 2 - tipRect.width / 2}px`; tooltipEl.style.top = `${rect.bottom + 8}px`; break; // ... other positions } } function hide() { tooltipEl?.remove(); tooltipEl = null; } node.addEventListener('mouseenter', show); node.addEventListener('mouseleave', hide); return { update(newOptions: TooltipOptions) { options = newOptions; }, destroy() { hide(); node.removeEventListener('mouseenter', show); node.removeEventListener('mouseleave', hide); } }; } ``` ### Auto-resize Textarea ```typescript // src/lib/actions/autoResize.ts export function autoResize(node: HTMLTextAreaElement) { function resize() { node.style.height = 'auto'; node.style.height = `${node.scrollHeight}px`; } resize(); node.addEventListener('input', resize); return { destroy() { node.removeEventListener('input', resize); } }; } // Usage: