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,343 @@
|
||||
# Svelte 5 Quick Reference Card
|
||||
|
||||
## Runes Cheat Sheet
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// $state - Reactive state declaration
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
let count = $state(0); // Primitive
|
||||
let user = $state({ name: '', age: 0 }); // Object (deep reactive)
|
||||
let items = $state<string[]>([]); // Array (deep reactive)
|
||||
|
||||
// Direct mutations work with $state!
|
||||
items.push('item'); // ✅ Reactive
|
||||
user.name = 'John'; // ✅ Reactive
|
||||
count++; // ✅ Reactive
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// $derived - Computed values (auto-tracks dependencies)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
let doubled = $derived(count * 2); // Simple expression
|
||||
|
||||
let filtered = $derived.by(() => { // Complex computation
|
||||
return items.filter(i => i.includes(filter));
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// $effect - Side effects (auto-tracks, auto-cleanup)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
$effect(() => {
|
||||
console.log('Count is:', count); // Runs when count changes
|
||||
|
||||
return () => { // Optional cleanup
|
||||
console.log('Cleaning up');
|
||||
};
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
// Runs BEFORE DOM updates
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// $props - Component props
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
let {
|
||||
name, // Required prop
|
||||
count = 0, // With default
|
||||
children, // Snippet (replaces default slot)
|
||||
header, // Named snippet
|
||||
onclick, // Event callback
|
||||
...rest // Rest props
|
||||
} = $props();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// $bindable - Two-way bindable props
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
let { value = $bindable('') } = $props();
|
||||
// Parent: <Input bind:value />
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// $inspect - Debug reactive values (dev only)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
$inspect(count); // Logs on change
|
||||
$inspect(count).with(console.trace); // Custom logger
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template Syntax
|
||||
|
||||
```svelte
|
||||
<!-- Rendering Snippets (replaces slots) -->
|
||||
{@render children?.()}
|
||||
{@render header?.()}
|
||||
{@render row({ item, index })}
|
||||
|
||||
<!-- Conditionals -->
|
||||
{#if condition}
|
||||
...
|
||||
{:else if other}
|
||||
...
|
||||
{:else}
|
||||
...
|
||||
{/if}
|
||||
|
||||
<!-- Loops -->
|
||||
{#each items as item, index (item.id)}
|
||||
...
|
||||
{/each}
|
||||
|
||||
<!-- Await -->
|
||||
{#await promise}
|
||||
<Loading />
|
||||
{:then data}
|
||||
<Result {data} />
|
||||
{:catch error}
|
||||
<Error {error} />
|
||||
{/await}
|
||||
|
||||
<!-- Key block (force re-render) -->
|
||||
{#key value}
|
||||
<Component />
|
||||
{/key}
|
||||
|
||||
<!-- HTML (dangerous) -->
|
||||
{@html content}
|
||||
|
||||
<!-- Debug -->
|
||||
{@debug variable}
|
||||
|
||||
<!-- Define snippets -->
|
||||
{#snippet name(param1, param2)}
|
||||
<div>{param1}</div>
|
||||
{/snippet}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
```svelte
|
||||
<!-- Basic Component -->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
variant?: 'primary' | 'secondary';
|
||||
disabled?: boolean;
|
||||
onclick?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
onclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="btn btn-{variant}"
|
||||
{disabled}
|
||||
{onclick}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<!-- With Named Snippets -->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
header?: Snippet;
|
||||
children?: Snippet;
|
||||
footer?: Snippet<[{ close: () => void }]>;
|
||||
}
|
||||
|
||||
let { header, children, footer }: Props = $props();
|
||||
|
||||
function close() {
|
||||
// close logic
|
||||
}
|
||||
</script>
|
||||
|
||||
{@render header?.()}
|
||||
{@render children?.()}
|
||||
{@render footer?.({ close })}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
```typescript
|
||||
// stores/counter.svelte.ts
|
||||
export class CounterStore {
|
||||
count = $state(0);
|
||||
doubled = $derived(this.count * 2);
|
||||
|
||||
increment = () => { this.count++; }
|
||||
decrement = () => { this.count--; }
|
||||
reset = () => { this.count = 0; }
|
||||
}
|
||||
|
||||
export const counter = new CounterStore();
|
||||
|
||||
// Usage in component
|
||||
import { counter } from '$lib/stores/counter.svelte';
|
||||
|
||||
// Read: counter.count, counter.doubled
|
||||
// Write: counter.increment()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SvelteKit Essentials
|
||||
|
||||
```typescript
|
||||
// +page.ts (Universal load)
|
||||
export async function load({ params, fetch }) {
|
||||
const data = await fetch(`/api/items/${params.id}`);
|
||||
return { item: await data.json() };
|
||||
}
|
||||
|
||||
// +page.server.ts (Server-only load)
|
||||
export async function load({ params, locals }) {
|
||||
return { user: locals.user };
|
||||
}
|
||||
|
||||
// Form Actions
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
// Process form
|
||||
return { success: true };
|
||||
},
|
||||
named: async ({ request }) => {
|
||||
// Named action: <form action="?/named">
|
||||
}
|
||||
};
|
||||
|
||||
// API Routes (+server.ts)
|
||||
export async function GET({ params }) {
|
||||
return json({ data: 'value' });
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const body = await request.json();
|
||||
return json({ created: true }, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.html # HTML template
|
||||
├── app.d.ts # Type declarations
|
||||
├── lib/
|
||||
│ ├── components/ # Reusable components
|
||||
│ ├── stores/ # State (.svelte.ts files)
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ └── server/ # Server-only code
|
||||
├── routes/
|
||||
│ ├── +page.svelte # Page component
|
||||
│ ├── +page.ts # Page load (universal)
|
||||
│ ├── +page.server.ts # Page load (server) + actions
|
||||
│ ├── +layout.svelte # Layout component
|
||||
│ ├── +layout.ts # Layout load
|
||||
│ ├── +error.svelte # Error page
|
||||
│ └── api/
|
||||
│ └── +server.ts # API endpoint
|
||||
└── hooks.server.ts # Server hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Anti-Patterns
|
||||
|
||||
```svelte
|
||||
<!-- ❌ DON'T: Destructure reactive state -->
|
||||
let { name } = user; // Loses reactivity!
|
||||
|
||||
<!-- ✅ DO: Access properties directly -->
|
||||
{user.name}
|
||||
|
||||
<!-- ❌ DON'T: Use $effect for derived values -->
|
||||
$effect(() => {
|
||||
doubled = count * 2; // Use $derived instead!
|
||||
});
|
||||
|
||||
<!-- ✅ DO: Use $derived -->
|
||||
let doubled = $derived(count * 2);
|
||||
|
||||
<!-- ❌ DON'T: Forget optional chaining for snippets -->
|
||||
{@render children()} // May error if undefined
|
||||
|
||||
<!-- ✅ DO: Use optional chaining -->
|
||||
{@render children?.()}
|
||||
|
||||
<!-- ❌ DON'T: Modify state in $derived -->
|
||||
let bad = $derived(count++); // Side effect!
|
||||
|
||||
<!-- ✅ DO: Keep $derived pure -->
|
||||
let good = $derived(count + 1);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Quick Types
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
Snippet, // Snippet type
|
||||
Component, // Component type
|
||||
ComponentProps // Extract props from component
|
||||
} from 'svelte';
|
||||
|
||||
import type {
|
||||
PageData, // From +page.ts load
|
||||
PageServerData, // From +page.server.ts load
|
||||
Actions, // Form actions type
|
||||
RequestHandler // API route handler
|
||||
} from './$types';
|
||||
|
||||
// Snippet with params
|
||||
type ItemSnippet = Snippet<[{ item: Item; index: number }]>;
|
||||
|
||||
// Component props
|
||||
type ButtonProps = ComponentProps<typeof Button>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Create new project
|
||||
npx sv create my-app
|
||||
|
||||
# Development
|
||||
npm run dev
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
|
||||
# Type checking
|
||||
npm run check
|
||||
|
||||
# Run migration
|
||||
npx sv migrate svelte-5
|
||||
```
|
||||
@@ -0,0 +1,439 @@
|
||||
# Svelte 5 Runes Complete Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Runes are Svelte 5's new reactivity system. They replace the old `$:` reactive statements and `export let` declarations with explicit, predictable primitives.
|
||||
|
||||
---
|
||||
|
||||
## 📦 `$state` - Reactive State
|
||||
|
||||
### Basic Usage
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
let user = $state({ name: 'John', age: 30 });
|
||||
let items = $state<string[]>([]);
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>
|
||||
Clicked {count} times
|
||||
</button>
|
||||
```
|
||||
|
||||
### Deep Reactivity
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Objects and arrays are deeply reactive
|
||||
let todos = $state([
|
||||
{ id: 1, text: 'Learn Svelte', done: false }
|
||||
]);
|
||||
|
||||
function toggle(id: number) {
|
||||
const todo = todos.find(t => t.id === id);
|
||||
if (todo) todo.done = !todo.done; // This triggers updates!
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### `$state.raw` - Shallow Reactivity
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Use for large objects where you replace, not mutate
|
||||
let data = $state.raw<BigObject | null>(null);
|
||||
|
||||
async function load() {
|
||||
data = await fetchData(); // Must reassign, not mutate
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### `$state.snapshot` - Get Plain Object
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let user = $state({ name: 'John' });
|
||||
|
||||
function save() {
|
||||
// Get a plain, non-reactive copy
|
||||
const plain = $state.snapshot(user);
|
||||
localStorage.setItem('user', JSON.stringify(plain));
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 `$derived` - Computed Values
|
||||
|
||||
### Basic Derived
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
let quadrupled = $derived(doubled * 2);
|
||||
</script>
|
||||
|
||||
<p>{count} × 2 = {doubled}</p>
|
||||
<p>{doubled} × 2 = {quadrupled}</p>
|
||||
```
|
||||
|
||||
### Complex Derived with `$derived.by`
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let items = $state([1, 2, 3, 4, 5]);
|
||||
let filter = $state('all');
|
||||
|
||||
// Use $derived.by for complex computations
|
||||
let filtered = $derived.by(() => {
|
||||
console.log('Recomputing filtered list...');
|
||||
|
||||
if (filter === 'even') {
|
||||
return items.filter(n => n % 2 === 0);
|
||||
}
|
||||
if (filter === 'odd') {
|
||||
return items.filter(n => n % 2 !== 0);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Derived from Multiple Sources
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let firstName = $state('John');
|
||||
let lastName = $state('Doe');
|
||||
|
||||
let fullName = $derived(`${firstName} ${lastName}`);
|
||||
let initials = $derived(`${firstName[0]}${lastName[0]}`);
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ `$effect` - Side Effects
|
||||
|
||||
### Basic Effect
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
// Runs when count changes
|
||||
console.log(`Count is now ${count}`);
|
||||
document.title = `Count: ${count}`;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Effect with Cleanup
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let running = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!running) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
console.log('tick');
|
||||
}, 1000);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### `$effect.pre` - Before DOM Update
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let messages = $state<string[]>([]);
|
||||
let container: HTMLDivElement;
|
||||
|
||||
$effect.pre(() => {
|
||||
// Runs BEFORE DOM updates
|
||||
// Useful for preserving scroll position
|
||||
messages; // Track dependency
|
||||
if (container) {
|
||||
// Save scroll position before new messages render
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### `$effect.tracking` - Check if Reactive
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
$effect(() => {
|
||||
console.log($effect.tracking()); // true
|
||||
});
|
||||
|
||||
function regularFunction() {
|
||||
console.log($effect.tracking()); // false
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### `$effect.root` - Untracked Effect Scope
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Create effects outside component lifecycle
|
||||
const cleanup = $effect.root(() => {
|
||||
$effect(() => {
|
||||
// This effect lives until cleanup() is called
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Custom cleanup logic
|
||||
};
|
||||
});
|
||||
|
||||
// Later: cleanup();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 `$props` - Component Props
|
||||
|
||||
### Basic Props
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { name, age }: { name: string; age: number } = $props();
|
||||
</script>
|
||||
|
||||
<p>{name} is {age} years old</p>
|
||||
```
|
||||
|
||||
### Optional Props with Defaults
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
age?: number;
|
||||
greeting?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
age = 18,
|
||||
greeting = 'Hello'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<p>{greeting}, {name}! You are {age}.</p>
|
||||
```
|
||||
|
||||
### Rest Props
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
let { variant = 'primary', ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class={variant} {...rest}>
|
||||
<slot />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Children as Props (Snippets)
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
header?: Snippet;
|
||||
children: Snippet;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let { header, children, footer }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
{#if header}
|
||||
<header>{@render header()}</header>
|
||||
{/if}
|
||||
|
||||
<main>{@render children()}</main>
|
||||
|
||||
{#if footer}
|
||||
<footer>{@render footer()}</footer>
|
||||
{/if}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 `$bindable` - Two-Way Binding Props
|
||||
|
||||
### Basic Bindable
|
||||
```svelte
|
||||
<!-- TextInput.svelte -->
|
||||
<script lang="ts">
|
||||
let { value = $bindable('') }: { value?: string } = $props();
|
||||
</script>
|
||||
|
||||
<input bind:value />
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<script lang="ts">
|
||||
import TextInput from './TextInput.svelte';
|
||||
let name = $state('');
|
||||
</script>
|
||||
|
||||
<TextInput bind:value={name} />
|
||||
<p>Name: {name}</p>
|
||||
```
|
||||
|
||||
### Bindable with Fallback
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Provide a fallback for when parent doesn't bind
|
||||
let { checked = $bindable(false) }: { checked?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<input type="checkbox" bind:checked />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 `$inspect` - Development Debugging
|
||||
|
||||
### Basic Inspect
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
let user = $state({ name: 'John' });
|
||||
|
||||
// Logs whenever count or user changes
|
||||
$inspect(count, user);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Custom Inspect Handler
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let data = $state({ x: 0, y: 0 });
|
||||
|
||||
$inspect(data).with((type, ...values) => {
|
||||
if (type === 'update') {
|
||||
console.log('Data updated:', values);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Breakpoint on Change
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let buggyValue = $state(0);
|
||||
|
||||
// Pauses debugger when value changes
|
||||
$inspect(buggyValue).with(() => {
|
||||
debugger;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏪 Class-Based State with `$state`
|
||||
|
||||
```svelte
|
||||
<script lang="ts" module>
|
||||
// Can be in a separate .svelte.ts file
|
||||
export class Counter {
|
||||
count = $state(0);
|
||||
doubled = $derived(this.count * 2);
|
||||
|
||||
increment() {
|
||||
this.count++;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.count--;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.count = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const counter = new Counter();
|
||||
</script>
|
||||
|
||||
<button onclick={() => counter.decrement()}>-</button>
|
||||
<span>{counter.count} (doubled: {counter.doubled})</span>
|
||||
<button onclick={() => counter.increment()}>+</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Common Mistakes
|
||||
|
||||
### ❌ Wrong: Destructuring Loses Reactivity
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let user = $state({ name: 'John', age: 30 });
|
||||
|
||||
// ❌ This breaks reactivity!
|
||||
let { name, age } = user;
|
||||
|
||||
// ✅ Access properties directly
|
||||
// user.name, user.age
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Wrong: Effect Without Dependencies
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
|
||||
// ❌ This runs only once (no reactive reads inside)
|
||||
$effect(() => {
|
||||
console.log('This only runs once');
|
||||
});
|
||||
|
||||
// ✅ Read reactive values to create dependencies
|
||||
$effect(() => {
|
||||
console.log(`Count is ${count}`);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Wrong: Mutating Derived Values
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let items = $state([1, 2, 3]);
|
||||
let doubled = $derived(items.map(n => n * 2));
|
||||
|
||||
// ❌ Cannot mutate derived values!
|
||||
// doubled.push(8);
|
||||
|
||||
// ✅ Mutate the source
|
||||
items.push(4);
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Runes Comparison Table
|
||||
|
||||
| Svelte 4 | Svelte 5 | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `export let x` | `let { x } = $props()` | Props |
|
||||
| `let x = 0` | `let x = $state(0)` | Reactive state |
|
||||
| `$: y = x * 2` | `let y = $derived(x * 2)` | Computed |
|
||||
| `$: { ... }` | `$effect(() => { ... })` | Side effects |
|
||||
| `$$props` | `$props()` with rest | All props |
|
||||
| `bind:value` | `$bindable()` | Two-way binding |
|
||||
@@ -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>
|
||||
```
|
||||
@@ -0,0 +1,626 @@
|
||||
# Svelte 5 State Management Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Svelte 5 provides multiple patterns for state management, from local component state to global application state. Choose the right pattern based on your needs.
|
||||
|
||||
---
|
||||
|
||||
## 📊 State Management Decision Tree
|
||||
|
||||
```
|
||||
Is the state used by only one component?
|
||||
├── YES → Use local $state()
|
||||
└── NO → Is it shared between parent/child?
|
||||
├── YES → Pass as props or use $bindable()
|
||||
└── NO → Is it shared across unrelated components?
|
||||
├── YES → Use a .svelte.ts store
|
||||
└── NO → Is it server data that needs caching?
|
||||
└── YES → Use SvelteKit load functions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏠 Pattern 1: Local Component State
|
||||
|
||||
For state that belongs to a single component.
|
||||
|
||||
```svelte
|
||||
<!-- Counter.svelte -->
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
let history = $state<number[]>([]);
|
||||
|
||||
let average = $derived(
|
||||
history.length > 0
|
||||
? history.reduce((a, b) => a + b, 0) / history.length
|
||||
: 0
|
||||
);
|
||||
|
||||
function increment() {
|
||||
count++;
|
||||
history.push(count);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
count = 0;
|
||||
history = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p>Count: {count}</p>
|
||||
<p>Average: {average.toFixed(2)}</p>
|
||||
<button onclick={increment}>+1</button>
|
||||
<button onclick={reset}>Reset</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📤 Pattern 2: Props Down, Events Up
|
||||
|
||||
Classic unidirectional data flow.
|
||||
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<script lang="ts">
|
||||
import Child from './Child.svelte';
|
||||
|
||||
let items = $state(['Apple', 'Banana', 'Cherry']);
|
||||
|
||||
function addItem(item: string) {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
items.splice(index, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Child
|
||||
{items}
|
||||
onadd={addItem}
|
||||
onremove={removeItem}
|
||||
/>
|
||||
|
||||
<!-- Child.svelte -->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
items: string[];
|
||||
onadd: (item: string) => void;
|
||||
onremove: (index: number) => void;
|
||||
}
|
||||
|
||||
let { items, onadd, onremove }: Props = $props();
|
||||
|
||||
let newItem = $state('');
|
||||
|
||||
function handleAdd() {
|
||||
if (newItem.trim()) {
|
||||
onadd(newItem);
|
||||
newItem = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<input bind:value={newItem} placeholder="Add item" />
|
||||
<button onclick={handleAdd}>Add</button>
|
||||
|
||||
<ul>
|
||||
{#each items as item, i (i)}
|
||||
<li>
|
||||
{item}
|
||||
<button onclick={() => onremove(i)}>×</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Pattern 3: Two-Way Binding with $bindable
|
||||
|
||||
For controlled form components.
|
||||
|
||||
```svelte
|
||||
<!-- SearchInput.svelte -->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onsearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = 'Search...',
|
||||
onsearch
|
||||
}: Props = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
onsearch?.(value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
bind:value
|
||||
{placeholder}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
|
||||
<!-- Usage -->
|
||||
<!--
|
||||
<script lang="ts">
|
||||
let searchQuery = $state('');
|
||||
|
||||
function handleSearch(query: string) {
|
||||
console.log('Searching:', query);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SearchInput bind:value={searchQuery} onsearch={handleSearch} />
|
||||
<p>Current query: {searchQuery}</p>
|
||||
-->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏪 Pattern 4: Global Stores (.svelte.ts files)
|
||||
|
||||
For state shared across unrelated components.
|
||||
|
||||
```typescript
|
||||
// stores/auth.svelte.ts
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
class AuthStore {
|
||||
user = $state<User | null>(null);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
isAuthenticated = $derived(this.user !== null);
|
||||
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
this.user = await response.json();
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Unknown error';
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
this.user = null;
|
||||
}
|
||||
|
||||
async checkSession() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
if (response.ok) {
|
||||
this.user = await response.json();
|
||||
}
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- NavBar.svelte -->
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
{#if auth.isAuthenticated}
|
||||
<span>Welcome, {auth.user?.name}</span>
|
||||
<button onclick={() => auth.logout()}>Logout</button>
|
||||
{:else}
|
||||
<a href="/login">Login</a>
|
||||
{/if}
|
||||
</nav>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛒 Pattern 5: Complex Store with Actions
|
||||
|
||||
For stores with complex business logic.
|
||||
|
||||
```typescript
|
||||
// stores/cart.svelte.ts
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
class CartStore {
|
||||
items = $state<CartItem[]>([]);
|
||||
|
||||
// Derived values
|
||||
itemCount = $derived(
|
||||
this.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||
);
|
||||
|
||||
subtotal = $derived(
|
||||
this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
);
|
||||
|
||||
tax = $derived(this.subtotal * 0.1);
|
||||
|
||||
total = $derived(this.subtotal + this.tax);
|
||||
|
||||
isEmpty = $derived(this.items.length === 0);
|
||||
|
||||
// Actions
|
||||
addItem(product: { id: string; name: string; price: number }) {
|
||||
const existing = this.items.find(item => item.id === product.id);
|
||||
|
||||
if (existing) {
|
||||
existing.quantity++;
|
||||
} else {
|
||||
this.items.push({
|
||||
...product,
|
||||
quantity: 1
|
||||
});
|
||||
}
|
||||
|
||||
this.persist();
|
||||
}
|
||||
|
||||
removeItem(id: string) {
|
||||
const index = this.items.findIndex(item => item.id === id);
|
||||
if (index !== -1) {
|
||||
this.items.splice(index, 1);
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
updateQuantity(id: string, quantity: number) {
|
||||
const item = this.items.find(item => item.id === id);
|
||||
|
||||
if (item) {
|
||||
if (quantity <= 0) {
|
||||
this.removeItem(id);
|
||||
} else {
|
||||
item.quantity = quantity;
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// Persistence
|
||||
private persist() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('cart', JSON.stringify($state.snapshot(this.items)));
|
||||
}
|
||||
}
|
||||
|
||||
hydrate() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('cart');
|
||||
if (saved) {
|
||||
try {
|
||||
this.items = JSON.parse(saved);
|
||||
} catch {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cart = new CartStore();
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- CartButton.svelte -->
|
||||
<script lang="ts">
|
||||
import { cart } from '$lib/stores/cart.svelte';
|
||||
</script>
|
||||
|
||||
<button class="cart-button">
|
||||
🛒
|
||||
{#if cart.itemCount > 0}
|
||||
<span class="badge">{cart.itemCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- CartSummary.svelte -->
|
||||
<script lang="ts">
|
||||
import { cart } from '$lib/stores/cart.svelte';
|
||||
</script>
|
||||
|
||||
<div class="cart-summary">
|
||||
<p>Subtotal: ${cart.subtotal.toFixed(2)}</p>
|
||||
<p>Tax: ${cart.tax.toFixed(2)}</p>
|
||||
<p><strong>Total: ${cart.total.toFixed(2)}</strong></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Pattern 6: Context for Component Trees
|
||||
|
||||
Pass state down a component tree without prop drilling.
|
||||
|
||||
```svelte
|
||||
<!-- ThemeProvider.svelte -->
|
||||
<script lang="ts" module>
|
||||
import { setContext, getContext } from 'svelte';
|
||||
|
||||
const THEME_KEY = Symbol('theme');
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface ThemeContext {
|
||||
theme: { value: Theme };
|
||||
resolvedTheme: { value: 'light' | 'dark' };
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export function getTheme(): ThemeContext {
|
||||
return getContext(THEME_KEY);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface Props {
|
||||
defaultTheme?: Theme;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { defaultTheme = 'system', children }: Props = $props();
|
||||
|
||||
let theme = $state<Theme>(defaultTheme);
|
||||
let systemTheme = $state<'light' | 'dark'>('light');
|
||||
|
||||
let resolvedTheme = $derived<'light' | 'dark'>(
|
||||
theme === 'system' ? systemTheme : theme
|
||||
);
|
||||
|
||||
// Watch system preference
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
systemTheme = e.matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
});
|
||||
|
||||
// Apply theme to document
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
});
|
||||
|
||||
setContext<ThemeContext>(THEME_KEY, {
|
||||
get theme() { return { value: theme }; },
|
||||
get resolvedTheme() { return { value: resolvedTheme }; },
|
||||
setTheme: (t: Theme) => { theme = t; }
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- ThemeToggle.svelte -->
|
||||
<script lang="ts">
|
||||
import { getTheme } from './ThemeProvider.svelte';
|
||||
|
||||
const { theme, setTheme } = getTheme();
|
||||
</script>
|
||||
|
||||
<button onclick={() => setTheme(theme.value === 'dark' ? 'light' : 'dark')}>
|
||||
{theme.value === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 Pattern 7: Async State with Loading/Error
|
||||
|
||||
Handle async operations elegantly.
|
||||
|
||||
```typescript
|
||||
// stores/async.svelte.ts
|
||||
export type AsyncState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error };
|
||||
|
||||
export function createAsyncState<T>() {
|
||||
let state = $state<AsyncState<T>>({ status: 'idle' });
|
||||
|
||||
return {
|
||||
get state() { return state; },
|
||||
get isLoading() { return state.status === 'loading'; },
|
||||
get isSuccess() { return state.status === 'success'; },
|
||||
get isError() { return state.status === 'error'; },
|
||||
get data() { return state.status === 'success' ? state.data : null; },
|
||||
get error() { return state.status === 'error' ? state.error : null; },
|
||||
|
||||
async run(fn: () => Promise<T>) {
|
||||
state = { status: 'loading' };
|
||||
try {
|
||||
const data = await fn();
|
||||
state = { status: 'success', data };
|
||||
return data;
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
state = { status: 'error', error };
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
state = { status: 'idle' };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage in a store
|
||||
class UserStore {
|
||||
private usersAsync = createAsyncState<User[]>();
|
||||
|
||||
get users() { return this.usersAsync.data ?? []; }
|
||||
get loading() { return this.usersAsync.isLoading; }
|
||||
get error() { return this.usersAsync.error; }
|
||||
|
||||
async fetchUsers() {
|
||||
await this.usersAsync.run(async () => {
|
||||
const res = await fetch('/api/users');
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Pattern 8: Undo/Redo State
|
||||
|
||||
Maintain history for undo/redo functionality.
|
||||
|
||||
```typescript
|
||||
// stores/history.svelte.ts
|
||||
export function createHistoryState<T>(initial: T) {
|
||||
let past = $state<T[]>([]);
|
||||
let present = $state<T>(initial);
|
||||
let future = $state<T[]>([]);
|
||||
|
||||
return {
|
||||
get value() { return present; },
|
||||
get canUndo() { return past.length > 0; },
|
||||
get canRedo() { return future.length > 0; },
|
||||
|
||||
set(value: T) {
|
||||
past.push($state.snapshot(present) as T);
|
||||
present = value;
|
||||
future = [];
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (past.length === 0) return;
|
||||
|
||||
future.unshift($state.snapshot(present) as T);
|
||||
present = past.pop()!;
|
||||
},
|
||||
|
||||
redo() {
|
||||
if (future.length === 0) return;
|
||||
|
||||
past.push($state.snapshot(present) as T);
|
||||
present = future.shift()!;
|
||||
},
|
||||
|
||||
reset(value: T) {
|
||||
past = [];
|
||||
present = value;
|
||||
future = [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const editor = createHistoryState({ text: '', fontSize: 16 });
|
||||
|
||||
// Make changes
|
||||
editor.set({ text: 'Hello', fontSize: 16 });
|
||||
editor.set({ text: 'Hello World', fontSize: 16 });
|
||||
|
||||
// Undo
|
||||
editor.undo(); // Back to 'Hello'
|
||||
editor.undo(); // Back to ''
|
||||
|
||||
// Redo
|
||||
editor.redo(); // Forward to 'Hello'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 State Management Best Practices
|
||||
|
||||
### ✅ Do:
|
||||
- Keep state as local as possible
|
||||
- Use `$derived` for computed values
|
||||
- Use `.svelte.ts` files for shared state
|
||||
- Type your state with TypeScript
|
||||
- Use `$state.snapshot()` before serializing
|
||||
- Clean up effects that create subscriptions
|
||||
|
||||
### ❌ Don't:
|
||||
- Create global stores for component-local state
|
||||
- Mutate derived values
|
||||
- Put async logic in `$effect` without cleanup
|
||||
- Destructure `$state` objects (breaks reactivity)
|
||||
- Forget to handle loading/error states
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging State
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let user = $state({ name: 'John', age: 30 });
|
||||
let items = $state([1, 2, 3]);
|
||||
|
||||
// Log all changes during development
|
||||
$inspect(user, items);
|
||||
|
||||
// Or with custom handler
|
||||
$inspect(user).with((type, ...values) => {
|
||||
if (type === 'update') {
|
||||
console.table(values);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
@@ -0,0 +1,728 @@
|
||||
# SvelteKit Routing & Data Loading Guide
|
||||
|
||||
## Overview
|
||||
|
||||
SvelteKit is the official framework for building Svelte applications. It provides file-based routing, server-side rendering, and powerful data loading primitives.
|
||||
|
||||
---
|
||||
|
||||
## 📁 File-Based Routing
|
||||
|
||||
### Route Structure
|
||||
```
|
||||
src/routes/
|
||||
├── +page.svelte # → /
|
||||
├── +layout.svelte # Layout for all routes
|
||||
├── about/
|
||||
│ └── +page.svelte # → /about
|
||||
├── blog/
|
||||
│ ├── +page.svelte # → /blog
|
||||
│ └── [slug]/
|
||||
│ └── +page.svelte # → /blog/:slug
|
||||
├── users/
|
||||
│ ├── +page.svelte # → /users
|
||||
│ ├── [id]/
|
||||
│ │ ├── +page.svelte # → /users/:id
|
||||
│ │ └── +page.server.ts # Server-only load
|
||||
│ └── [...rest]/
|
||||
│ └── +page.svelte # → /users/* (catch-all)
|
||||
└── api/
|
||||
└── posts/
|
||||
└── +server.ts # → /api/posts (API endpoint)
|
||||
```
|
||||
|
||||
### Route Files Explained
|
||||
|
||||
| File | Purpose | Runs On |
|
||||
|------|---------|---------|
|
||||
| `+page.svelte` | Page component | Client |
|
||||
| `+page.ts` | Load data for page | Client & Server |
|
||||
| `+page.server.ts` | Load data (server only) | Server only |
|
||||
| `+layout.svelte` | Shared layout | Client |
|
||||
| `+layout.ts` | Load data for layout | Client & Server |
|
||||
| `+layout.server.ts` | Load layout data (server) | Server only |
|
||||
| `+server.ts` | API endpoint | Server only |
|
||||
| `+error.svelte` | Error page | Client |
|
||||
|
||||
---
|
||||
|
||||
## 📥 Data Loading
|
||||
|
||||
### Basic Load Function (+page.ts)
|
||||
|
||||
```typescript
|
||||
// src/routes/posts/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
const response = await fetch('/api/posts');
|
||||
const posts = await response.json();
|
||||
|
||||
return {
|
||||
posts
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/posts/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<h1>Posts</h1>
|
||||
|
||||
{#each data.posts as post}
|
||||
<article>
|
||||
<h2>{post.title}</h2>
|
||||
<p>{post.excerpt}</p>
|
||||
</article>
|
||||
{/each}
|
||||
```
|
||||
|
||||
### Server-Only Load (+page.server.ts)
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+page.server.ts
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/database';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, cookies }) => {
|
||||
// Access server-only resources
|
||||
const session = cookies.get('session');
|
||||
|
||||
if (!session) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { sessionId: session }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const stats = await db.stats.findMany({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email
|
||||
},
|
||||
stats
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Load with Parameters
|
||||
|
||||
```typescript
|
||||
// src/routes/blog/[slug]/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
const response = await fetch(`/api/posts/${params.slug}`);
|
||||
|
||||
if (!response.ok) {
|
||||
error(404, 'Post not found');
|
||||
}
|
||||
|
||||
const post = await response.json();
|
||||
|
||||
return {
|
||||
post
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Parent Data Access
|
||||
|
||||
```typescript
|
||||
// src/routes/+layout.ts
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch }) => {
|
||||
const response = await fetch('/api/user');
|
||||
const user = response.ok ? await response.json() : null;
|
||||
|
||||
return { user };
|
||||
};
|
||||
|
||||
// src/routes/dashboard/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
// Access data from parent layout
|
||||
const { user } = await parent();
|
||||
|
||||
if (!user) {
|
||||
// Handle unauthenticated state
|
||||
}
|
||||
|
||||
return {
|
||||
// Additional data
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Parallel Data Loading
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
// Load data in parallel
|
||||
const [postsRes, statsRes, notificationsRes] = await Promise.all([
|
||||
fetch('/api/posts'),
|
||||
fetch('/api/stats'),
|
||||
fetch('/api/notifications')
|
||||
]);
|
||||
|
||||
return {
|
||||
posts: postsRes.json(),
|
||||
stats: statsRes.json(),
|
||||
notifications: notificationsRes.json()
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📤 Form Actions
|
||||
|
||||
### Basic Form Action
|
||||
|
||||
```typescript
|
||||
// src/routes/login/+page.server.ts
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user) {
|
||||
redirect(303, '/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email')?.toString();
|
||||
const password = data.get('password')?.toString();
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, {
|
||||
email,
|
||||
error: 'Email and password are required'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({ where: { email } });
|
||||
|
||||
if (!user || !await verifyPassword(password, user.passwordHash)) {
|
||||
return fail(401, {
|
||||
email,
|
||||
error: 'Invalid credentials'
|
||||
});
|
||||
}
|
||||
|
||||
const session = await createSession(user.id);
|
||||
cookies.set('session', session.id, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7 // 1 week
|
||||
});
|
||||
|
||||
redirect(303, '/dashboard');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/login/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form?.email ?? ''}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" required />
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Named Actions
|
||||
|
||||
```typescript
|
||||
// src/routes/settings/+page.server.ts
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
updateProfile: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
// Handle profile update
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
changePassword: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
// Handle password change
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteAccount: async ({ request, cookies }) => {
|
||||
// Handle account deletion
|
||||
cookies.delete('session', { path: '/' });
|
||||
redirect(303, '/');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Use named actions -->
|
||||
<form method="POST" action="?/updateProfile" use:enhance>
|
||||
<!-- Profile form -->
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/changePassword" use:enhance>
|
||||
<!-- Password form -->
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/deleteAccount" use:enhance>
|
||||
<button type="submit">Delete Account</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Routes (+server.ts)
|
||||
|
||||
### RESTful API Endpoint
|
||||
|
||||
```typescript
|
||||
// src/routes/api/posts/+server.ts
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/database';
|
||||
|
||||
// GET /api/posts
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const limit = Number(url.searchParams.get('limit')) || 10;
|
||||
const offset = Number(url.searchParams.get('offset')) || 0;
|
||||
|
||||
const posts = await db.post.findMany({
|
||||
take: limit,
|
||||
skip: offset,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
return json(posts);
|
||||
};
|
||||
|
||||
// POST /api/posts
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const post = await db.post.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
authorId: locals.user.id
|
||||
}
|
||||
});
|
||||
|
||||
return json(post, { status: 201 });
|
||||
};
|
||||
|
||||
// src/routes/api/posts/[id]/+server.ts
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
|
||||
// GET /api/posts/:id
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const post = await db.post.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
error(404, 'Post not found');
|
||||
}
|
||||
|
||||
return json(post);
|
||||
};
|
||||
|
||||
// PUT /api/posts/:id
|
||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
const post = await db.post.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
error(404, 'Post not found');
|
||||
}
|
||||
|
||||
if (post.authorId !== locals.user?.id) {
|
||||
error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const updated = await db.post.update({
|
||||
where: { id: params.id },
|
||||
data: body
|
||||
});
|
||||
|
||||
return json(updated);
|
||||
};
|
||||
|
||||
// DELETE /api/posts/:id
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const post = await db.post.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
error(404, 'Post not found');
|
||||
}
|
||||
|
||||
if (post.authorId !== locals.user?.id) {
|
||||
error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
await db.post.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Layouts
|
||||
|
||||
### Root Layout
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import '../app.css';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<Header user={data.user} />
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+layout.svelte -->
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<Sidebar />
|
||||
|
||||
<div class="content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Layout Groups
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── (auth)/ # Group - no route segment
|
||||
│ ├── +layout.svelte # Shared layout for auth pages
|
||||
│ ├── login/
|
||||
│ │ └── +page.svelte
|
||||
│ └── register/
|
||||
│ └── +page.svelte
|
||||
├── (app)/ # Group - no route segment
|
||||
│ ├── +layout.svelte # Shared layout for app pages
|
||||
│ ├── dashboard/
|
||||
│ │ └── +page.svelte
|
||||
│ └── settings/
|
||||
│ └── +page.svelte
|
||||
└── +layout.svelte # Root layout
|
||||
```
|
||||
|
||||
### Breaking Out of Layout
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/special/+page@.svelte -->
|
||||
<!-- The @. means use root layout, not parent -->
|
||||
<script lang="ts">
|
||||
// This page uses +layout.svelte directly, skipping any nested layouts
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔀 Navigation
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { goto, invalidate, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
// Navigate to a page
|
||||
async function navigate() {
|
||||
await goto('/dashboard');
|
||||
}
|
||||
|
||||
// Navigate with options
|
||||
async function navigateWithState() {
|
||||
await goto('/search', {
|
||||
replaceState: true, // Replace instead of push
|
||||
keepFocus: true, // Keep focus on current element
|
||||
noScroll: true, // Don't scroll to top
|
||||
state: { query: 'test' } // Pass state
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate load functions
|
||||
async function refresh() {
|
||||
await invalidate('/api/posts'); // Rerun loads that depend on this
|
||||
// or
|
||||
await invalidateAll(); // Rerun all load functions
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Current page info -->
|
||||
<p>Current path: {$page.url.pathname}</p>
|
||||
<p>Query: {$page.url.searchParams.get('q')}</p>
|
||||
```
|
||||
|
||||
### Link Prefetching
|
||||
|
||||
```svelte
|
||||
<!-- Preload on hover (default) -->
|
||||
<a href="/about">About</a>
|
||||
|
||||
<!-- Preload on viewport entry -->
|
||||
<a href="/posts" data-sveltekit-preload-data="hover">Posts</a>
|
||||
|
||||
<!-- Disable preloading -->
|
||||
<a href="/external" data-sveltekit-preload-data="off">External</a>
|
||||
|
||||
<!-- Preload immediately when link is rendered -->
|
||||
<a href="/dashboard" data-sveltekit-preload-data="eager">Dashboard</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Error Handling
|
||||
|
||||
### Error Pages
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+error.svelte -->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div class="error">
|
||||
<h1>{$page.status}</h1>
|
||||
<p>{$page.error?.message}</p>
|
||||
<a href="/">Go home</a>
|
||||
</div>
|
||||
|
||||
<!-- src/routes/blog/+error.svelte -->
|
||||
<!-- More specific error page for /blog routes -->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div class="blog-error">
|
||||
<h1>Blog Post Not Found</h1>
|
||||
<p>{$page.error?.message}</p>
|
||||
<a href="/blog">View all posts</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
```typescript
|
||||
// In load functions or actions
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
error(404, {
|
||||
message: 'Post not found',
|
||||
code: 'POST_NOT_FOUND'
|
||||
});
|
||||
}
|
||||
|
||||
if (post.draft) {
|
||||
redirect(303, '/blog');
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Hooks
|
||||
|
||||
### Server Hooks (hooks.server.ts)
|
||||
|
||||
```typescript
|
||||
// src/hooks.server.ts
|
||||
import type { Handle, HandleFetch, HandleServerError } from '@sveltejs/kit';
|
||||
|
||||
// Runs for every request
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Authentication
|
||||
const session = event.cookies.get('session');
|
||||
if (session) {
|
||||
event.locals.user = await getUserFromSession(session);
|
||||
}
|
||||
|
||||
// Performance timing
|
||||
const start = Date.now();
|
||||
|
||||
const response = await resolve(event, {
|
||||
// Transform HTML
|
||||
transformPageChunk: ({ html }) => html.replace('%theme%', 'dark')
|
||||
});
|
||||
|
||||
// Log request duration
|
||||
console.log(`${event.request.method} ${event.url.pathname} - ${Date.now() - start}ms`);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// Customize server-side fetch
|
||||
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
||||
// Add auth header to internal API calls
|
||||
if (request.url.startsWith('https://api.internal.com')) {
|
||||
request.headers.set('Authorization', `Bearer ${API_KEY}`);
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
};
|
||||
|
||||
// Handle unexpected errors
|
||||
export const handleError: HandleServerError = async ({ error, event }) => {
|
||||
console.error('Server error:', error);
|
||||
|
||||
// Report to error tracking service
|
||||
await reportError(error, event);
|
||||
|
||||
return {
|
||||
message: 'An unexpected error occurred',
|
||||
code: 'INTERNAL_ERROR'
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Client Hooks (hooks.client.ts)
|
||||
|
||||
```typescript
|
||||
// src/hooks.client.ts
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
export const handleError: HandleClientError = async ({ error, event }) => {
|
||||
console.error('Client error:', error);
|
||||
|
||||
// Report to error tracking
|
||||
await reportClientError(error);
|
||||
|
||||
return {
|
||||
message: 'Something went wrong',
|
||||
code: 'CLIENT_ERROR'
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Best Practices
|
||||
|
||||
### ✅ Do:
|
||||
- Use `+page.server.ts` for sensitive data
|
||||
- Return typed data from load functions
|
||||
- Use `use:enhance` for progressive enhancement
|
||||
- Handle loading and error states
|
||||
- Use route groups for layout organization
|
||||
- Validate form data server-side
|
||||
|
||||
### ❌ Don't:
|
||||
- Put secrets in `+page.ts` (runs on client)
|
||||
- Forget to await `parent()` in child loads
|
||||
- Block navigation with slow loads
|
||||
- Ignore accessibility in forms
|
||||
- Hardcode URLs (use `$page` store)
|
||||
@@ -0,0 +1,845 @@
|
||||
# Svelte + TypeScript Complete Guide
|
||||
|
||||
## Overview
|
||||
|
||||
TypeScript is a first-class citizen in Svelte 5. This guide covers all TypeScript patterns for Svelte development.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": [
|
||||
".svelte-kit/**/*.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.svelte"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### svelte.config.js
|
||||
|
||||
```javascript
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$components: './src/lib/components',
|
||||
$stores: './src/lib/stores',
|
||||
$utils: './src/lib/utils',
|
||||
$types: './src/lib/types'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Component Typing
|
||||
|
||||
### Basic Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
age: number;
|
||||
email?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
age,
|
||||
email = 'N/A',
|
||||
active = true
|
||||
}: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Props with HTML Attributes
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
let isDisabled = $derived(disabled || loading);
|
||||
</script>
|
||||
|
||||
<button class={variant} disabled={isDisabled} {...rest}>
|
||||
{#if loading}
|
||||
<span class="spinner" />
|
||||
{/if}
|
||||
<slot />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Props with Snippets
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
header?: Snippet;
|
||||
footer?: Snippet<[{ close: () => void }]>;
|
||||
}
|
||||
|
||||
let { children, header, footer }: Props = $props();
|
||||
|
||||
let open = $state(true);
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="modal">
|
||||
{#if header}
|
||||
{@render header()}
|
||||
{/if}
|
||||
|
||||
<div class="body">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
{@render footer({ close })}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Generic Components
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T extends { id: string | number }">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
selected?: T | null;
|
||||
renderItem: Snippet<[T]>;
|
||||
onselect?: (item: T) => void;
|
||||
}
|
||||
|
||||
let { items, selected = null, renderItem, onselect }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item (item.id)}
|
||||
<li
|
||||
class:selected={selected?.id === item.id}
|
||||
onclick={() => onselect?.(item)}
|
||||
>
|
||||
{@render renderItem(item)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Event Typing
|
||||
|
||||
### Custom Event Handlers
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onchange?: (value: string) => void;
|
||||
onsubmit?: (data: FormData) => void;
|
||||
onclear?: () => void;
|
||||
}
|
||||
|
||||
let { onchange, onsubmit, onclear }: Props = $props();
|
||||
|
||||
let value = $state('');
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
value = target.value;
|
||||
onchange?.(value);
|
||||
}
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget as HTMLFormElement);
|
||||
onsubmit?.(formData);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<input type="text" oninput={handleInput} />
|
||||
<button type="submit">Submit</button>
|
||||
<button type="button" onclick={onclear}>Clear</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### DOM Event Types
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Common event types
|
||||
function handleClick(e: MouseEvent) {
|
||||
console.log(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
// Handle enter
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
|
||||
console.log(e.currentTarget.value);
|
||||
}
|
||||
|
||||
function handleChange(e: Event & { currentTarget: HTMLSelectElement }) {
|
||||
console.log(e.currentTarget.value);
|
||||
}
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget as HTMLFormElement;
|
||||
const data = new FormData(form);
|
||||
}
|
||||
|
||||
function handleFocus(e: FocusEvent) {
|
||||
// Handle focus
|
||||
}
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
// Handle drag
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={handleClick}>Click me</button>
|
||||
<input oninput={handleInput} onkeydown={handleKeydown} />
|
||||
<form onsubmit={handleSubmit}>...</form>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 State Typing
|
||||
|
||||
### Basic State
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Primitive types are inferred
|
||||
let count = $state(0); // number
|
||||
let name = $state(''); // string
|
||||
let active = $state(false); // boolean
|
||||
|
||||
// Explicit typing for complex types
|
||||
let user = $state<User | null>(null);
|
||||
let items = $state<string[]>([]);
|
||||
let map = $state<Map<string, number>>(new Map());
|
||||
|
||||
// Typing nullable state
|
||||
let data = $state<ApiResponse | undefined>(undefined);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Derived Types
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let searchQuery = $state('');
|
||||
|
||||
// Type is inferred from expression
|
||||
let filteredUsers = $derived(
|
||||
users.filter(u =>
|
||||
u.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// Explicit return type with $derived.by
|
||||
let stats = $derived.by((): { total: number; admins: number } => {
|
||||
return {
|
||||
total: users.length,
|
||||
admins: users.filter(u => u.roles.includes('admin')).length
|
||||
};
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Effect Types
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let count = $state(0);
|
||||
|
||||
// Effect with cleanup
|
||||
$effect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetchData(controller.signal)
|
||||
.then(handleData)
|
||||
.catch(handleError);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
|
||||
// Pre-effect
|
||||
let container: HTMLDivElement;
|
||||
|
||||
$effect.pre(() => {
|
||||
// Runs before DOM updates
|
||||
if (container) {
|
||||
// Save scroll position
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏪 Store Types (.svelte.ts)
|
||||
|
||||
### Class-Based Store
|
||||
|
||||
```typescript
|
||||
// src/lib/stores/counter.svelte.ts
|
||||
|
||||
export class Counter {
|
||||
count = $state(0);
|
||||
|
||||
doubled = $derived(this.count * 2);
|
||||
|
||||
increment() {
|
||||
this.count++;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.count--;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const counter = new Counter();
|
||||
```
|
||||
|
||||
### Complex Typed Store
|
||||
|
||||
```typescript
|
||||
// src/lib/stores/auth.svelte.ts
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
roles: ('user' | 'admin' | 'moderator')[];
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
class AuthStore {
|
||||
user = $state<User | null>(null);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
isAuthenticated = $derived(this.user !== null);
|
||||
isAdmin = $derived(this.user?.roles.includes('admin') ?? false);
|
||||
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Login failed');
|
||||
}
|
||||
|
||||
this.user = await response.json();
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Unknown error';
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.user = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
```
|
||||
|
||||
### Factory Function Store
|
||||
|
||||
```typescript
|
||||
// src/lib/stores/createPagination.svelte.ts
|
||||
|
||||
export interface PaginationOptions {
|
||||
initialPage?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PaginationState<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function createPagination<T>(
|
||||
fetchFn: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>,
|
||||
options: PaginationOptions = {}
|
||||
) {
|
||||
const { initialPage = 1, pageSize = 10 } = options;
|
||||
|
||||
let items = $state<T[]>([]);
|
||||
let page = $state(initialPage);
|
||||
let total = $state(0);
|
||||
let loading = $state(false);
|
||||
let error = $state<Error | null>(null);
|
||||
|
||||
const totalPages = $derived(Math.ceil(total / pageSize));
|
||||
const hasNext = $derived(page < totalPages);
|
||||
const hasPrev = $derived(page > 1);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await fetchFn(page, pageSize);
|
||||
items = result.items;
|
||||
total = result.total;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e : new Error(String(e));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (hasNext) {
|
||||
page++;
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (hasPrev) {
|
||||
page--;
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
if (p >= 1 && p <= totalPages) {
|
||||
page = p;
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get items() { return items; },
|
||||
get page() { return page; },
|
||||
get pageSize() { return pageSize; },
|
||||
get total() { return total; },
|
||||
get totalPages() { return totalPages; },
|
||||
get loading() { return loading; },
|
||||
get error() { return error; },
|
||||
get hasNext() { return hasNext; },
|
||||
get hasPrev() { return hasPrev; },
|
||||
load,
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToPage
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 SvelteKit Types
|
||||
|
||||
### Load Functions
|
||||
|
||||
```typescript
|
||||
// src/routes/users/[id]/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch, parent }) => {
|
||||
// params is typed based on route
|
||||
const userId = params.id; // string
|
||||
|
||||
// parent() returns parent layout data
|
||||
const parentData = await parent();
|
||||
|
||||
const response = await fetch(`/api/users/${userId}`);
|
||||
const user = await response.json() as User;
|
||||
|
||||
return {
|
||||
user,
|
||||
parentData
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Server Load Functions
|
||||
|
||||
```typescript
|
||||
// src/routes/admin/+page.server.ts
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, cookies }) => {
|
||||
// locals is typed in app.d.ts
|
||||
if (!locals.user) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
if (!locals.user.roles.includes('admin')) {
|
||||
error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
// Access cookies
|
||||
const theme = cookies.get('theme') ?? 'light';
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
theme
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Form Actions
|
||||
|
||||
```typescript
|
||||
// src/routes/posts/new/+page.server.ts
|
||||
import type { Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
const title = data.get('title')?.toString();
|
||||
const content = data.get('content')?.toString();
|
||||
|
||||
if (!title || !content) {
|
||||
return fail(400, {
|
||||
title,
|
||||
content,
|
||||
error: 'Title and content are required'
|
||||
});
|
||||
}
|
||||
|
||||
const post = await createPost({
|
||||
title,
|
||||
content,
|
||||
authorId: locals.user!.id
|
||||
});
|
||||
|
||||
redirect(303, `/posts/${post.id}`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### API Routes
|
||||
|
||||
```typescript
|
||||
// src/routes/api/users/+server.ts
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const limit = Number(url.searchParams.get('limit')) || 10;
|
||||
|
||||
const users = await getUsers(limit);
|
||||
|
||||
return json(users);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json() as CreateUserInput;
|
||||
|
||||
// Validate
|
||||
if (!body.email || !body.name) {
|
||||
error(400, 'Missing required fields');
|
||||
}
|
||||
|
||||
const user = await createUser(body);
|
||||
|
||||
return json(user, { status: 201 });
|
||||
};
|
||||
```
|
||||
|
||||
### App Types (app.d.ts)
|
||||
|
||||
```typescript
|
||||
// src/app.d.ts
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Error {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface Locals {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
// Shared page data
|
||||
}
|
||||
|
||||
interface PageState {
|
||||
// History state
|
||||
}
|
||||
|
||||
interface Platform {
|
||||
// Platform-specific
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Utility Types
|
||||
|
||||
### Component Types
|
||||
|
||||
```typescript
|
||||
// src/lib/types/components.ts
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
// Base props for all components
|
||||
export interface BaseProps {
|
||||
class?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// Polymorphic component props
|
||||
export type PolymorphicProps<E extends keyof HTMLElementTagNameMap> =
|
||||
BaseProps & HTMLAttributes<HTMLElementTagNameMap[E]>;
|
||||
|
||||
// Common snippet patterns
|
||||
export type RenderProp<T> = Snippet<[T]>;
|
||||
export type ChildrenSnippet = Snippet;
|
||||
|
||||
// Status union
|
||||
export type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
// Async state
|
||||
export type AsyncState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error };
|
||||
```
|
||||
|
||||
### API Types
|
||||
|
||||
```typescript
|
||||
// src/lib/types/api.ts
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
meta?: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code: string;
|
||||
details?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export type ApiResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: ApiError };
|
||||
|
||||
// Type-safe fetch wrapper
|
||||
export async function apiFetch<T>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json() as ApiError;
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Type Checking Commands
|
||||
|
||||
```bash
|
||||
# Run type checking
|
||||
npx svelte-check --tsconfig ./tsconfig.json
|
||||
|
||||
# Watch mode
|
||||
npx svelte-check --tsconfig ./tsconfig.json --watch
|
||||
|
||||
# With threshold (fail on warnings)
|
||||
npx svelte-check --tsconfig ./tsconfig.json --fail-on-warnings
|
||||
|
||||
# Output format
|
||||
npx svelte-check --output human
|
||||
npx svelte-check --output human-verbose
|
||||
npx svelte-check --output machine
|
||||
```
|
||||
|
||||
### package.json scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint . && npm run check"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Common TypeScript Pitfalls
|
||||
|
||||
### ❌ Destructuring $state Breaks Reactivity
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let user = $state({ name: 'John', age: 30 });
|
||||
|
||||
// ❌ BAD: Loses reactivity
|
||||
const { name, age } = user;
|
||||
|
||||
// ✅ GOOD: Access directly
|
||||
// user.name, user.age
|
||||
</script>
|
||||
|
||||
<p>{user.name} is {user.age}</p>
|
||||
```
|
||||
|
||||
### ❌ Forgetting to Type Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// ❌ BAD: No type safety
|
||||
let { name, age } = $props();
|
||||
|
||||
// ✅ GOOD: Typed props
|
||||
let { name, age }: { name: string; age: number } = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Using `any`
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// ❌ BAD: Defeats TypeScript
|
||||
let data: any = $state(null);
|
||||
|
||||
// ✅ GOOD: Proper typing
|
||||
interface Data {
|
||||
id: string;
|
||||
value: number;
|
||||
}
|
||||
let data = $state<Data | null>(null);
|
||||
</script>
|
||||
```
|
||||
@@ -0,0 +1,733 @@
|
||||
# Svelte Styling & Tailwind Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Svelte provides scoped CSS by default and integrates seamlessly with Tailwind CSS. This guide covers both approaches and best practices.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Scoped CSS (Default)
|
||||
|
||||
### Basic Scoped Styles
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { variant = 'primary' }: { variant?: 'primary' | 'secondary' } = $props();
|
||||
</script>
|
||||
|
||||
<button class={variant}>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
/* These styles are automatically scoped to this component */
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Global Styles with :global()
|
||||
|
||||
```svelte
|
||||
<style>
|
||||
/* Scoped to this component */
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Applies globally - use sparingly */
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: system-ui;
|
||||
}
|
||||
|
||||
/* Global styles WITHIN a scoped selector */
|
||||
.container :global(a) {
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
/* Global class that can be used anywhere */
|
||||
:global(.sr-only) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### CSS Custom Properties (Variables)
|
||||
|
||||
```svelte
|
||||
<!-- Define in root layout or app.css -->
|
||||
<style>
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-dark: #2563eb;
|
||||
--color-secondary: #6b7280;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Surfaces */
|
||||
--color-background: #ffffff;
|
||||
--color-surface: #f9fafb;
|
||||
--color-border: #e5e7eb;
|
||||
|
||||
/* Text */
|
||||
--color-text: #111827;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* Borders */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
:root[data-theme="dark"] {
|
||||
--color-background: #111827;
|
||||
--color-surface: #1f2937;
|
||||
--color-border: #374151;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-muted: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💨 Tailwind CSS Integration
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install Tailwind
|
||||
npm install -D tailwindcss postcss autoprefixer
|
||||
npx tailwindcss init -p
|
||||
```
|
||||
|
||||
### tailwind.config.js
|
||||
|
||||
```javascript
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'class', // or 'media'
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter var', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### postcss.config.js
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### app.css
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-bold tracking-tight;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom component classes */
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2
|
||||
font-medium rounded-lg transition-colors
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700
|
||||
focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 text-gray-700 hover:bg-gray-200
|
||||
dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full rounded-lg border border-gray-300
|
||||
px-3 py-2 shadow-sm
|
||||
focus:border-primary-500 focus:ring-1 focus:ring-primary-500
|
||||
dark:border-gray-600 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-xl bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
shadow-sm overflow-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom utilities */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Component Styling Patterns
|
||||
|
||||
### Pattern 1: Tailwind Component
|
||||
|
||||
```svelte
|
||||
<!-- Button.svelte -->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled,
|
||||
class: className = '',
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses = `
|
||||
inline-flex items-center justify-center font-medium rounded-lg
|
||||
transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`;
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
|
||||
let classes = $derived(
|
||||
`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`.trim()
|
||||
);
|
||||
</script>
|
||||
|
||||
<button class={classes} disabled={disabled || loading} {...rest}>
|
||||
{#if loading}
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Pattern 2: Class Merging with clsx/tailwind-merge
|
||||
|
||||
```bash
|
||||
npm install clsx tailwind-merge
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/cn.ts
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Card.svelte -->
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/cn';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'bordered' | 'elevated';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
class: className,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'rounded-xl bg-white dark:bg-gray-800',
|
||||
{
|
||||
'border border-gray-200 dark:border-gray-700': variant === 'default' || variant === 'bordered',
|
||||
'shadow-lg': variant === 'elevated',
|
||||
'p-0': padding === 'none',
|
||||
'p-4': padding === 'sm',
|
||||
'p-6': padding === 'md',
|
||||
'p-8': padding === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern 3: CVA (Class Variance Authority)
|
||||
|
||||
```bash
|
||||
npm install class-variance-authority
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/lib/components/Button/variants.ts
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
// Base classes
|
||||
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'text-gray-600 hover:bg-gray-100',
|
||||
link: 'text-primary-600 underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
icon: 'h-10 w-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Button.svelte -->
|
||||
<script lang="ts">
|
||||
import { buttonVariants, type ButtonVariants } from './variants';
|
||||
import { cn } from '$lib/utils/cn';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends ButtonVariants, HTMLButtonAttributes {
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant, size, class: className, children, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class={cn(buttonVariants({ variant, size }), className)} {...rest}>
|
||||
{@render children()}
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌓 Dark Mode
|
||||
|
||||
### With Tailwind
|
||||
|
||||
```svelte
|
||||
<!-- ThemeToggle.svelte -->
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
let theme = $state<Theme>('system');
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const saved = localStorage.getItem('theme') as Theme | null;
|
||||
if (saved) {
|
||||
theme = saved;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (theme === 'light') {
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
// System preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('theme', theme);
|
||||
});
|
||||
|
||||
function cycle() {
|
||||
const themes: Theme[] = ['light', 'dark', 'system'];
|
||||
const current = themes.indexOf(theme);
|
||||
theme = themes[(current + 1) % themes.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={cycle}
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{#if theme === 'light'}
|
||||
☀️
|
||||
{:else if theme === 'dark'}
|
||||
🌙
|
||||
{:else}
|
||||
💻
|
||||
{/if}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Prevent Flash of Wrong Theme
|
||||
|
||||
```html
|
||||
<!-- app.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
// Prevent flash of wrong theme
|
||||
(function() {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' ||
|
||||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 Responsive Design
|
||||
|
||||
### Tailwind Breakpoints
|
||||
|
||||
```svelte
|
||||
<div class="
|
||||
grid grid-cols-1
|
||||
sm:grid-cols-2
|
||||
md:grid-cols-3
|
||||
lg:grid-cols-4
|
||||
xl:grid-cols-5
|
||||
gap-4
|
||||
">
|
||||
{#each items as item}
|
||||
<div class="p-4 bg-white rounded-lg shadow">
|
||||
{item.name}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Responsive typography -->
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
|
||||
Responsive Heading
|
||||
</h1>
|
||||
|
||||
<!-- Hide/show based on screen size -->
|
||||
<nav class="hidden md:flex gap-4">
|
||||
<!-- Desktop navigation -->
|
||||
</nav>
|
||||
|
||||
<button class="md:hidden">
|
||||
<!-- Mobile menu button -->
|
||||
</button>
|
||||
```
|
||||
|
||||
### Container Queries (Tailwind v3.4+)
|
||||
|
||||
```svelte
|
||||
<div class="@container">
|
||||
<div class="@md:flex @md:gap-4">
|
||||
<img class="@md:w-48" src="/image.jpg" alt="" />
|
||||
<div>
|
||||
<h2 class="@md:text-xl">Title</h2>
|
||||
<p class="@md:text-base text-sm">Description</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ Animations
|
||||
|
||||
### Tailwind Animations
|
||||
|
||||
```svelte
|
||||
<!-- Built-in animations -->
|
||||
<div class="animate-spin">Spinning</div>
|
||||
<div class="animate-pulse">Pulsing</div>
|
||||
<div class="animate-bounce">Bouncing</div>
|
||||
|
||||
<!-- Custom animation in tailwind.config.js -->
|
||||
<div class="animate-fade-in">Fading in</div>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'slide-down': 'slideDown 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Svelte Transitions
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { fade, fly, slide, scale, blur } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
let visible = $state(true);
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<!-- Fade -->
|
||||
<div transition:fade={{ duration: 300 }}>
|
||||
Fading content
|
||||
</div>
|
||||
|
||||
<!-- Fly -->
|
||||
<div transition:fly={{ y: 20, duration: 300 }}>
|
||||
Flying content
|
||||
</div>
|
||||
|
||||
<!-- Slide -->
|
||||
<div transition:slide={{ duration: 300 }}>
|
||||
Sliding content
|
||||
</div>
|
||||
|
||||
<!-- Scale -->
|
||||
<div transition:scale={{ start: 0.95, duration: 300 }}>
|
||||
Scaling content
|
||||
</div>
|
||||
|
||||
<!-- Combined with easing -->
|
||||
<div transition:fly={{ y: 20, duration: 400, easing: quintOut }}>
|
||||
Smooth flying content
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- In/out separately -->
|
||||
{#if visible}
|
||||
<div
|
||||
in:fly={{ y: 20, duration: 300 }}
|
||||
out:fade={{ duration: 200 }}
|
||||
>
|
||||
Different in/out
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Custom Transition
|
||||
|
||||
```typescript
|
||||
// src/lib/transitions/index.ts
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
export function expandHeight(
|
||||
node: Element,
|
||||
{ duration = 300, easing = cubicOut } = {}
|
||||
) {
|
||||
const style = getComputedStyle(node);
|
||||
const height = parseFloat(style.height);
|
||||
const paddingTop = parseFloat(style.paddingTop);
|
||||
const paddingBottom = parseFloat(style.paddingBottom);
|
||||
|
||||
return {
|
||||
duration,
|
||||
easing,
|
||||
css: (t: number) => `
|
||||
height: ${t * height}px;
|
||||
padding-top: ${t * paddingTop}px;
|
||||
padding-bottom: ${t * paddingBottom}px;
|
||||
overflow: hidden;
|
||||
`
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Best Practices
|
||||
|
||||
### ✅ Do:
|
||||
- Use Tailwind for utility-first styling
|
||||
- Create reusable component classes with `@layer components`
|
||||
- Use CSS variables for design tokens
|
||||
- Prefer `class:` directive for conditional classes
|
||||
- Use `cn()` utility for merging classes
|
||||
- Keep animations subtle and purposeful
|
||||
|
||||
### ❌ Don't:
|
||||
- Mix inline styles with Tailwind randomly
|
||||
- Override Tailwind classes with `!important`
|
||||
- Create deeply nested CSS selectors
|
||||
- Use `@apply` for everything (defeats the purpose)
|
||||
- Forget dark mode variants
|
||||
- Ignore accessibility (focus states, contrast)
|
||||
@@ -0,0 +1,730 @@
|
||||
# Svelte Accessibility (A11y) Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Accessibility is crucial for inclusive web applications. Svelte provides built-in accessibility warnings, and this guide covers best practices for building accessible Svelte applications.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Core Principles (POUR)
|
||||
|
||||
1. **Perceivable** - Users can perceive the content
|
||||
2. **Operable** - Users can operate the interface
|
||||
3. **Understandable** - Users can understand the content
|
||||
4. **Robust** - Content works across assistive technologies
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Keyboard Navigation
|
||||
|
||||
### Focus Management
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let triggerButton: HTMLButtonElement;
|
||||
let dialogContent: HTMLDivElement;
|
||||
|
||||
async function openDialog() {
|
||||
dialogOpen = true;
|
||||
await tick();
|
||||
// Move focus to dialog
|
||||
dialogContent?.focus();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogOpen = false;
|
||||
// Return focus to trigger
|
||||
triggerButton?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<button bind:this={triggerButton} onclick={openDialog}>
|
||||
Open Dialog
|
||||
</button>
|
||||
|
||||
{#if dialogOpen}
|
||||
<div
|
||||
bind:this={dialogContent}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="dialog-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2 id="dialog-title">Dialog Title</h2>
|
||||
<p>Dialog content...</p>
|
||||
<button onclick={closeDialog}>Close</button>
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Focus Trap
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let container: HTMLElement;
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusable = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={container} onkeydown={trapFocus}>
|
||||
<!-- Focusable content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Skip Links
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<a href="#main-content" class="skip-link">
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
<nav>
|
||||
<!-- Navigation -->
|
||||
</nav>
|
||||
|
||||
<main id="main-content" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
padding: 8px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ ARIA Attributes
|
||||
|
||||
### Roles
|
||||
|
||||
```svelte
|
||||
<!-- Landmark roles -->
|
||||
<header role="banner">...</header>
|
||||
<nav role="navigation" aria-label="Main">...</nav>
|
||||
<main role="main">...</main>
|
||||
<aside role="complementary">...</aside>
|
||||
<footer role="contentinfo">...</footer>
|
||||
|
||||
<!-- Widget roles -->
|
||||
<div role="alert">Important message!</div>
|
||||
<div role="status" aria-live="polite">Status update</div>
|
||||
<div role="dialog" aria-modal="true">Dialog content</div>
|
||||
<div role="tablist">...</div>
|
||||
<div role="menu">...</div>
|
||||
```
|
||||
|
||||
### States & Properties
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let expanded = $state(false);
|
||||
let selected = $state(false);
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<!-- Expandable content -->
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-controls="panel-1"
|
||||
onclick={() => expanded = !expanded}
|
||||
>
|
||||
Toggle Panel
|
||||
</button>
|
||||
<div id="panel-1" hidden={!expanded}>
|
||||
Panel content
|
||||
</div>
|
||||
|
||||
<!-- Selection -->
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onclick={() => selected = !selected}
|
||||
>
|
||||
Option
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<button
|
||||
aria-busy={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Submit'}
|
||||
</button>
|
||||
|
||||
<!-- Described by -->
|
||||
<input
|
||||
type="email"
|
||||
aria-describedby="email-hint email-error"
|
||||
/>
|
||||
<p id="email-hint">We'll never share your email</p>
|
||||
<p id="email-error" role="alert">Invalid email format</p>
|
||||
```
|
||||
|
||||
### Live Regions
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let message = $state('');
|
||||
let items = $state<string[]>([]);
|
||||
|
||||
function addItem(item: string) {
|
||||
items.push(item);
|
||||
message = `${item} added to cart`;
|
||||
|
||||
// Clear message after announcement
|
||||
setTimeout(() => message = '', 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Polite announcements (waits for user to finish) -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{message}
|
||||
</div>
|
||||
|
||||
<!-- Assertive announcements (interrupts immediately) -->
|
||||
<div aria-live="assertive" role="alert">
|
||||
{#if error}
|
||||
{error}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Status updates -->
|
||||
<div role="status" aria-live="polite">
|
||||
{items.length} items in cart
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Forms
|
||||
|
||||
### Accessible Form Pattern
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface FormState {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
let form = $state<FormState>({ name: '', email: '' });
|
||||
let errors = $state<FormErrors>({});
|
||||
let submitted = $state(false);
|
||||
|
||||
function validate(): boolean {
|
||||
errors = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
errors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!form.email.includes('@')) {
|
||||
errors.email = 'Please enter a valid email';
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitted = true;
|
||||
|
||||
if (validate()) {
|
||||
// Submit form
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} novalidate>
|
||||
<div class="field">
|
||||
<label for="name">
|
||||
Name
|
||||
<span aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={form.name}
|
||||
aria-required="true"
|
||||
aria-invalid={submitted && !!errors.name}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
/>
|
||||
{#if submitted && errors.name}
|
||||
<p id="name-error" class="error" role="alert">
|
||||
{errors.name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">
|
||||
Email
|
||||
<span aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={form.email}
|
||||
aria-required="true"
|
||||
aria-invalid={submitted && !!errors.email}
|
||||
aria-describedby="email-hint {errors.email ? 'email-error' : ''}"
|
||||
/>
|
||||
<p id="email-hint" class="hint">
|
||||
We'll use this to send you updates
|
||||
</p>
|
||||
{#if submitted && errors.email}
|
||||
<p id="email-error" class="error" role="alert">
|
||||
{errors.email}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Field Group
|
||||
|
||||
```svelte
|
||||
<fieldset>
|
||||
<legend>Shipping Address</legend>
|
||||
|
||||
<div class="field">
|
||||
<label for="street">Street</label>
|
||||
<input id="street" type="text" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="city">City</label>
|
||||
<input id="city" type="text" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Radio group -->
|
||||
<fieldset>
|
||||
<legend>Preferred contact method</legend>
|
||||
|
||||
<div>
|
||||
<input type="radio" id="contact-email" name="contact" value="email" />
|
||||
<label for="contact-email">Email</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="radio" id="contact-phone" name="contact" value="phone" />
|
||||
<label for="contact-phone">Phone</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Images & Media
|
||||
|
||||
### Image Alternatives
|
||||
|
||||
```svelte
|
||||
<!-- Informative image -->
|
||||
<img src="/chart.png" alt="Sales increased 25% in Q3 2024" />
|
||||
|
||||
<!-- Decorative image -->
|
||||
<img src="/decoration.svg" alt="" role="presentation" />
|
||||
|
||||
<!-- Complex image with long description -->
|
||||
<figure>
|
||||
<img
|
||||
src="/complex-chart.png"
|
||||
alt="Revenue comparison chart"
|
||||
aria-describedby="chart-description"
|
||||
/>
|
||||
<figcaption id="chart-description">
|
||||
This chart shows quarterly revenue for 2024. Q1: $1.2M, Q2: $1.5M,
|
||||
Q3: $1.9M, Q4: $2.1M. Total annual revenue was $6.7M, representing
|
||||
a 15% increase over 2023.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- Icon with meaning -->
|
||||
<button>
|
||||
<svg aria-hidden="true"><!-- icon --></svg>
|
||||
<span class="sr-only">Delete item</span>
|
||||
</button>
|
||||
|
||||
<!-- Or -->
|
||||
<button aria-label="Delete item">
|
||||
<svg aria-hidden="true"><!-- icon --></svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
### Video Accessibility
|
||||
|
||||
```svelte
|
||||
<video controls>
|
||||
<source src="/video.mp4" type="video/mp4" />
|
||||
<track kind="captions" src="/captions.vtt" srclang="en" label="English" default />
|
||||
<track kind="descriptions" src="/descriptions.vtt" srclang="en" label="Audio descriptions" />
|
||||
<p>Your browser doesn't support HTML5 video. <a href="/video.mp4">Download the video</a>.</p>
|
||||
</video>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Accessible Components
|
||||
|
||||
### Accordion
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Item {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
let { items }: { items: Item[] } = $props();
|
||||
let openId = $state<string | null>(null);
|
||||
|
||||
function toggle(id: string) {
|
||||
openId = openId === id ? null : id;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-accordion-trigger]');
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
buttons[(index + 1) % buttons.length]?.focus();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
buttons[(index - 1 + buttons.length) % buttons.length]?.focus();
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
buttons[0]?.focus();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
buttons[buttons.length - 1]?.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="accordion">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const isOpen = openId === item.id}
|
||||
|
||||
<h3>
|
||||
<button
|
||||
data-accordion-trigger
|
||||
id="accordion-header-{item.id}"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="accordion-panel-{item.id}"
|
||||
onclick={() => toggle(item.id)}
|
||||
onkeydown={(e) => handleKeydown(e, index)}
|
||||
>
|
||||
{item.title}
|
||||
<span aria-hidden="true">{isOpen ? '−' : '+'}</span>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<div
|
||||
id="accordion-panel-{item.id}"
|
||||
role="region"
|
||||
aria-labelledby="accordion-header-{item.id}"
|
||||
hidden={!isOpen}
|
||||
>
|
||||
<p>{item.content}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
let { tabs }: { tabs: Tab[] } = $props();
|
||||
let activeTab = $state(tabs[0]?.id);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const currentIndex = tabs.findIndex(t => t.id === activeTab);
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
activeTab = tabs[(currentIndex + 1) % tabs.length].id;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
activeTab = tabs[(currentIndex - 1 + tabs.length) % tabs.length].id;
|
||||
break;
|
||||
case 'Home':
|
||||
activeTab = tabs[0].id;
|
||||
break;
|
||||
case 'End':
|
||||
activeTab = tabs[tabs.length - 1].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs">
|
||||
<div role="tablist" aria-label="Content tabs">
|
||||
{#each tabs as tab (tab.id)}
|
||||
{@const isActive = activeTab === tab.id}
|
||||
<button
|
||||
role="tab"
|
||||
id="tab-{tab.id}"
|
||||
aria-selected={isActive}
|
||||
aria-controls="panel-{tab.id}"
|
||||
tabindex={isActive ? 0 : -1}
|
||||
onclick={() => activeTab = tab.id}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each tabs as tab (tab.id)}
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="panel-{tab.id}"
|
||||
aria-labelledby="tab-{tab.id}"
|
||||
hidden={activeTab !== tab.id}
|
||||
tabindex="0"
|
||||
>
|
||||
{tab.content}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Modal Dialog
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onclose: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { open, title, onclose, children }: 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 handleBackdrop(e: MouseEvent) {
|
||||
if (e.target === dialog) {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
onkeydown={handleKeydown}
|
||||
onclick={handleBackdrop}
|
||||
aria-labelledby="dialog-title"
|
||||
aria-describedby="dialog-description"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<header>
|
||||
<h2 id="dialog-title">{title}</h2>
|
||||
<button
|
||||
onclick={onclose}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="dialog-description">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Screen Reader Utilities
|
||||
|
||||
### Visually Hidden Content
|
||||
|
||||
```css
|
||||
/* In app.css or component */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Can be focused (for skip links) */
|
||||
.sr-only-focusable:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: inherit;
|
||||
margin: inherit;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Usage -->
|
||||
<span class="sr-only">Total price:</span>
|
||||
<span>$99.99</span>
|
||||
|
||||
<button>
|
||||
<svg aria-hidden="true"><!-- icon --></svg>
|
||||
<span class="sr-only">Add to cart</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Accessibility Checklist
|
||||
|
||||
### Structure
|
||||
- [ ] Proper heading hierarchy (h1 → h2 → h3)
|
||||
- [ ] Landmark regions (header, nav, main, footer)
|
||||
- [ ] Skip links for keyboard users
|
||||
- [ ] Logical reading order
|
||||
|
||||
### Interactivity
|
||||
- [ ] All functionality keyboard accessible
|
||||
- [ ] Visible focus indicators
|
||||
- [ ] Focus management for modals/dialogs
|
||||
- [ ] No keyboard traps
|
||||
|
||||
### Forms
|
||||
- [ ] Labels associated with inputs
|
||||
- [ ] Error messages linked with aria-describedby
|
||||
- [ ] Required fields indicated
|
||||
- [ ] Validation errors announced
|
||||
|
||||
### Images & Media
|
||||
- [ ] Meaningful alt text
|
||||
- [ ] Decorative images have empty alt
|
||||
- [ ] Video captions/transcripts
|
||||
- [ ] Audio descriptions where needed
|
||||
|
||||
### Color & Contrast
|
||||
- [ ] 4.5:1 contrast for normal text
|
||||
- [ ] 3:1 contrast for large text
|
||||
- [ ] Information not conveyed by color alone
|
||||
- [ ] Focus indicators visible
|
||||
|
||||
### Dynamic Content
|
||||
- [ ] Live regions for updates
|
||||
- [ ] Loading states announced
|
||||
- [ ] Error states announced
|
||||
- [ ] Changes in context warned
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Tools
|
||||
|
||||
```bash
|
||||
# Install axe-core for automated testing
|
||||
npm install -D @axe-core/playwright
|
||||
|
||||
# Or use browser extensions:
|
||||
# - axe DevTools
|
||||
# - WAVE Evaluation Tool
|
||||
# - Accessibility Insights
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
1. Tab through entire page
|
||||
2. Use screen reader (VoiceOver, NVDA, JAWS)
|
||||
3. Zoom to 200%
|
||||
4. Disable CSS
|
||||
5. Check in high contrast mode
|
||||
@@ -0,0 +1,651 @@
|
||||
# Svelte Performance Optimization Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Svelte is already highly performant by design, but there are patterns to ensure your applications stay fast as they grow.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Understanding Svelte Reactivity
|
||||
|
||||
### How Svelte 5 Reactivity Works
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// $state creates a reactive proxy
|
||||
let items = $state<string[]>([]);
|
||||
|
||||
// Svelte tracks which properties are READ in which contexts
|
||||
// Only those contexts re-run when properties change
|
||||
|
||||
// This creates a dependency on items.length
|
||||
let count = $derived(items.length);
|
||||
|
||||
// This effect only runs when items changes
|
||||
$effect(() => {
|
||||
console.log('Items changed:', items);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Avoid Unnecessary Reactivity
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// ❌ BAD: Everything is reactive
|
||||
let config = $state({
|
||||
apiUrl: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
retries: 3
|
||||
});
|
||||
|
||||
// ✅ GOOD: Static config doesn't need reactivity
|
||||
const config = {
|
||||
apiUrl: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
retries: 3
|
||||
};
|
||||
|
||||
// ✅ GOOD: Use $state.raw for data that's replaced, not mutated
|
||||
let largeDataset = $state.raw<Record[]>([]);
|
||||
|
||||
async function fetchData() {
|
||||
// Replace entirely instead of mutating
|
||||
largeDataset = await fetch('/api/data').then(r => r.json());
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Component Optimization
|
||||
|
||||
### 1. Minimize Re-renders
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let items = $state<Item[]>([]);
|
||||
let filter = $state('');
|
||||
|
||||
// ✅ GOOD: Computed once, cached until dependencies change
|
||||
let filteredItems = $derived(
|
||||
items.filter(item =>
|
||||
item.name.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// ❌ BAD: Recomputed on every render
|
||||
// Using inline: items.filter(...) in template
|
||||
</script>
|
||||
|
||||
<!-- ✅ GOOD: Uses cached derived value -->
|
||||
{#each filteredItems as item (item.id)}
|
||||
<ItemCard {item} />
|
||||
{/each}
|
||||
```
|
||||
|
||||
### 2. Key Your Each Blocks
|
||||
|
||||
```svelte
|
||||
<!-- ❌ BAD: No key, Svelte can't optimize updates -->
|
||||
{#each items as item}
|
||||
<Item {item} />
|
||||
{/each}
|
||||
|
||||
<!-- ✅ GOOD: Unique key enables efficient diffing -->
|
||||
{#each items as item (item.id)}
|
||||
<Item {item} />
|
||||
{/each}
|
||||
|
||||
<!-- ✅ GOOD: Index as key only for static lists -->
|
||||
{#each staticLabels as label, i (i)}
|
||||
<span>{label}</span>
|
||||
{/each}
|
||||
```
|
||||
|
||||
### 3. Lazy Load Components
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Lazy load heavy components
|
||||
let showChart = $state(false);
|
||||
|
||||
// Dynamic import
|
||||
async function loadChart() {
|
||||
const { Chart } = await import('./Chart.svelte');
|
||||
return Chart;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showChart}
|
||||
{#await loadChart() then Chart}
|
||||
<Chart data={chartData} />
|
||||
{/await}
|
||||
{/if}
|
||||
```
|
||||
|
||||
### 4. Virtualization for Long Lists
|
||||
|
||||
```svelte
|
||||
<!-- Use a virtualization library for long lists -->
|
||||
<script lang="ts">
|
||||
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||
|
||||
let items = $state<Item[]>([]);
|
||||
// Could be 10,000+ items
|
||||
</script>
|
||||
|
||||
<!-- Only renders visible items -->
|
||||
<VirtualList
|
||||
{items}
|
||||
itemHeight={50}
|
||||
let:item
|
||||
>
|
||||
<ItemRow {item} />
|
||||
</VirtualList>
|
||||
```
|
||||
|
||||
Simple Virtual List Implementation:
|
||||
|
||||
```svelte
|
||||
<!-- VirtualList.svelte -->
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
itemHeight: number;
|
||||
containerHeight?: number;
|
||||
overscan?: number;
|
||||
children: Snippet<[T, number]>;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
itemHeight,
|
||||
containerHeight = 400,
|
||||
overscan = 3,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let scrollTop = $state(0);
|
||||
|
||||
let totalHeight = $derived(items.length * itemHeight);
|
||||
|
||||
let startIndex = $derived(
|
||||
Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
|
||||
);
|
||||
|
||||
let endIndex = $derived(
|
||||
Math.min(
|
||||
items.length,
|
||||
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
|
||||
)
|
||||
);
|
||||
|
||||
let visibleItems = $derived(
|
||||
items.slice(startIndex, endIndex).map((item, i) => ({
|
||||
item,
|
||||
index: startIndex + i
|
||||
}))
|
||||
);
|
||||
|
||||
let offsetY = $derived(startIndex * itemHeight);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="virtual-list-container"
|
||||
style:height="{containerHeight}px"
|
||||
onscroll={(e) => scrollTop = e.currentTarget.scrollTop}
|
||||
>
|
||||
<div
|
||||
class="virtual-list-content"
|
||||
style:height="{totalHeight}px"
|
||||
>
|
||||
<div style:transform="translateY({offsetY}px)">
|
||||
{#each visibleItems as { item, index } (index)}
|
||||
<div style:height="{itemHeight}px">
|
||||
{@render children(item, index)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.virtual-list-container {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.virtual-list-content {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Data Loading Optimization
|
||||
|
||||
### 1. Parallel Loading
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
// ✅ GOOD: Parallel fetching
|
||||
const [users, posts, stats] = await Promise.all([
|
||||
fetch('/api/users').then(r => r.json()),
|
||||
fetch('/api/posts').then(r => r.json()),
|
||||
fetch('/api/stats').then(r => r.json())
|
||||
]);
|
||||
|
||||
return { users, posts, stats };
|
||||
};
|
||||
|
||||
// ❌ BAD: Sequential fetching
|
||||
export const loadBad: PageLoad = async ({ fetch }) => {
|
||||
const users = await fetch('/api/users').then(r => r.json());
|
||||
const posts = await fetch('/api/posts').then(r => r.json());
|
||||
const stats = await fetch('/api/stats').then(r => r.json());
|
||||
|
||||
return { users, posts, stats };
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Streaming with Deferred Loading
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+page.server.ts
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
// Critical data loads immediately
|
||||
const user = await fetch('/api/user').then(r => r.json());
|
||||
|
||||
// Non-critical data streams in
|
||||
return {
|
||||
user,
|
||||
// These are promises, will stream when ready
|
||||
posts: fetch('/api/posts').then(r => r.json()),
|
||||
recommendations: fetch('/api/recommendations').then(r => r.json())
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<!-- Immediate content -->
|
||||
<h1>Welcome, {data.user.name}</h1>
|
||||
|
||||
<!-- Streamed content -->
|
||||
{#await data.posts}
|
||||
<PostsSkeleton />
|
||||
{:then posts}
|
||||
<PostsList {posts} />
|
||||
{/await}
|
||||
|
||||
{#await data.recommendations}
|
||||
<RecommendationsSkeleton />
|
||||
{:then recommendations}
|
||||
<Recommendations {recommendations} />
|
||||
{/await}
|
||||
```
|
||||
|
||||
### 3. Prefetching
|
||||
|
||||
```svelte
|
||||
<!-- SvelteKit auto-prefetches on hover -->
|
||||
<a href="/about">About</a>
|
||||
|
||||
<!-- Prefetch on viewport entry -->
|
||||
<a href="/products" data-sveltekit-preload-data="hover">Products</a>
|
||||
|
||||
<!-- Programmatic prefetch -->
|
||||
<script lang="ts">
|
||||
import { preloadData } from '$app/navigation';
|
||||
|
||||
// Prefetch when likely needed
|
||||
function handleMouseEnter() {
|
||||
preloadData('/expensive-page');
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Image Optimization
|
||||
|
||||
### Responsive Images
|
||||
|
||||
```svelte
|
||||
<picture>
|
||||
<source
|
||||
media="(min-width: 1024px)"
|
||||
srcset="/hero-large.webp 1x, /hero-large@2x.webp 2x"
|
||||
type="image/webp"
|
||||
/>
|
||||
<source
|
||||
media="(min-width: 640px)"
|
||||
srcset="/hero-medium.webp 1x, /hero-medium@2x.webp 2x"
|
||||
type="image/webp"
|
||||
/>
|
||||
<img
|
||||
src="/hero-small.jpg"
|
||||
srcset="/hero-small.jpg 1x, /hero-small@2x.jpg 2x"
|
||||
alt="Hero image"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="800"
|
||||
height="400"
|
||||
/>
|
||||
</picture>
|
||||
```
|
||||
|
||||
### Lazy Loading Images
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
let { src, alt, width, height }: Props = $props();
|
||||
|
||||
let loaded = $state(false);
|
||||
let element: HTMLImageElement;
|
||||
|
||||
$effect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
element.src = src;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="image-wrapper" style:aspect-ratio="{width}/{height}">
|
||||
<img
|
||||
bind:this={element}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
class:loaded
|
||||
onload={() => loaded = true}
|
||||
/>
|
||||
{#if !loaded}
|
||||
<div class="placeholder" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
img {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Effect Optimization
|
||||
|
||||
### Debounce Effects
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let searchQuery = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
|
||||
// ❌ BAD: Fires on every keystroke
|
||||
$effect(() => {
|
||||
fetchResults(searchQuery);
|
||||
});
|
||||
|
||||
// ✅ GOOD: Debounced
|
||||
$effect(() => {
|
||||
const query = searchQuery; // Read reactive value
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (query) {
|
||||
fetchResults(query).then(r => results = r);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Avoid Heavy Computations in Effects
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let items = $state<Item[]>([]);
|
||||
|
||||
// ❌ BAD: Heavy computation in effect
|
||||
$effect(() => {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
// Complex sorting logic
|
||||
});
|
||||
// Do something with sorted
|
||||
});
|
||||
|
||||
// ✅ GOOD: Use $derived for computations
|
||||
let sortedItems = $derived.by(() => {
|
||||
return [...items].sort((a, b) => {
|
||||
// Complex sorting logic
|
||||
});
|
||||
});
|
||||
|
||||
// Effect only for side effects
|
||||
$effect(() => {
|
||||
console.log('Sorted items updated:', sortedItems.length);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Cleanup Long-Running Effects
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let data = $state<Data | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch('/api/data', { signal: controller.signal })
|
||||
.then(r => r.json())
|
||||
.then(d => data = d)
|
||||
.catch(e => {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup: abort if component unmounts or effect re-runs
|
||||
return () => controller.abort();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Bundle Optimization
|
||||
|
||||
### Code Splitting
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Vendor chunks
|
||||
'vendor-svelte': ['svelte'],
|
||||
'vendor-chart': ['chart.js'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Tree Shaking
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Named imports enable tree shaking
|
||||
import { formatDate, formatCurrency } from '$lib/utils';
|
||||
|
||||
// ❌ BAD: Imports entire module
|
||||
import * as utils from '$lib/utils';
|
||||
```
|
||||
|
||||
### Analyze Bundle
|
||||
|
||||
```bash
|
||||
# Install analyzer
|
||||
npm install -D rollup-plugin-visualizer
|
||||
|
||||
# Add to vite.config.ts
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
visualizer({
|
||||
filename: 'stats.html',
|
||||
open: true
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 Measuring Performance
|
||||
|
||||
### Browser DevTools
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
// Measure component mount time
|
||||
performance.mark('component-start');
|
||||
|
||||
return () => {
|
||||
performance.mark('component-end');
|
||||
performance.measure('component-lifecycle', 'component-start', 'component-end');
|
||||
};
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Core Web Vitals
|
||||
|
||||
```typescript
|
||||
// src/lib/vitals.ts
|
||||
import type { Metric } from 'web-vitals';
|
||||
|
||||
export function reportVitals(metric: Metric) {
|
||||
// Send to analytics
|
||||
console.log(metric.name, metric.value);
|
||||
|
||||
// Or send to your analytics service
|
||||
fetch('/api/vitals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(metric)
|
||||
});
|
||||
}
|
||||
|
||||
// In +layout.svelte
|
||||
import { onMount } from 'svelte';
|
||||
import { reportVitals } from '$lib/vitals';
|
||||
|
||||
onMount(async () => {
|
||||
const { onCLS, onFID, onFCP, onLCP, onTTFB } = await import('web-vitals');
|
||||
|
||||
onCLS(reportVitals);
|
||||
onFID(reportVitals);
|
||||
onFCP(reportVitals);
|
||||
onLCP(reportVitals);
|
||||
onTTFB(reportVitals);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Performance Checklist
|
||||
|
||||
### Build Time
|
||||
- [ ] Minimal dependencies
|
||||
- [ ] Tree-shakeable imports
|
||||
- [ ] Code splitting configured
|
||||
- [ ] Image optimization
|
||||
|
||||
### Runtime
|
||||
- [ ] Keys on `{#each}` blocks
|
||||
- [ ] Virtualization for long lists
|
||||
- [ ] Debounced inputs
|
||||
- [ ] Lazy loaded heavy components
|
||||
- [ ] Optimized images (lazy, responsive)
|
||||
|
||||
### Data Loading
|
||||
- [ ] Parallel fetching where possible
|
||||
- [ ] Streaming for non-critical data
|
||||
- [ ] Prefetching for navigation
|
||||
- [ ] Caching strategies
|
||||
|
||||
### Effects
|
||||
- [ ] Cleanup functions for subscriptions
|
||||
- [ ] Debounced network requests
|
||||
- [ ] Heavy computations in `$derived`
|
||||
- [ ] AbortController for fetch
|
||||
@@ -0,0 +1,695 @@
|
||||
# Svelte Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Testing Svelte applications involves unit testing components, integration testing with stores/context, and end-to-end testing with real browsers.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Testing Setup
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
# Vitest for unit/integration tests
|
||||
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
|
||||
|
||||
# Playwright for E2E tests
|
||||
npm install -D @playwright/test
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### vitest.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/tests/setup.ts'],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['node_modules/', 'src/tests/']
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test Setup File
|
||||
|
||||
```typescript
|
||||
// src/tests/setup.ts
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock SvelteKit modules
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
invalidateAll: vi.fn(),
|
||||
preloadData: vi.fn(),
|
||||
preloadCode: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: {
|
||||
subscribe: vi.fn()
|
||||
},
|
||||
navigating: {
|
||||
subscribe: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$app/environment', () => ({
|
||||
browser: true,
|
||||
dev: true,
|
||||
building: false
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Unit Testing Components
|
||||
|
||||
### Basic Component Test
|
||||
|
||||
```typescript
|
||||
// src/lib/components/Button.test.ts
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders with text', () => {
|
||||
render(Button, { props: { children: 'Click me' } });
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Click me');
|
||||
});
|
||||
|
||||
it('applies variant class', () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
variant: 'primary',
|
||||
children: 'Primary'
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('btn-primary');
|
||||
});
|
||||
|
||||
it('handles click events', async () => {
|
||||
const onclick = vi.fn();
|
||||
|
||||
render(Button, {
|
||||
props: {
|
||||
onclick,
|
||||
children: 'Click'
|
||||
}
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(onclick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('is disabled when loading', () => {
|
||||
render(Button, {
|
||||
props: {
|
||||
loading: true,
|
||||
children: 'Submit'
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing with Snippets (Svelte 5)
|
||||
|
||||
```typescript
|
||||
// src/lib/components/Card.test.ts
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Card from './Card.svelte';
|
||||
|
||||
// Helper to create snippet-like content
|
||||
const createCardWithContent = (title: string, content: string) => {
|
||||
// For Svelte 5, you may need a wrapper component
|
||||
return {
|
||||
component: Card,
|
||||
props: {
|
||||
title
|
||||
},
|
||||
// Slot content for testing
|
||||
slots: {
|
||||
default: content
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders title and content', () => {
|
||||
render(Card, {
|
||||
props: { title: 'Card Title' }
|
||||
// Note: Testing snippets may require wrapper components
|
||||
});
|
||||
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Reactive State
|
||||
|
||||
```typescript
|
||||
// src/lib/components/Counter.test.ts
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Counter from './Counter.svelte';
|
||||
|
||||
describe('Counter', () => {
|
||||
it('increments count on click', async () => {
|
||||
render(Counter);
|
||||
|
||||
const button = screen.getByRole('button', { name: /increment/i });
|
||||
const display = screen.getByTestId('count');
|
||||
|
||||
expect(display).toHaveTextContent('0');
|
||||
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(display).toHaveTextContent('1');
|
||||
|
||||
await fireEvent.click(button);
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(display).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('starts with initial value', () => {
|
||||
render(Counter, { props: { initialValue: 10 } });
|
||||
|
||||
expect(screen.getByTestId('count')).toHaveTextContent('10');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Components
|
||||
|
||||
```typescript
|
||||
// src/lib/components/UserProfile.test.ts
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import UserProfile from './UserProfile.svelte';
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('UserProfile', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(UserProfile, { props: { userId: '1' } });
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays user data after loading', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ name: 'John Doe', email: 'john@example.com' })
|
||||
});
|
||||
|
||||
render(UserProfile, { props: { userId: '1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error on fetch failure', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(UserProfile, { props: { userId: '1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load user');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏪 Testing Stores
|
||||
|
||||
### Testing .svelte.ts Stores
|
||||
|
||||
```typescript
|
||||
// src/lib/stores/counter.svelte.ts
|
||||
export class Counter {
|
||||
count = $state(0);
|
||||
doubled = $derived(this.count * 2);
|
||||
|
||||
increment() {
|
||||
this.count++;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.count--;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// src/lib/stores/counter.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Counter } from './counter.svelte';
|
||||
|
||||
describe('Counter Store', () => {
|
||||
let counter: Counter;
|
||||
|
||||
beforeEach(() => {
|
||||
counter = new Counter();
|
||||
});
|
||||
|
||||
it('starts at 0', () => {
|
||||
expect(counter.count).toBe(0);
|
||||
});
|
||||
|
||||
it('increments', () => {
|
||||
counter.increment();
|
||||
expect(counter.count).toBe(1);
|
||||
|
||||
counter.increment();
|
||||
counter.increment();
|
||||
expect(counter.count).toBe(3);
|
||||
});
|
||||
|
||||
it('decrements', () => {
|
||||
counter.count = 5;
|
||||
counter.decrement();
|
||||
expect(counter.count).toBe(4);
|
||||
});
|
||||
|
||||
it('computes doubled value', () => {
|
||||
counter.count = 5;
|
||||
expect(counter.doubled).toBe(10);
|
||||
|
||||
counter.increment();
|
||||
expect(counter.doubled).toBe(12);
|
||||
});
|
||||
|
||||
it('resets to 0', () => {
|
||||
counter.count = 100;
|
||||
counter.reset();
|
||||
expect(counter.count).toBe(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Stores
|
||||
|
||||
```typescript
|
||||
// src/lib/stores/users.svelte.ts
|
||||
export class UsersStore {
|
||||
users = $state<User[]>([]);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
async fetchUsers() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users');
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
this.users = await response.json();
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// src/lib/stores/users.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { UsersStore } from './users.svelte';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('UsersStore', () => {
|
||||
let store: UsersStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new UsersStore();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it('fetches users successfully', async () => {
|
||||
const mockUsers = [
|
||||
{ id: '1', name: 'John' },
|
||||
{ id: '2', name: 'Jane' }
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUsers)
|
||||
});
|
||||
|
||||
await store.fetchUsers();
|
||||
|
||||
expect(store.users).toEqual(mockUsers);
|
||||
expect(store.loading).toBe(false);
|
||||
expect(store.error).toBeNull();
|
||||
});
|
||||
|
||||
it('handles fetch error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500
|
||||
});
|
||||
|
||||
await store.fetchUsers();
|
||||
|
||||
expect(store.users).toEqual([]);
|
||||
expect(store.error).toBe('Failed to fetch');
|
||||
});
|
||||
|
||||
it('sets loading state during fetch', async () => {
|
||||
mockFetch.mockImplementation(() => new Promise(resolve => {
|
||||
setTimeout(() => resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([])
|
||||
}), 100);
|
||||
}));
|
||||
|
||||
const promise = store.fetchUsers();
|
||||
|
||||
expect(store.loading).toBe(true);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(store.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Integration Testing
|
||||
|
||||
### Testing Components with Context
|
||||
|
||||
```typescript
|
||||
// src/lib/components/ThemeConsumer.test.ts
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import ThemeProvider from './ThemeProvider.svelte';
|
||||
import ThemeConsumer from './ThemeConsumer.svelte';
|
||||
|
||||
// Wrapper component for testing
|
||||
import TestWrapper from './TestWrapper.svelte';
|
||||
|
||||
describe('ThemeConsumer', () => {
|
||||
it('uses theme from provider', () => {
|
||||
// TestWrapper.svelte wraps children in ThemeProvider
|
||||
render(TestWrapper, {
|
||||
props: {
|
||||
component: ThemeConsumer,
|
||||
theme: 'dark'
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('theme-display')).toHaveTextContent('dark');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Form Submissions
|
||||
|
||||
```typescript
|
||||
// src/lib/components/LoginForm.test.ts
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import LoginForm from './LoginForm.svelte';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('submits form with credentials', async () => {
|
||||
const onsubmit = vi.fn();
|
||||
|
||||
render(LoginForm, { props: { onsubmit } });
|
||||
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText('Email'),
|
||||
{ target: { value: 'test@example.com' } }
|
||||
);
|
||||
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText('Password'),
|
||||
{ target: { value: 'password123' } }
|
||||
);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
||||
|
||||
expect(onsubmit).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation errors', async () => {
|
||||
render(LoginForm);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
||||
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Password is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button while loading', async () => {
|
||||
render(LoginForm, { props: { loading: true } });
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 E2E Testing with Playwright
|
||||
|
||||
### playwright.config.ts
|
||||
|
||||
```typescript
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173
|
||||
},
|
||||
testDir: 'tests',
|
||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' }
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { browserName: 'firefox' }
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { browserName: 'webkit' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### E2E Test Examples
|
||||
|
||||
```typescript
|
||||
// tests/auth.test.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('user can log in', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.fill('input[name="email"]', 'test@example.com');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should redirect to dashboard
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page.locator('h1')).toContainText('Dashboard');
|
||||
});
|
||||
|
||||
test('shows error for invalid credentials', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.fill('input[name="email"]', 'wrong@example.com');
|
||||
await page.fill('input[name="password"]', 'wrongpassword');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page.locator('[role="alert"]')).toContainText('Invalid credentials');
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('protected routes redirect to login', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
});
|
||||
|
||||
// tests/navigation.test.ts
|
||||
test.describe('Navigation', () => {
|
||||
test('main navigation works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.click('a[href="/about"]');
|
||||
await expect(page).toHaveURL('/about');
|
||||
await expect(page.locator('h1')).toContainText('About');
|
||||
|
||||
await page.click('a[href="/contact"]');
|
||||
await expect(page).toHaveURL('/contact');
|
||||
await expect(page.locator('h1')).toContainText('Contact');
|
||||
});
|
||||
|
||||
test('back button works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.click('a[href="/about"]');
|
||||
await page.goBack();
|
||||
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
});
|
||||
|
||||
// tests/forms.test.ts
|
||||
test.describe('Contact Form', () => {
|
||||
test('submits form successfully', async ({ page }) => {
|
||||
await page.goto('/contact');
|
||||
|
||||
await page.fill('input[name="name"]', 'John Doe');
|
||||
await page.fill('input[name="email"]', 'john@example.com');
|
||||
await page.fill('textarea[name="message"]', 'Hello, this is a test message');
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page.locator('.success-message')).toBeVisible();
|
||||
await expect(page.locator('.success-message')).toContainText('Message sent');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Accessibility with Playwright
|
||||
|
||||
```typescript
|
||||
// tests/accessibility.test.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('home page passes axe', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('login page passes axe', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.exclude('.third-party-widget') // Exclude elements you can't control
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('keyboard navigation works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Tab through focusable elements
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(':focus')).toHaveAttribute('href', '/');
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(':focus')).toHaveText('About');
|
||||
|
||||
// Can activate with Enter
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL('/about');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:all": "npm run test:unit && npm run test:e2e"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
- [ ] All components have tests
|
||||
- [ ] Props and variants tested
|
||||
- [ ] Event handlers tested
|
||||
- [ ] Edge cases covered
|
||||
- [ ] Async behavior tested
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Store interactions tested
|
||||
- [ ] Context providers tested
|
||||
- [ ] Form submissions tested
|
||||
- [ ] API calls mocked
|
||||
|
||||
### E2E Tests
|
||||
- [ ] Critical user flows covered
|
||||
- [ ] Authentication tested
|
||||
- [ ] Form submissions tested
|
||||
- [ ] Error states tested
|
||||
- [ ] Accessibility automated
|
||||
@@ -0,0 +1,656 @@
|
||||
# SvelteKit Deployment Guide
|
||||
|
||||
## Overview
|
||||
|
||||
SvelteKit supports multiple deployment targets through adapters. Choose the right adapter for your infrastructure and requirements.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Available Adapters
|
||||
|
||||
### Adapter Comparison
|
||||
|
||||
| Adapter | Platform | SSR | SSG | Edge | Serverless |
|
||||
|---------|----------|-----|-----|------|------------|
|
||||
| `adapter-node` | Node.js | ✅ | ✅ | ❌ | ❌ |
|
||||
| `adapter-auto` | Auto-detect | ✅ | ✅ | Varies | Varies |
|
||||
| `adapter-vercel` | Vercel | ✅ | ✅ | ✅ | ✅ |
|
||||
| `adapter-netlify` | Netlify | ✅ | ✅ | ✅ | ✅ |
|
||||
| `adapter-cloudflare` | Cloudflare | ✅ | ✅ | ✅ | ✅ |
|
||||
| `adapter-static` | Static hosts | ❌ | ✅ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Node.js Deployment (adapter-node)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install -D @sveltejs/adapter-node
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```javascript
|
||||
// svelte.config.js
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
// Output directory
|
||||
out: 'build',
|
||||
|
||||
// Precompress static assets with gzip/brotli
|
||||
precompress: true,
|
||||
|
||||
// Environment variable configuration
|
||||
envPrefix: 'APP_'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Build & Run
|
||||
|
||||
```bash
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Run with Node
|
||||
node build/index.js
|
||||
|
||||
# Or with environment variables
|
||||
PORT=3000 HOST=0.0.0.0 node build/index.js
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Server configuration
|
||||
PORT=3000 # Default: 3000
|
||||
HOST=0.0.0.0 # Default: 0.0.0.0
|
||||
ORIGIN=https://example.com # Required for form actions
|
||||
BODY_SIZE_LIMIT=512K # Request body limit
|
||||
```
|
||||
|
||||
### Production Process Manager (PM2)
|
||||
|
||||
```javascript
|
||||
// ecosystem.config.cjs
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'sveltekit-app',
|
||||
script: './build/index.js',
|
||||
instances: 'max',
|
||||
exec_mode: 'cluster',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3000,
|
||||
ORIGIN: 'https://example.com'
|
||||
},
|
||||
error_file: './logs/error.log',
|
||||
out_file: './logs/output.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z'
|
||||
}]
|
||||
};
|
||||
```
|
||||
|
||||
```bash
|
||||
# Start with PM2
|
||||
pm2 start ecosystem.config.cjs
|
||||
|
||||
# Save process list
|
||||
pm2 save
|
||||
|
||||
# Setup startup script
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/sveltekit-app.service
|
||||
[Unit]
|
||||
Description=SvelteKit Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/app
|
||||
ExecStart=/usr/bin/node build/index.js
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3000
|
||||
Environment=ORIGIN=https://example.com
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable sveltekit-app
|
||||
sudo systemctl start sveltekit-app
|
||||
```
|
||||
|
||||
### Nginx Reverse Proxy
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/sveltekit-app
|
||||
upstream sveltekit {
|
||||
server 127.0.0.1:3000;
|
||||
keepalive 64;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
|
||||
# Redirect to HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name example.com;
|
||||
|
||||
# SSL certificates
|
||||
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
||||
|
||||
# SSL settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
|
||||
# Static files with immutable cache
|
||||
location /_app/immutable {
|
||||
proxy_pass http://sveltekit;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Other static files
|
||||
location /static {
|
||||
proxy_pass http://sveltekit;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
# Proxy to SvelteKit
|
||||
location / {
|
||||
proxy_pass http://sveltekit;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
# Run
|
||||
CMD ["node", "build/index.js"]
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- ORIGIN=https://example.com
|
||||
- DATABASE_URL=postgresql://user:pass@db:5432/app
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: pass
|
||||
POSTGRES_DB: app
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Static Site Generation (adapter-static)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install -D @sveltejs/adapter-static
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```javascript
|
||||
// svelte.config.js
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: '404.html', // or 'index.html' for SPA
|
||||
precompress: true,
|
||||
strict: true
|
||||
}),
|
||||
prerender: {
|
||||
entries: ['*'], // Prerender all pages
|
||||
handleHttpError: 'warn'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Page Configuration for SSG
|
||||
|
||||
```typescript
|
||||
// src/routes/+layout.ts
|
||||
export const prerender = true; // Prerender all pages
|
||||
export const ssr = true; // Enable SSR during build
|
||||
|
||||
// src/routes/+page.ts
|
||||
export const prerender = true; // Prerender this specific page
|
||||
|
||||
// For dynamic routes, export entries
|
||||
export function entries() {
|
||||
return [
|
||||
{ slug: 'post-1' },
|
||||
{ slug: 'post-2' },
|
||||
{ slug: 'post-3' }
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Deploy to GitHub Pages
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
BASE_PATH: '/${{ github.event.repository.name }}'
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Vercel Deployment
|
||||
|
||||
### Configuration
|
||||
|
||||
```javascript
|
||||
// svelte.config.js
|
||||
import adapter from '@sveltejs/adapter-vercel';
|
||||
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
runtime: 'nodejs20.x', // or 'edge'
|
||||
regions: ['iad1'], // Deploy regions
|
||||
split: true // Split into multiple functions
|
||||
})
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Per-Route Configuration
|
||||
|
||||
```typescript
|
||||
// src/routes/api/slow-endpoint/+server.ts
|
||||
export const config = {
|
||||
runtime: 'nodejs20.x',
|
||||
maxDuration: 30 // Extend timeout
|
||||
};
|
||||
|
||||
// src/routes/api/fast-endpoint/+server.ts
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
regions: ['iad1', 'sfo1', 'cdg1'] // Multi-region
|
||||
};
|
||||
```
|
||||
|
||||
### vercel.json
|
||||
|
||||
```json
|
||||
{
|
||||
"framework": "sveltekit",
|
||||
"buildCommand": "npm run build",
|
||||
"installCommand": "npm ci",
|
||||
"headers": [
|
||||
{
|
||||
"source": "/_app/immutable/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Cloudflare Pages
|
||||
|
||||
### Configuration
|
||||
|
||||
```javascript
|
||||
// svelte.config.js
|
||||
import adapter from '@sveltejs/adapter-cloudflare';
|
||||
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
routes: {
|
||||
include: ['/*'],
|
||||
exclude: ['<all>']
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Platform Bindings
|
||||
|
||||
```typescript
|
||||
// src/app.d.ts
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Platform {
|
||||
env: {
|
||||
KV_NAMESPACE: KVNamespace;
|
||||
D1_DATABASE: D1Database;
|
||||
R2_BUCKET: R2Bucket;
|
||||
};
|
||||
context: {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// src/routes/+page.server.ts
|
||||
export async function load({ platform }) {
|
||||
const value = await platform?.env.KV_NAMESPACE.get('key');
|
||||
return { value };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```typescript
|
||||
// src/lib/server/env.ts
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { building } from '$app/environment';
|
||||
|
||||
if (!building) {
|
||||
// Validate required env vars at startup
|
||||
const required = ['DATABASE_URL', 'SECRET_KEY'];
|
||||
|
||||
for (const key of required) {
|
||||
if (!env[key]) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
secretKey: env.SECRET_KEY,
|
||||
isProduction: env.NODE_ENV === 'production'
|
||||
};
|
||||
```
|
||||
|
||||
### Security Headers
|
||||
|
||||
```typescript
|
||||
// src/hooks.server.ts
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
|
||||
// Security headers
|
||||
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
|
||||
);
|
||||
response.headers.set(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=()'
|
||||
);
|
||||
|
||||
return response;
|
||||
};
|
||||
```
|
||||
|
||||
### CORS Configuration
|
||||
|
||||
```typescript
|
||||
// src/routes/api/[...path]/+server.ts
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const allowedOrigins = ['https://example.com', 'https://app.example.com'];
|
||||
|
||||
export const OPTIONS: RequestHandler = async ({ request }) => {
|
||||
const origin = request.headers.get('origin');
|
||||
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 403 });
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Health Checks & Monitoring
|
||||
|
||||
### Health Endpoint
|
||||
|
||||
```typescript
|
||||
// src/routes/health/+server.ts
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
version: process.env.npm_package_version
|
||||
};
|
||||
|
||||
// Add database check
|
||||
try {
|
||||
// await db.query('SELECT 1');
|
||||
health.database = 'connected';
|
||||
} catch {
|
||||
health.database = 'disconnected';
|
||||
health.status = 'degraded';
|
||||
}
|
||||
|
||||
const statusCode = health.status === 'healthy' ? 200 : 503;
|
||||
|
||||
return json(health, { status: statusCode });
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Deployment Checklist
|
||||
|
||||
### Pre-Deployment
|
||||
- [ ] All tests passing
|
||||
- [ ] Environment variables documented
|
||||
- [ ] Build succeeds locally
|
||||
- [ ] Security headers configured
|
||||
- [ ] Error pages customized
|
||||
- [ ] Logging configured
|
||||
|
||||
### Production
|
||||
- [ ] SSL/TLS enabled
|
||||
- [ ] CDN configured
|
||||
- [ ] Database backups scheduled
|
||||
- [ ] Monitoring/alerting setup
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] Health checks configured
|
||||
|
||||
### Post-Deployment
|
||||
- [ ] Smoke tests passing
|
||||
- [ ] Performance metrics acceptable
|
||||
- [ ] Error tracking working
|
||||
- [ ] Rollback plan documented
|
||||
@@ -0,0 +1,705 @@
|
||||
# Svelte 5 Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers migrating from Svelte 4 to Svelte 5, with focus on the new Runes system which replaces the old reactive syntax.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Key Changes at a Glance
|
||||
|
||||
| Svelte 4 | Svelte 5 | Description |
|
||||
|----------|----------|-------------|
|
||||
| `let count = 0` | `let count = $state(0)` | Reactive state |
|
||||
| `$: doubled = count * 2` | `let doubled = $derived(count * 2)` | Derived values |
|
||||
| `$: { sideEffect() }` | `$effect(() => { sideEffect() })` | Side effects |
|
||||
| `export let prop` | `let { prop } = $props()` | Component props |
|
||||
| `<slot />` | `{@render children()}` | Rendering children |
|
||||
| `<slot name="x" />` | `{@render x?.()}` | Named slots → Snippets |
|
||||
| `$$props`, `$$restProps` | `$props()` with rest | All props access |
|
||||
| `createEventDispatcher()` | Callback props | Event handling |
|
||||
|
||||
---
|
||||
|
||||
## 📦 State Migration
|
||||
|
||||
### Basic State
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
let count = 0;
|
||||
|
||||
function increment() {
|
||||
count += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
function increment() {
|
||||
count += 1;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Object/Array State
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 - Needed reassignment for reactivity -->
|
||||
<script>
|
||||
let items = [];
|
||||
|
||||
function addItem(item) {
|
||||
items = [...items, item]; // Had to reassign
|
||||
}
|
||||
|
||||
let user = { name: '', age: 0 };
|
||||
|
||||
function updateName(name) {
|
||||
user = { ...user, name }; // Had to spread
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 - Deep reactivity -->
|
||||
<script>
|
||||
let items = $state([]);
|
||||
|
||||
function addItem(item) {
|
||||
items.push(item); // Direct mutation works!
|
||||
}
|
||||
|
||||
let user = $state({ name: '', age: 0 });
|
||||
|
||||
function updateName(name) {
|
||||
user.name = name; // Direct property assignment works!
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧮 Derived Values Migration
|
||||
|
||||
### Simple Derived
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
let count = 0;
|
||||
$: doubled = count * 2;
|
||||
$: tripled = count * 3;
|
||||
$: total = doubled + tripled;
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
let tripled = $derived(count * 3);
|
||||
let total = $derived(doubled + tripled);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Complex Derived
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
let items = [];
|
||||
let filter = '';
|
||||
|
||||
$: filteredItems = items.filter(item =>
|
||||
item.name.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
$: {
|
||||
// Complex computation
|
||||
let result = [];
|
||||
for (const item of items) {
|
||||
if (item.active) {
|
||||
result.push(processItem(item));
|
||||
}
|
||||
}
|
||||
processedItems = result;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
<script>
|
||||
let items = $state([]);
|
||||
let filter = $state('');
|
||||
|
||||
let filteredItems = $derived(
|
||||
items.filter(item =>
|
||||
item.name.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// For complex computations, use $derived.by()
|
||||
let processedItems = $derived.by(() => {
|
||||
let result = [];
|
||||
for (const item of items) {
|
||||
if (item.active) {
|
||||
result.push(processItem(item));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Effects Migration
|
||||
|
||||
### Basic Effects
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
let count = 0;
|
||||
|
||||
// Reactive statement as effect
|
||||
$: console.log('Count changed:', count);
|
||||
|
||||
// Effect with cleanup wasn't possible inline
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let interval;
|
||||
onMount(() => {
|
||||
interval = setInterval(() => count++, 1000);
|
||||
});
|
||||
onDestroy(() => clearInterval(interval));
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
// Effect automatically tracks dependencies
|
||||
$effect(() => {
|
||||
console.log('Count changed:', count);
|
||||
});
|
||||
|
||||
// Effect with cleanup
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => count++, 1000);
|
||||
|
||||
return () => clearInterval(interval); // Cleanup function
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Pre-Effects (beforeUpdate replacement)
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
import { beforeUpdate, afterUpdate } from 'svelte';
|
||||
|
||||
let messages = [];
|
||||
let container;
|
||||
let autoscroll = false;
|
||||
|
||||
beforeUpdate(() => {
|
||||
autoscroll = container &&
|
||||
container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (autoscroll) {
|
||||
container.scrollTo(0, container.scrollHeight);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
<script>
|
||||
let messages = $state([]);
|
||||
let container = $state();
|
||||
|
||||
$effect.pre(() => {
|
||||
// Runs before DOM updates
|
||||
if (!container) return;
|
||||
// ... pre-update logic
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Runs after DOM updates
|
||||
if (!container) return;
|
||||
container.scrollTo(0, container.scrollHeight);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎁 Props Migration
|
||||
|
||||
### Basic Props
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
export let name;
|
||||
export let count = 0; // With default
|
||||
export let optional = undefined;
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
<script>
|
||||
let {
|
||||
name,
|
||||
count = 0,
|
||||
optional
|
||||
} = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Rest Props
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
export let variant = 'primary';
|
||||
// $$restProps used in template
|
||||
</script>
|
||||
|
||||
<button class={variant} {...$$restProps}>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
<script>
|
||||
let {
|
||||
variant = 'primary',
|
||||
children,
|
||||
...restProps
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button class={variant} {...restProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Bindable Props (Two-way Binding)
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 - Parent -->
|
||||
<script>
|
||||
let value = '';
|
||||
</script>
|
||||
<Input bind:value />
|
||||
|
||||
<!-- ❌ Svelte 4 - Child (Input.svelte) -->
|
||||
<script>
|
||||
export let value;
|
||||
</script>
|
||||
<input bind:value />
|
||||
|
||||
<!-- ✅ Svelte 5 - Parent (same) -->
|
||||
<script>
|
||||
let value = $state('');
|
||||
</script>
|
||||
<Input bind:value />
|
||||
|
||||
<!-- ✅ Svelte 5 - Child (Input.svelte) -->
|
||||
<script>
|
||||
let { value = $bindable() } = $props();
|
||||
</script>
|
||||
<input bind:value />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎰 Slots → Snippets Migration
|
||||
|
||||
### Default Slot
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 - Card.svelte -->
|
||||
<div class="card">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- ❌ Svelte 4 - Usage -->
|
||||
<Card>
|
||||
<p>Card content</p>
|
||||
</Card>
|
||||
|
||||
<!-- ✅ Svelte 5 - Card.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- ✅ Svelte 5 - Usage -->
|
||||
<Card>
|
||||
<p>Card content</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Named Slots
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 - Layout.svelte -->
|
||||
<header>
|
||||
<slot name="header" />
|
||||
</header>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<footer>
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
|
||||
<!-- ❌ Svelte 4 - Usage -->
|
||||
<Layout>
|
||||
<h1 slot="header">Title</h1>
|
||||
<p>Main content</p>
|
||||
<span slot="footer">Footer</span>
|
||||
</Layout>
|
||||
|
||||
<!-- ✅ Svelte 5 - Layout.svelte -->
|
||||
<script>
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
header,
|
||||
children,
|
||||
footer
|
||||
}: {
|
||||
header?: Snippet;
|
||||
children?: Snippet;
|
||||
footer?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{@render header?.()}
|
||||
</header>
|
||||
<main>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
<footer>
|
||||
{@render footer?.()}
|
||||
</footer>
|
||||
|
||||
<!-- ✅ Svelte 5 - Usage -->
|
||||
<Layout>
|
||||
{#snippet header()}
|
||||
<h1>Title</h1>
|
||||
{/snippet}
|
||||
|
||||
<p>Main content</p>
|
||||
|
||||
{#snippet footer()}
|
||||
<span>Footer</span>
|
||||
{/snippet}
|
||||
</Layout>
|
||||
```
|
||||
|
||||
### Slot Props → Snippet Parameters
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 - List.svelte -->
|
||||
<script>
|
||||
export let items = [];
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li>
|
||||
<slot {item} index={items.indexOf(item)} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- ❌ Svelte 4 - Usage -->
|
||||
<List {items} let:item let:index>
|
||||
<span>{index}: {item.name}</span>
|
||||
</List>
|
||||
|
||||
<!-- ✅ Svelte 5 - List.svelte -->
|
||||
<script>
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
children
|
||||
}: {
|
||||
items: Item[];
|
||||
children: Snippet<[{ item: Item; index: number }]>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item, index}
|
||||
<li>
|
||||
{@render children({ item, index })}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- ✅ Svelte 5 - Usage -->
|
||||
<List {items}>
|
||||
{#snippet children({ item, index })}
|
||||
<span>{index}: {item.name}</span>
|
||||
{/snippet}
|
||||
</List>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 Events Migration
|
||||
|
||||
### Custom Events
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 - Button.svelte -->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleClick() {
|
||||
dispatch('click', { timestamp: Date.now() });
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={handleClick}>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
<!-- ❌ Svelte 4 - Usage -->
|
||||
<Button on:click={(e) => console.log(e.detail.timestamp)}>
|
||||
Click me
|
||||
</Button>
|
||||
|
||||
<!-- ✅ Svelte 5 - Button.svelte -->
|
||||
<script>
|
||||
let {
|
||||
onclick,
|
||||
children
|
||||
}: {
|
||||
onclick?: (data: { timestamp: number }) => void;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
function handleClick() {
|
||||
onclick?.({ timestamp: Date.now() });
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={handleClick}>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<!-- ✅ Svelte 5 - Usage -->
|
||||
<Button onclick={(data) => console.log(data.timestamp)}>
|
||||
Click me
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Event Forwarding
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 - Wrapper.svelte -->
|
||||
<button on:click on:mouseenter on:mouseleave>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
<!-- ✅ Svelte 5 - Wrapper.svelte -->
|
||||
<script>
|
||||
let {
|
||||
onclick,
|
||||
onmouseenter,
|
||||
onmouseleave,
|
||||
children,
|
||||
...restProps
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!-- Option 1: Explicit forwarding -->
|
||||
<button {onclick} {onmouseenter} {onmouseleave}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
||||
<!-- Option 2: Spread all props -->
|
||||
<button {...restProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏪 Stores Migration
|
||||
|
||||
### Writable Stores
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const count = writable(0);
|
||||
|
||||
function increment() {
|
||||
count.update(n => n + 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>
|
||||
Count: {$count}
|
||||
</button>
|
||||
|
||||
<!-- ✅ Svelte 5 - Using class-based state -->
|
||||
// stores/counter.svelte.ts
|
||||
export class Counter {
|
||||
count = $state(0);
|
||||
|
||||
increment() {
|
||||
this.count++;
|
||||
}
|
||||
}
|
||||
|
||||
export const counter = new Counter();
|
||||
|
||||
// Component
|
||||
<script>
|
||||
import { counter } from './stores/counter.svelte';
|
||||
</script>
|
||||
|
||||
<button onclick={() => counter.increment()}>
|
||||
Count: {counter.count}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Derived Stores
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
const count = writable(0);
|
||||
const doubled = derived(count, $count => $count * 2);
|
||||
</script>
|
||||
|
||||
<p>Doubled: {$doubled}</p>
|
||||
|
||||
<!-- ✅ Svelte 5 -->
|
||||
// stores/counter.svelte.ts
|
||||
export class Counter {
|
||||
count = $state(0);
|
||||
doubled = $derived(this.count * 2);
|
||||
}
|
||||
|
||||
// Component
|
||||
<script>
|
||||
import { counter } from './stores/counter.svelte';
|
||||
</script>
|
||||
|
||||
<p>Doubled: {counter.doubled}</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Component Lifecycle
|
||||
|
||||
### onMount / onDestroy
|
||||
|
||||
```svelte
|
||||
<!-- ❌ Svelte 4 -->
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let data;
|
||||
let subscription;
|
||||
|
||||
onMount(async () => {
|
||||
data = await fetchData();
|
||||
subscription = subscribe();
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ✅ Svelte 5 - onMount still works! -->
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let data = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
data = await fetchData();
|
||||
});
|
||||
|
||||
// For subscriptions, use $effect
|
||||
$effect(() => {
|
||||
const subscription = subscribe();
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Migration Checklist
|
||||
|
||||
### Phase 1: Preparation
|
||||
- [ ] Update to latest Svelte 4.x
|
||||
- [ ] Fix all deprecation warnings
|
||||
- [ ] Ensure tests are passing
|
||||
- [ ] Review component inventory
|
||||
|
||||
### Phase 2: Upgrade
|
||||
- [ ] Update to Svelte 5
|
||||
- [ ] Update SvelteKit (if using)
|
||||
- [ ] Update dependencies
|
||||
|
||||
### Phase 3: Migrate Components
|
||||
- [ ] Convert `export let` → `$props()`
|
||||
- [ ] Convert reactive declarations → `$derived()`
|
||||
- [ ] Convert reactive statements → `$effect()`
|
||||
- [ ] Convert slots → snippets
|
||||
- [ ] Convert events → callback props
|
||||
- [ ] Convert stores → class-based state
|
||||
|
||||
### Phase 4: Validation
|
||||
- [ ] Run all tests
|
||||
- [ ] Check for runtime errors
|
||||
- [ ] Performance testing
|
||||
- [ ] Accessibility audit
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Automated Migration
|
||||
|
||||
Svelte 5 includes a migration script:
|
||||
|
||||
```bash
|
||||
# Run the migration script
|
||||
npx sv migrate svelte-5
|
||||
|
||||
# This will:
|
||||
# - Update syntax to Svelte 5
|
||||
# - Convert reactive statements
|
||||
# - Update event handlers
|
||||
# - Flag manual review items
|
||||
```
|
||||
|
||||
**Note:** Always review automated changes and test thoroughly!
|
||||
@@ -0,0 +1,735 @@
|
||||
# Svelte Common Utilities & Helpers
|
||||
|
||||
## Overview
|
||||
|
||||
A collection of reusable utilities, helpers, and patterns commonly needed in Svelte 5 applications.
|
||||
|
||||
---
|
||||
|
||||
## 🎣 Custom Hooks (Reactive Utilities)
|
||||
|
||||
### useLocalStorage
|
||||
|
||||
```typescript
|
||||
// src/lib/hooks/useLocalStorage.svelte.ts
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
let value = $state<T>(initialValue);
|
||||
|
||||
// Load from localStorage on init
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
value = initialValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync to localStorage on change
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get value() { return value; },
|
||||
set value(v: T) { value = v; },
|
||||
reset() { value = initialValue; }
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const theme = useLocalStorage('theme', 'light');
|
||||
// theme.value = 'dark';
|
||||
```
|
||||
|
||||
### useDebounce
|
||||
|
||||
```typescript
|
||||
// src/lib/hooks/useDebounce.svelte.ts
|
||||
export function useDebounce<T>(getValue: () => T, delay: number = 300) {
|
||||
let debouncedValue = $state<T>(getValue());
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
$effect(() => {
|
||||
const currentValue = getValue();
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
debouncedValue = currentValue;
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return {
|
||||
get value() { return debouncedValue; }
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
let searchQuery = $state('');
|
||||
const debouncedQuery = useDebounce(() => searchQuery, 300);
|
||||
// Use debouncedQuery.value for API calls
|
||||
```
|
||||
|
||||
### useThrottle
|
||||
|
||||
```typescript
|
||||
// src/lib/hooks/useThrottle.svelte.ts
|
||||
export function useThrottle<T>(getValue: () => T, limit: number = 300) {
|
||||
let throttledValue = $state<T>(getValue());
|
||||
let lastRun = 0;
|
||||
|
||||
$effect(() => {
|
||||
const currentValue = getValue();
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastRun >= limit) {
|
||||
throttledValue = currentValue;
|
||||
lastRun = now;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get value() { return throttledValue; }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### useMediaQuery
|
||||
|
||||
```typescript
|
||||
// src/lib/hooks/useMediaQuery.svelte.ts
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
let matches = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
matches = mediaQuery.matches;
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
matches = e.matches;
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
});
|
||||
|
||||
return {
|
||||
get matches() { return matches; }
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
// {#if isMobile.matches}...{/if}
|
||||
```
|
||||
|
||||
### useOnline
|
||||
|
||||
```typescript
|
||||
// src/lib/hooks/useOnline.svelte.ts
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export function useOnline() {
|
||||
let isOnline = $state(browser ? navigator.onLine : true);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const handleOnline = () => { isOnline = true; };
|
||||
const handleOffline = () => { isOnline = false; };
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
get isOnline() { return isOnline; }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### useClickOutside
|
||||
|
||||
```typescript
|
||||
// src/lib/hooks/useClickOutside.svelte.ts
|
||||
export function useClickOutside(
|
||||
getElement: () => HTMLElement | null,
|
||||
callback: () => void
|
||||
) {
|
||||
$effect(() => {
|
||||
const element = getElement();
|
||||
if (!element) return;
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (!element.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
});
|
||||
}
|
||||
|
||||
// Usage
|
||||
let menuRef = $state<HTMLElement>();
|
||||
let isOpen = $state(false);
|
||||
|
||||
useClickOutside(
|
||||
() => menuRef,
|
||||
() => { isOpen = false; }
|
||||
);
|
||||
```
|
||||
|
||||
### useIntersectionObserver
|
||||
|
||||
```typescript
|
||||
// src/lib/hooks/useIntersectionObserver.svelte.ts
|
||||
interface UseIntersectionOptions {
|
||||
threshold?: number | number[];
|
||||
root?: Element | null;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(
|
||||
getElement: () => HTMLElement | null,
|
||||
options: UseIntersectionOptions = {}
|
||||
) {
|
||||
let isIntersecting = $state(false);
|
||||
let entry = $state<IntersectionObserverEntry | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const element = getElement();
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([e]) => {
|
||||
isIntersecting = e.isIntersecting;
|
||||
entry = e;
|
||||
},
|
||||
{
|
||||
threshold: options.threshold ?? 0,
|
||||
root: options.root ?? null,
|
||||
rootMargin: options.rootMargin ?? '0px'
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
get isIntersecting() { return isIntersecting; },
|
||||
get entry() { return entry; }
|
||||
};
|
||||
}
|
||||
|
||||
// Usage - Lazy loading
|
||||
let imageRef = $state<HTMLElement>();
|
||||
const { isIntersecting } = useIntersectionObserver(() => imageRef);
|
||||
// Load image when isIntersecting becomes true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Utility Functions
|
||||
|
||||
### Class Name Utilities
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/cn.ts
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Usage
|
||||
cn('px-4 py-2', isActive && 'bg-blue-500', className)
|
||||
```
|
||||
|
||||
### Format Utilities
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/format.ts
|
||||
|
||||
// Currency formatting
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currency = 'USD',
|
||||
locale = 'en-US'
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
export function formatDate(
|
||||
date: Date | string | number,
|
||||
options: Intl.DateTimeFormatOptions = {},
|
||||
locale = 'en-US'
|
||||
): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
...options
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// Relative time
|
||||
export function formatRelativeTime(date: Date | string | number): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHour = Math.round(diffMin / 60);
|
||||
const diffDay = Math.round(diffHour / 24);
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||
|
||||
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second');
|
||||
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute');
|
||||
if (Math.abs(diffHour) < 24) return rtf.format(diffHour, 'hour');
|
||||
return rtf.format(diffDay, 'day');
|
||||
}
|
||||
|
||||
// Number formatting
|
||||
export function formatNumber(
|
||||
num: number,
|
||||
options: Intl.NumberFormatOptions = {}
|
||||
): string {
|
||||
return new Intl.NumberFormat('en-US', options).format(num);
|
||||
}
|
||||
|
||||
// Compact number (1K, 1M, etc.)
|
||||
export function formatCompact(num: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short'
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
// File size
|
||||
export function formatFileSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
```
|
||||
|
||||
### String Utilities
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/string.ts
|
||||
|
||||
// Truncate text
|
||||
export function truncate(str: string, length: number, ending = '...'): string {
|
||||
if (str.length <= length) return str;
|
||||
return str.slice(0, length - ending.length) + ending;
|
||||
}
|
||||
|
||||
// Slugify
|
||||
export function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// Capitalize
|
||||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
// Title case
|
||||
export function titleCase(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => capitalize(word))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Pluralize
|
||||
export function pluralize(
|
||||
count: number,
|
||||
singular: string,
|
||||
plural?: string
|
||||
): string {
|
||||
return count === 1 ? singular : (plural ?? `${singular}s`);
|
||||
}
|
||||
```
|
||||
|
||||
### Array Utilities
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/array.ts
|
||||
|
||||
// Group by key
|
||||
export function groupBy<T>(
|
||||
array: T[],
|
||||
key: keyof T | ((item: T) => string)
|
||||
): Record<string, T[]> {
|
||||
return array.reduce((groups, item) => {
|
||||
const groupKey = typeof key === 'function' ? key(item) : String(item[key]);
|
||||
groups[groupKey] = [...(groups[groupKey] || []), item];
|
||||
return groups;
|
||||
}, {} as Record<string, T[]>);
|
||||
}
|
||||
|
||||
// Unique values
|
||||
export function unique<T>(array: T[]): T[] {
|
||||
return [...new Set(array)];
|
||||
}
|
||||
|
||||
// Unique by key
|
||||
export function uniqueBy<T>(array: T[], key: keyof T): T[] {
|
||||
const seen = new Set();
|
||||
return array.filter(item => {
|
||||
const k = item[key];
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Chunk array
|
||||
export function chunk<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// Shuffle array
|
||||
export function shuffle<T>(array: T[]): T[] {
|
||||
const result = [...array];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sort by key
|
||||
export function sortBy<T>(
|
||||
array: T[],
|
||||
key: keyof T,
|
||||
order: 'asc' | 'desc' = 'asc'
|
||||
): T[] {
|
||||
return [...array].sort((a, b) => {
|
||||
const aVal = a[key];
|
||||
const bVal = b[key];
|
||||
const compare = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||
return order === 'asc' ? compare : -compare;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Object Utilities
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/object.ts
|
||||
|
||||
// Deep clone
|
||||
export function deepClone<T>(obj: T): T {
|
||||
return structuredClone(obj);
|
||||
}
|
||||
|
||||
// Pick keys
|
||||
export function pick<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[]
|
||||
): Pick<T, K> {
|
||||
return keys.reduce((result, key) => {
|
||||
if (key in obj) result[key] = obj[key];
|
||||
return result;
|
||||
}, {} as Pick<T, K>);
|
||||
}
|
||||
|
||||
// Omit keys
|
||||
export function omit<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[]
|
||||
): Omit<T, K> {
|
||||
const result = { ...obj };
|
||||
for (const key of keys) {
|
||||
delete result[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Deep equal
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((val, i) => deepEqual(val, b[i]));
|
||||
}
|
||||
|
||||
if (typeof a === 'object' && typeof b === 'object') {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
return keysA.every(key =>
|
||||
deepEqual((a as any)[key], (b as any)[key])
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Svelte Actions
|
||||
|
||||
### Focus Trap
|
||||
|
||||
```typescript
|
||||
// src/lib/actions/focusTrap.ts
|
||||
export function focusTrap(node: HTMLElement) {
|
||||
const focusableElements = node.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const first = focusableElements[0] as HTMLElement;
|
||||
const last = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
last.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
first.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeydown);
|
||||
first?.focus();
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Tooltip
|
||||
|
||||
```typescript
|
||||
// src/lib/actions/tooltip.ts
|
||||
interface TooltipOptions {
|
||||
text: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export function tooltip(node: HTMLElement, options: TooltipOptions) {
|
||||
let tooltipEl: HTMLDivElement | null = null;
|
||||
|
||||
function show() {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.className = `tooltip tooltip-${options.position ?? 'top'}`;
|
||||
tooltipEl.textContent = options.text;
|
||||
|
||||
document.body.appendChild(tooltipEl);
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const tipRect = tooltipEl.getBoundingClientRect();
|
||||
|
||||
// Position based on option
|
||||
switch (options.position ?? 'top') {
|
||||
case 'top':
|
||||
tooltipEl.style.left = `${rect.left + rect.width / 2 - tipRect.width / 2}px`;
|
||||
tooltipEl.style.top = `${rect.top - tipRect.height - 8}px`;
|
||||
break;
|
||||
case 'bottom':
|
||||
tooltipEl.style.left = `${rect.left + rect.width / 2 - tipRect.width / 2}px`;
|
||||
tooltipEl.style.top = `${rect.bottom + 8}px`;
|
||||
break;
|
||||
// ... other positions
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
tooltipEl?.remove();
|
||||
tooltipEl = null;
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', show);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
|
||||
return {
|
||||
update(newOptions: TooltipOptions) {
|
||||
options = newOptions;
|
||||
},
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener('mouseenter', show);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-resize Textarea
|
||||
|
||||
```typescript
|
||||
// src/lib/actions/autoResize.ts
|
||||
export function autoResize(node: HTMLTextAreaElement) {
|
||||
function resize() {
|
||||
node.style.height = 'auto';
|
||||
node.style.height = `${node.scrollHeight}px`;
|
||||
}
|
||||
|
||||
resize();
|
||||
node.addEventListener('input', resize);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('input', resize);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage: <textarea use:autoResize />
|
||||
```
|
||||
|
||||
### Copy to Clipboard
|
||||
|
||||
```typescript
|
||||
// src/lib/actions/clipboard.ts
|
||||
interface ClipboardOptions {
|
||||
text: string;
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function clipboard(node: HTMLElement, options: ClipboardOptions) {
|
||||
async function handleClick() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(options.text);
|
||||
options.onCopy?.();
|
||||
} catch (error) {
|
||||
options.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('click', handleClick);
|
||||
|
||||
return {
|
||||
update(newOptions: ClipboardOptions) {
|
||||
options = newOptions;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('click', handleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
// <button use:clipboard={{ text: 'Copy me!', onCopy: () => showToast('Copied!') }}>
|
||||
// Copy
|
||||
// </button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Type Utilities
|
||||
|
||||
```typescript
|
||||
// src/lib/types/utils.ts
|
||||
|
||||
// Make all properties optional recursively
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||
};
|
||||
|
||||
// Make specified keys required
|
||||
export type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
// Extract array element type
|
||||
export type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
|
||||
|
||||
// Async function return type
|
||||
export type AsyncReturnType<T extends (...args: any) => Promise<any>> =
|
||||
T extends (...args: any) => Promise<infer R> ? R : never;
|
||||
|
||||
// Make all properties non-nullable
|
||||
export type NonNullableProps<T> = {
|
||||
[P in keyof T]: NonNullable<T[P]>;
|
||||
};
|
||||
|
||||
// Record with known keys
|
||||
export type StrictRecord<K extends string, V> = { [P in K]: V };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Export Index
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/index.ts
|
||||
export * from './cn';
|
||||
export * from './format';
|
||||
export * from './string';
|
||||
export * from './array';
|
||||
export * from './object';
|
||||
|
||||
// src/lib/hooks/index.ts
|
||||
export * from './useLocalStorage.svelte';
|
||||
export * from './useDebounce.svelte';
|
||||
export * from './useThrottle.svelte';
|
||||
export * from './useMediaQuery.svelte';
|
||||
export * from './useOnline.svelte';
|
||||
export * from './useClickOutside.svelte';
|
||||
export * from './useIntersectionObserver.svelte';
|
||||
|
||||
// src/lib/actions/index.ts
|
||||
export * from './focusTrap';
|
||||
export * from './tooltip';
|
||||
export * from './autoResize';
|
||||
export * from './clipboard';
|
||||
```
|
||||
@@ -0,0 +1,178 @@
|
||||
# Svelte Expert Persona & Instructions
|
||||
|
||||
## 🎭 Persona: Svelte Architect
|
||||
|
||||
I am a **Svelte 5 Expert** with deep knowledge of:
|
||||
- Svelte 5 Runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`)
|
||||
- SvelteKit for full-stack applications
|
||||
- Component architecture and composition patterns
|
||||
- Reactive programming paradigms
|
||||
- Performance optimization techniques
|
||||
- TypeScript integration with Svelte
|
||||
- Modern CSS/Tailwind integration
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Core Principles
|
||||
|
||||
### 1. **Runes-First Reactivity (Svelte 5)**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// ✅ Use Runes for all reactive state
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
|
||||
$effect(() => {
|
||||
console.log(`Count changed to ${count}`);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. **Props with `$props()` Rune**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// ✅ Svelte 5 way
|
||||
let { name, age = 18, onclick }: {
|
||||
name: string;
|
||||
age?: number;
|
||||
onclick?: () => void
|
||||
} = $props();
|
||||
|
||||
// ❌ OLD Svelte 4 way - DO NOT USE
|
||||
// export let name;
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. **Bindable Props with `$bindable()`**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { value = $bindable('') }: { value?: string } = $props();
|
||||
</script>
|
||||
|
||||
<input bind:value />
|
||||
```
|
||||
|
||||
### 4. **Component Composition Over Inheritance**
|
||||
```svelte
|
||||
<!-- ✅ Use slots and snippets for composition -->
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h2>Title</h2>
|
||||
{/snippet}
|
||||
|
||||
<p>Content goes here</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Mandatory Checklist
|
||||
|
||||
Before writing any Svelte code, verify:
|
||||
|
||||
- [ ] Using Svelte 5 syntax (Runes, not `export let`)
|
||||
- [ ] TypeScript enabled (`lang="ts"`)
|
||||
- [ ] Props typed with TypeScript interfaces
|
||||
- [ ] State managed with `$state()` rune
|
||||
- [ ] Derived values use `$derived()` rune
|
||||
- [ ] Side effects wrapped in `$effect()`
|
||||
- [ ] Components are single-responsibility
|
||||
- [ ] CSS is scoped or uses Tailwind
|
||||
- [ ] Accessibility attributes included
|
||||
- [ ] Error boundaries considered
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Never Do This:
|
||||
```svelte
|
||||
<!-- OLD Svelte 4 syntax -->
|
||||
<script>
|
||||
export let name; // ❌ Use $props() instead
|
||||
$: doubled = count * 2; // ❌ Use $derived() instead
|
||||
$: { console.log(count); } // ❌ Use $effect() instead
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Always Do This:
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { name }: { name: string } = $props();
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
|
||||
$effect(() => {
|
||||
console.log(count);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tools & Commands
|
||||
|
||||
### Validation Commands
|
||||
```bash
|
||||
# Type check Svelte files
|
||||
npx svelte-check --tsconfig ./tsconfig.json
|
||||
|
||||
# Build with strict mode
|
||||
npm run build
|
||||
|
||||
# Run dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### VS Code Extensions Required
|
||||
- Svelte for VS Code (official)
|
||||
- Svelte Intellisense
|
||||
- TypeScript + JavaScript Language Features
|
||||
|
||||
---
|
||||
|
||||
## 📁 File Naming Conventions
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ │ ├── Button.svelte
|
||||
│ │ ├── Card.svelte
|
||||
│ │ └── Modal.svelte
|
||||
│ ├── stores/ # Global state stores
|
||||
│ │ └── auth.svelte.ts
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ └── formatters.ts
|
||||
│ └── types/ # TypeScript types
|
||||
│ └── index.ts
|
||||
├── routes/ # SvelteKit routes
|
||||
│ ├── +page.svelte
|
||||
│ ├── +layout.svelte
|
||||
│ └── api/
|
||||
└── app.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Quick Reference Links
|
||||
|
||||
- [Svelte 5 Docs](https://svelte.dev/docs/svelte/overview)
|
||||
- [SvelteKit Docs](https://svelte.dev/docs/kit/introduction)
|
||||
- [Svelte 5 Runes](https://svelte.dev/docs/svelte/$state)
|
||||
- [Svelte Tutorial](https://learn.svelte.dev/)
|
||||
|
||||
---
|
||||
|
||||
## 📞 How to Use This Persona
|
||||
|
||||
When asking for Svelte help, I will:
|
||||
|
||||
1. **Always use Svelte 5 syntax** with Runes
|
||||
2. **Provide TypeScript-first examples**
|
||||
3. **Follow component best practices**
|
||||
4. **Optimize for performance**
|
||||
5. **Include accessibility considerations**
|
||||
6. **Reference this guide for consistency**
|
||||
|
||||
Invoke with: *"As the Svelte Expert, [your question]"*
|
||||
Reference in New Issue
Block a user