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
694 lines
13 KiB
Markdown
694 lines
13 KiB
Markdown
# Svelte 5 Component Patterns
|
||
|
||
## 📦 Component Structure Template
|
||
|
||
Every Svelte component should follow this structure:
|
||
|
||
```svelte
|
||
<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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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.
|
||
|
||
```svelte
|
||
<!-- 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>
|
||
```
|