0b76a4b119
- Delete Mode1Test, Mode2Test, Mode3Test, Mode5Test (keep only Mode6Test) - Delete old documentation, Docker files, and k8s configs - Add comprehensive Svelte 5 reference library (svelte-reference/) - Update ModeSwitch orchestrator: - Fix port to 3005 (never use 3000/3001) - Add API endpoints for mode switching - Add bun-types and proper tsconfig - Clean up mode folder mappings
16 KiB
16 KiB
Svelte Common Utilities & Helpers
Overview
A collection of reusable utilities, helpers, and patterns commonly needed in Svelte 5 applications.
🎣 Custom Hooks (Reactive Utilities)
useLocalStorage
// src/lib/hooks/useLocalStorage.svelte.ts
import { browser } from '$app/environment';
export function useLocalStorage<T>(key: string, initialValue: T) {
let value = $state<T>(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
// src/lib/hooks/useDebounce.svelte.ts
export function useDebounce<T>(getValue: () => T, delay: number = 300) {
let debouncedValue = $state<T>(getValue());
let timeout: ReturnType<typeof setTimeout>;
$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
// src/lib/hooks/useThrottle.svelte.ts
export function useThrottle<T>(getValue: () => T, limit: number = 300) {
let throttledValue = $state<T>(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
// 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
// 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
// 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<HTMLElement>();
let isOpen = $state(false);
useClickOutside(
() => menuRef,
() => { isOpen = false; }
);
useIntersectionObserver
// 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<IntersectionObserverEntry | null>(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<HTMLElement>();
const { isIntersecting } = useIntersectionObserver(() => imageRef);
// Load image when isIntersecting becomes true
🛠️ Utility Functions
Class Name Utilities
// 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
// 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
// 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
// src/lib/utils/array.ts
// Group by key
export function groupBy<T>(
array: T[],
key: keyof T | ((item: T) => string)
): Record<string, T[]> {
return array.reduce((groups, item) => {
const groupKey = typeof key === 'function' ? key(item) : String(item[key]);
groups[groupKey] = [...(groups[groupKey] || []), item];
return groups;
}, {} as Record<string, T[]>);
}
// Unique values
export function unique<T>(array: T[]): T[] {
return [...new Set(array)];
}
// Unique by key
export function uniqueBy<T>(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<T>(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<T>(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<T>(
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
// src/lib/utils/object.ts
// Deep clone
export function deepClone<T>(obj: T): T {
return structuredClone(obj);
}
// Pick keys
export function pick<T extends object, K extends keyof T>(
obj: T,
keys: K[]
): Pick<T, K> {
return keys.reduce((result, key) => {
if (key in obj) result[key] = obj[key];
return result;
}, {} as Pick<T, K>);
}
// Omit keys
export function omit<T extends object, K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> {
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
// 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
// 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
// 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: <textarea use:autoResize />
Copy to Clipboard
// src/lib/actions/clipboard.ts
interface ClipboardOptions {
text: string;
onCopy?: () => void;
onError?: (error: Error) => void;
}
export function clipboard(node: HTMLElement, options: ClipboardOptions) {
async function handleClick() {
try {
await navigator.clipboard.writeText(options.text);
options.onCopy?.();
} catch (error) {
options.onError?.(error as Error);
}
}
node.addEventListener('click', handleClick);
return {
update(newOptions: ClipboardOptions) {
options = newOptions;
},
destroy() {
node.removeEventListener('click', handleClick);
}
};
}
// Usage
// <button use:clipboard={{ text: 'Copy me!', onCopy: () => showToast('Copied!') }}>
// Copy
// </button>
📋 Type Utilities
// src/lib/types/utils.ts
// Make all properties optional recursively
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Make specified keys required
export type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Extract array element type
export type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
// Async function return type
export type AsyncReturnType<T extends (...args: any) => Promise<any>> =
T extends (...args: any) => Promise<infer R> ? R : never;
// Make all properties non-nullable
export type NonNullableProps<T> = {
[P in keyof T]: NonNullable<T[P]>;
};
// Record with known keys
export type StrictRecord<K extends string, V> = { [P in K]: V };
🔗 Export Index
// src/lib/utils/index.ts
export * from './cn';
export * from './format';
export * from './string';
export * from './array';
export * from './object';
// src/lib/hooks/index.ts
export * from './useLocalStorage.svelte';
export * from './useDebounce.svelte';
export * from './useThrottle.svelte';
export * from './useMediaQuery.svelte';
export * from './useOnline.svelte';
export * from './useClickOutside.svelte';
export * from './useIntersectionObserver.svelte';
// src/lib/actions/index.ts
export * from './focusTrap';
export * from './tooltip';
export * from './autoResize';
export * from './clipboard';