Files
Job-Info/svelte-reference/02-COMPONENT_PATTERNS.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

13 KiB
Raw Blame History

Svelte 5 Component Patterns

📦 Component Structure Template

Every Svelte component should follow this structure:

<script lang="ts" module>
  // Module-level code (runs once, shared between instances)
  // Use for types, constants, or shared state
  export interface ButtonProps {
    variant?: 'primary' | 'secondary' | 'danger';
    size?: 'sm' | 'md' | 'lg';
    disabled?: boolean;
  }
</script>

<script lang="ts">
  // Instance-level code (runs for each component instance)
  import type { Snippet } from 'svelte';
  import type { HTMLButtonAttributes } from 'svelte/elements';
  
  interface Props extends ButtonProps, HTMLButtonAttributes {
    children: Snippet;
  }
  
  let { 
    variant = 'primary',
    size = 'md',
    disabled = false,
    children,
    ...rest 
  }: Props = $props();
  
  // Derived values
  let classes = $derived(
    `btn btn-${variant} btn-${size} ${disabled ? 'btn-disabled' : ''}`
  );
</script>

<button class={classes} {disabled} {...rest}>
  {@render children()}
</button>

<style>
  .btn {
    /* Base styles */
  }
</style>

🎨 Pattern 1: Presentational Components

Pure UI components with no business logic.

<!-- Card.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  
  interface Props {
    title?: string;
    header?: Snippet;
    children: Snippet;
    footer?: Snippet;
    variant?: 'default' | 'elevated' | 'outlined';
  }
  
  let { 
    title,
    header,
    children, 
    footer,
    variant = 'default'
  }: Props = $props();
</script>

<article class="card card-{variant}">
  {#if header}
    <header class="card-header">
      {@render header()}
    </header>
  {:else if title}
    <header class="card-header">
      <h3>{title}</h3>
    </header>
  {/if}
  
  <div class="card-body">
    {@render children()}
  </div>
  
  {#if footer}
    <footer class="card-footer">
      {@render footer()}
    </footer>
  {/if}
</article>

<style>
  .card {
    border-radius: 0.5rem;
    overflow: hidden;
  }
  
  .card-default {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
  }
  
  .card-elevated {
    background: var(--color-surface);
    box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  }
  
  .card-outlined {
    background: transparent;
    border: 2px solid var(--color-border);
  }
</style>

🔧 Pattern 2: Container Components

Components that manage state and pass it down.

<!-- TodoList.svelte -->
<script lang="ts">
  import TodoItem from './TodoItem.svelte';
  import AddTodo from './AddTodo.svelte';
  
  interface Todo {
    id: number;
    text: string;
    completed: boolean;
  }
  
  let todos = $state<Todo[]>([]);
  let filter = $state<'all' | 'active' | 'completed'>('all');
  
  let filteredTodos = $derived.by(() => {
    switch (filter) {
      case 'active': return todos.filter(t => !t.completed);
      case 'completed': return todos.filter(t => t.completed);
      default: return todos;
    }
  });
  
  let stats = $derived({
    total: todos.length,
    active: todos.filter(t => !t.completed).length,
    completed: todos.filter(t => t.completed).length
  });
  
  function addTodo(text: string) {
    todos.push({
      id: Date.now(),
      text,
      completed: false
    });
  }
  
  function toggleTodo(id: number) {
    const todo = todos.find(t => t.id === id);
    if (todo) todo.completed = !todo.completed;
  }
  
  function deleteTodo(id: number) {
    const index = todos.findIndex(t => t.id === id);
    if (index !== -1) todos.splice(index, 1);
  }
</script>

<div class="todo-list">
  <AddTodo onadd={addTodo} />
  
  <div class="filters">
    <button 
      class:active={filter === 'all'}
      onclick={() => filter = 'all'}
    >
      All ({stats.total})
    </button>
    <button 
      class:active={filter === 'active'}
      onclick={() => filter = 'active'}
    >
      Active ({stats.active})
    </button>
    <button 
      class:active={filter === 'completed'}
      onclick={() => filter = 'completed'}
    >
      Completed ({stats.completed})
    </button>
  </div>
  
  <ul>
    {#each filteredTodos as todo (todo.id)}
      <TodoItem 
        {todo}
        ontoggle={() => toggleTodo(todo.id)}
        ondelete={() => deleteTodo(todo.id)}
      />
    {/each}
  </ul>
</div>

🔄 Pattern 3: Controlled Components

Components where parent controls the value.

<!-- Select.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  
  interface Option {
    value: string;
    label: string;
    disabled?: boolean;
  }
  
  interface Props {
    value: string;
    options: Option[];
    placeholder?: string;
    disabled?: boolean;
    onchange?: (value: string) => void;
  }
  
  let { 
    value,
    options,
    placeholder = 'Select an option',
    disabled = false,
    onchange
  }: Props = $props();
  
  let open = $state(false);
  
  let selectedLabel = $derived(
    options.find(o => o.value === value)?.label ?? placeholder
  );
  
  function select(option: Option) {
    if (option.disabled) return;
    onchange?.(option.value);
    open = false;
  }
</script>

<div class="select" class:open class:disabled>
  <button 
    type="button"
    class="select-trigger"
    onclick={() => open = !open}
    {disabled}
  >
    <span>{selectedLabel}</span>
    <svg class="chevron"><!-- icon --></svg>
  </button>
  
  {#if open}
    <ul class="select-options">
      {#each options as option (option.value)}
        <li>
          <button
            type="button"
            class:selected={option.value === value}
            class:disabled={option.disabled}
            onclick={() => select(option)}
          >
            {option.label}
          </button>
        </li>
      {/each}
    </ul>
  {/if}
</div>

🎭 Pattern 4: Compound Components

Related components that work together.

<!-- Tabs.svelte -->
<script lang="ts" module>
  import { setContext, getContext } from 'svelte';
  
  const TABS_KEY = Symbol('tabs');
  
  interface TabsContext {
    activeTab: { value: string };
    setActiveTab: (id: string) => void;
  }
  
  export function getTabsContext(): TabsContext {
    return getContext(TABS_KEY);
  }
</script>

<script lang="ts">
  import type { Snippet } from 'svelte';
  
  interface Props {
    defaultTab?: string;
    children: Snippet;
  }
  
  let { defaultTab = '', children }: Props = $props();
  
  let activeTab = $state(defaultTab);
  
  setContext<TabsContext>(TABS_KEY, {
    get activeTab() { return { value: activeTab }; },
    setActiveTab: (id: string) => { activeTab = id; }
  });
</script>

<div class="tabs">
  {@render children()}
</div>

<!-- TabList.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  
  interface Props {
    children: Snippet;
  }
  
  let { children }: Props = $props();
</script>

<div role="tablist" class="tab-list">
  {@render children()}
</div>

<!-- Tab.svelte -->
<script lang="ts">
  import { getTabsContext } from './Tabs.svelte';
  import type { Snippet } from 'svelte';
  
  interface Props {
    id: string;
    children: Snippet;
  }
  
  let { id, children }: Props = $props();
  
  const { activeTab, setActiveTab } = getTabsContext();
  
  let isActive = $derived(activeTab.value === id);
</script>

<button
  role="tab"
  aria-selected={isActive}
  class:active={isActive}
  onclick={() => setActiveTab(id)}
>
  {@render children()}
</button>

<!-- TabPanel.svelte -->
<script lang="ts">
  import { getTabsContext } from './Tabs.svelte';
  import type { Snippet } from 'svelte';
  
  interface Props {
    id: string;
    children: Snippet;
  }
  
  let { id, children }: Props = $props();
  
  const { activeTab } = getTabsContext();
  
  let isActive = $derived(activeTab.value === id);
</script>

{#if isActive}
  <div role="tabpanel">
    {@render children()}
  </div>
{/if}

<!-- Usage -->
<!--
<Tabs defaultTab="tab1">
  <TabList>
    <Tab id="tab1">First</Tab>
    <Tab id="tab2">Second</Tab>
  </TabList>
  <TabPanel id="tab1">Content 1</TabPanel>
  <TabPanel id="tab2">Content 2</TabPanel>
</Tabs>
-->

🔀 Pattern 5: Render Props with Snippets

Pass rendering control to parent.

<!-- List.svelte -->
<script lang="ts" generics="T">
  import type { Snippet } from 'svelte';
  
  interface Props {
    items: T[];
    renderItem: Snippet<[T, number]>;
    renderEmpty?: Snippet;
  }
  
  let { items, renderItem, renderEmpty }: Props = $props();
</script>

{#if items.length === 0}
  {#if renderEmpty}
    {@render renderEmpty()}
  {:else}
    <p>No items</p>
  {/if}
{:else}
  <ul>
    {#each items as item, index (index)}
      <li>
        {@render renderItem(item, index)}
      </li>
    {/each}
  </ul>
{/if}

<!-- Usage -->
<!--
<List items={users}>
  {#snippet renderItem(user, index)}
    <div class="user">
      <span>{index + 1}.</span>
      <span>{user.name}</span>
    </div>
  {/snippet}
  
  {#snippet renderEmpty()}
    <p>No users found. Create one?</p>
  {/snippet}
</List>
-->

🎛️ Pattern 6: Form Components

Accessible, validated form inputs.

<!-- FormField.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  
  interface Props {
    name: string;
    label: string;
    error?: string;
    required?: boolean;
    children: Snippet<[{ id: string; invalid: boolean }]>;
  }
  
  let { name, label, error, required = false, children }: Props = $props();
  
  let id = $derived(`field-${name}`);
  let errorId = $derived(`${id}-error`);
  let invalid = $derived(!!error);
</script>

<div class="form-field" class:invalid>
  <label for={id}>
    {label}
    {#if required}
      <span class="required" aria-hidden="true">*</span>
    {/if}
  </label>
  
  {@render children({ id, invalid })}
  
  {#if error}
    <p id={errorId} class="error" role="alert">
      {error}
    </p>
  {/if}
</div>

<!-- TextInput.svelte -->
<script lang="ts">
  import type { HTMLInputAttributes } from 'svelte/elements';
  
  interface Props extends HTMLInputAttributes {
    value?: string;
    invalid?: boolean;
  }
  
  let { value = $bindable(''), invalid = false, ...rest }: Props = $props();
</script>

<input 
  type="text"
  bind:value
  aria-invalid={invalid}
  {...rest}
/>

<!-- Usage -->
<!--
<FormField name="email" label="Email" error={errors.email} required>
  {#snippet children({ id, invalid })}
    <TextInput 
      {id}
      {invalid}
      type="email"
      bind:value={form.email}
      placeholder="you@example.com"
    />
  {/snippet}
</FormField>
-->

🚦 Pattern 7: Loading States

Handle async data gracefully.

<!-- AsyncContent.svelte -->
<script lang="ts" generics="T">
  import type { Snippet } from 'svelte';
  
  interface Props {
    promise: Promise<T>;
    loading?: Snippet;
    error?: Snippet<[Error]>;
    children: Snippet<[T]>;
  }
  
  let { promise, loading, error, children }: Props = $props();
</script>

{#await promise}
  {#if loading}
    {@render loading()}
  {:else}
    <div class="loading">Loading...</div>
  {/if}
{:then data}
  {@render children(data)}
{:catch err}
  {#if error}
    {@render error(err)}
  {:else}
    <div class="error">Error: {err.message}</div>
  {/if}
{/await}

<!-- Usage -->
<!--
<AsyncContent promise={fetchUsers()}>
  {#snippet loading()}
    <Skeleton count={5} />
  {/snippet}
  
  {#snippet children(users)}
    <UserList {users} />
  {/snippet}
  
  {#snippet error(err)}
    <Alert variant="error">
      Failed to load users: {err.message}
    </Alert>
  {/snippet}
</AsyncContent>
-->

Pattern 8: Accessible Modal

Focus trap and keyboard handling.

<!-- Modal.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  import { tick } from 'svelte';
  
  interface Props {
    open: boolean;
    onclose: () => void;
    title: string;
    children: Snippet;
    footer?: Snippet;
  }
  
  let { open, onclose, title, children, footer }: Props = $props();
  
  let dialog: HTMLDialogElement;
  let previousFocus: HTMLElement | null = null;
  
  $effect(() => {
    if (open) {
      previousFocus = document.activeElement as HTMLElement;
      tick().then(() => dialog?.showModal());
    } else {
      dialog?.close();
      previousFocus?.focus();
    }
  });
  
  function handleKeydown(e: KeyboardEvent) {
    if (e.key === 'Escape') {
      onclose();
    }
  }
  
  function handleBackdropClick(e: MouseEvent) {
    if (e.target === dialog) {
      onclose();
    }
  }
</script>

{#if open}
  <dialog
    bind:this={dialog}
    onkeydown={handleKeydown}
    onclick={handleBackdropClick}
    aria-labelledby="modal-title"
  >
    <div class="modal-content">
      <header>
        <h2 id="modal-title">{title}</h2>
        <button 
          class="close-button"
          onclick={onclose}
          aria-label="Close modal"
        >
          ×
        </button>
      </header>
      
      <div class="modal-body">
        {@render children()}
      </div>
      
      {#if footer}
        <footer>
          {@render footer()}
        </footer>
      {/if}
    </div>
  </dialog>
{/if}

<style>
  dialog {
    padding: 0;
    border: none;
    border-radius: 0.5rem;
    box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
  }
  
  dialog::backdrop {
    background: rgb(0 0 0 / 0.5);
  }
</style>