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
13 KiB
13 KiB
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>