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
This commit is contained in:
@@ -0,0 +1,693 @@
|
||||
# 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>
|
||||
```
|
||||
Reference in New Issue
Block a user