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
736 lines
16 KiB
Markdown
736 lines
16 KiB
Markdown
# 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<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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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<HTMLElement>();
|
|
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<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
|
|
|
|
```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<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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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: <textarea use:autoResize />
|
|
```
|
|
|
|
### Copy to Clipboard
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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';
|
|
```
|