Files
Job-Info/svelte-reference/12-UTILITIES.md
T
aewing 0b76a4b119 Cleanup: Remove old modes, add Svelte 5 reference library, update orchestrator
- 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
2026-01-21 03:56:01 +00:00

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';