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

694 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
```