rune-form
Version:
Type-safe reactive form builder for Svelte 5
484 lines (386 loc) • 12.3 kB
Markdown
# RuneForm
A powerful, reactive form library for Svelte 5 using runes and Zod validation with automatic memory management.
## ✨ Features
- **🚀 Svelte 5 Runes**: Built with the latest Svelte 5 runes for optimal performance
- **🔒 Zod Integration**: Full TypeScript support with Zod schemas and validation
- **🎯 Automatic Touched Tracking**: Automatically tracks which fields have been modified
- **⚡ Real-time Validation**: Debounced validation with error handling
- **🌳 Nested Objects**: Deep support for complex nested form structures
- **📋 Dynamic Arrays**: Advanced array operations with automatic state synchronization
- **🧠 Memory Management**: Automatic resource disposal with `Symbol.dispose`
- **🎨 Form Enhancement**: Built-in form submission handling
- **🔧 Array Manipulation**: Rich set of array operations (push, splice, swap, etc.)
- **💾 Caching**: Intelligent caching with memory leak prevention
## 📦 Installation
```bash
npm install rune-form
```
## 🚀 Quick Start
```svelte
<script lang="ts">
import { RuneForm } from 'rune-form';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18 years old')
});
const form = RuneForm.fromSchema(schema);
const handleSubmit = async (data) => {
console.log('Form data:', data);
// Submit to server...
};
</script>
<form use:form.enhance={{ onSubmit: handleSubmit }}>
<div>
<input type="text" bind:value={form.data.name} placeholder="Name" />
{#if form.touched.name && form.errors.name}
<span class="error">{form.errors.name[0]}</span>
{/if}
</div>
<div>
<input type="email" bind:value={form.data.email} placeholder="Email" />
{#if form.touched.email && form.errors.email}
<span class="error">{form.errors.email[0]}</span>
{/if}
</div>
<button type="submit" disabled={!form.isValid || form.isValidating}>
{form.isValidating ? 'Validating...' : 'Submit'}
</button>
</form>
```
## 🎯 Automatic Touched Tracking
RuneForm automatically tracks which fields have been modified, regardless of how you interact with the form data.
### Direct Data Binding (Automatic)
```svelte
<script>
const form = RuneForm.fromSchema(schema);
</script>
<input type="text" bind:value={form.data.name} />
<!-- form.touched.name automatically becomes true after user modifies the field -->
```
### Deep Nested Object Tracking
```svelte
<script>
const schema = z.object({
address: z.object({
street: z.string(),
city: z.string(),
country: z.object({
name: z.string(),
code: z.string()
})
})
});
const form = RuneForm.fromSchema(schema);
</script>
<input type="text" bind:value={form.data.address.street} />
<!-- form.touched['address.street'] automatically becomes true -->
<input type="text" bind:value={form.data.address.country.name} />
<!-- form.touched['address.country.name'] automatically becomes true -->
```
### Using getField (Automatic)
```svelte
<script>
const nameField = form.getField('name');
const streetField = form.getField('address.street');
</script>
<input type="text" bind:value={nameField.value} />
<!-- nameField.touched automatically becomes true -->
<input type="text" bind:value={streetField.value} />
<!-- streetField.touched automatically becomes true -->
```
## 📋 Advanced Array Operations
RuneForm provides powerful array manipulation capabilities with automatic state synchronization.
### Dynamic Arrays with Rich Operations
```svelte
<script>
const schema = z.object({
items: z.array(
z.object({
name: z.string(),
quantity: z.number(),
tags: z.array(z.string())
})
)
});
const form = RuneForm.fromSchema(schema, {
items: [{ name: 'Item 1', quantity: 1, tags: ['tag1'] }]
});
</script>
{#each form.data.items as item, i (i)}
<div class="item">
<input type="text" bind:value={item.name} />
<input type="number" bind:value={item.quantity} />
<!-- Array operations -->
<button onclick={() => form.splice('items', i, 1)}>Remove</button>
<button onclick={() => form.swap('items', i, i - 1)} disabled={i === 0}>Move Up</button>
<button onclick={() => form.swap('items', i, i + 1)} disabled={i === form.data.items.length - 1}
>Move Down</button
>
</div>
{/each}
<!-- Add new items -->
<button onclick={() => form.push('items', { name: '', quantity: 1, tags: [] })}> Add Item </button>
<!-- Insert at specific position -->
<button onclick={() => form.splice('items', 1, 0, { name: 'New Item', quantity: 1, tags: [] })}>
Insert at Position 1
</button>
```
### Array Operations API
```typescript
// Add items to the end
form.push('items', newItem);
// Insert at specific position
form.splice('items', index, 0, newItem);
// Remove items
form.splice('items', index, 1);
// Replace items
form.splice('items', index, 1, newItem);
// Swap items
form.swap('items', index1, index2);
// Direct array mutations (also tracked automatically)
form.data.items.push(newItem);
form.data.items.splice(index, 1);
form.data.items[0] = updatedItem;
```
## 🧠 Memory Management
RuneForm includes automatic memory management to prevent memory leaks.
### Automatic Resource Disposal
```svelte
<script>
import { onDestroy } from 'svelte';
const form = RuneForm.fromSchema(schema);
// Automatic disposal when component is destroyed
onDestroy(() => {
form.dispose(); // Optional: explicit cleanup
});
</script>
```
### Symbol.dispose Support
RuneForm implements `Symbol.dispose` for automatic resource management:
```typescript
// Automatic disposal when form goes out of scope
{
const form = RuneForm.fromSchema(schema);
// Use form...
// form[Symbol.dispose]() is automatically called when leaving scope
}
```
## 🔧 Advanced Features
### Custom Error Handling
```svelte
<script>
const handleSubmit = async (data) => {
// Custom validation
if (data.password !== data.confirmPassword) {
form.setCustomError('confirmPassword', 'Passwords do not match');
return;
}
// Multiple custom errors
form.setCustomErrors('email', ['Email already exists', 'Please use a different email']);
// Submit form
await submitToServer(data);
};
</script>
```
### Form State Management
```typescript
// Check form state
console.log(form.isValid); // boolean
console.log(form.isValidating); // boolean
console.log(form.errors); // Record<string, string[]>
console.log(form.touched); // Record<string, boolean>
// Manage touched state
form.markTouched('name');
form.markFieldAsPristine('name');
form.markAllTouched();
form.markAllAsPristine();
// Reset form
form.reset(); // Clears all data, errors, and touched state
```
### Field Access with getField
```svelte
<script>
const nameField = form.getField('name');
const addressField = form.getField('address');
const nestedField = form.getField('address.street');
const arrayField = form.getField('items.0.name');
</script>
<!-- Field object provides rich information -->
<div>
<input type="text" bind:value={nameField.value} />
{#if nameField.touched && nameField.error}
<span class="error">{nameField.error}</span>
{/if}
<span>Validating: {nameField.isValidating}</span>
</div>
```
## 📚 API Reference
### RuneForm Class
#### Constructor
```typescript
new RuneForm<T>(
validator: Validator<T>,
initialData?: Partial<T>
)
```
#### Static Methods
```typescript
RuneForm.fromSchema<S extends ZodObject>(
schema: S,
initialData?: Partial<z.infer<S>>
): RuneForm<z.infer<S>>
```
#### Instance Methods
```typescript
// Field access
getField<K extends Paths<T>>(path: K): FieldObject
// Touched state management
markTouched(path: Paths<T>): void
markFieldAsPristine(path: Paths<T>): void
markAllTouched(): void
markAllAsPristine(): void
// Form state
reset(): void
validateSchema(): Promise<void>
// Array operations
push<K extends ArrayPaths<T>>(path: K, value: PathValue<T, `${K}.${number}`>): void
splice<K extends ArrayPaths<T>>(path: K, start: number, deleteCount?: number, ...items: PathValue<T, `${K}.${number}`>[]): void
swap<K extends ArrayPaths<T>>(path: K, i: number, j: number): void
// Custom errors
setCustomError(path: Paths<T>, message: string): void
setCustomErrors(path: Paths<T>, messages: string[]): void
// Resource management
dispose(): void
[Symbol.dispose](): void
// Form enhancement
get enhance(): (node: HTMLFormElement) => { destroy(): void }
```
#### Properties
```typescript
// Reactive state
data: T;
errors: Record<string, string[]>;
customErrors: Partial<Record<string, string[]>>;
touched: Record<string, boolean>;
isValid: boolean;
isValidating: boolean;
```
### FieldObject Interface
```typescript
interface FieldObject {
value: PathValue<T, K>;
error: string | undefined;
errors: string[];
touched: boolean;
constraints: Record<string, unknown>;
isValidating: boolean;
}
```
## 🎨 Complete Example
```svelte
<script lang="ts">
import { RuneForm } from 'rune-form';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
address: z.object({
street: z.string().min(2, 'Street is required'),
city: z.string().min(2, 'City is required'),
zip: z.string().regex(/^\d{5}$/, 'Invalid ZIP code')
}),
items: z
.array(
z.object({
name: z.string().min(1, 'Item name is required'),
quantity: z.number().min(1, 'Quantity must be at least 1')
})
)
.min(1, 'At least one item is required')
});
const form = RuneForm.fromSchema(schema, {
name: '',
email: '',
address: {
street: '',
city: '',
zip: ''
},
items: []
});
const handleSubmit = async (data) => {
console.log('Submitting:', data);
// Submit to server...
};
</script>
<form use:form.enhance={{ onSubmit: handleSubmit }} class="space-y-6">
<!-- Basic fields -->
<div>
<label for="name">Name</label>
<input id="name" type="text" bind:value={form.data.name} />
{#if form.touched.name && form.errors.name}
<span class="error">{form.errors.name[0]}</span>
{/if}
</div>
<div>
<label for="email">Email</label>
<input id="email" type="email" bind:value={form.data.email} />
{#if form.touched.email && form.errors.email}
<span class="error">{form.errors.email[0]}</span>
{/if}
</div>
<!-- Nested object -->
<fieldset>
<legend>Address</legend>
<div>
<label for="street">Street</label>
<input id="street" type="text" bind:value={form.data.address.street} />
{#if form.touched['address.street'] && form.errors['address.street']}
<span class="error">{form.errors['address.street'][0]}</span>
{/if}
</div>
<!-- More address fields... -->
</fieldset>
<!-- Dynamic array -->
<fieldset>
<legend>Items</legend>
{#each form.data.items as item, i (i)}
<div class="item">
<input type="text" bind:value={item.name} placeholder="Item name" />
<input type="number" bind:value={item.quantity} min="1" />
<button type="button" onclick={() => form.splice('items', i, 1)}>Remove</button>
<button type="button" onclick={() => form.swap('items', i, i - 1)} disabled={i === 0}
>↑</button
>
<button
type="button"
onclick={() => form.swap('items', i, i + 1)}
disabled={i === form.data.items.length - 1}>↓</button
>
</div>
{/each}
<button type="button" onclick={() => form.push('items', { name: '', quantity: 1 })}>
Add Item
</button>
</fieldset>
<button type="submit" disabled={!form.isValid || form.isValidating}>
{form.isValidating ? 'Validating...' : 'Submit'}
</button>
<!-- Form state display -->
<div class="form-state">
<p>Valid: {form.isValid}</p>
<p>Validating: {form.isValidating}</p>
<p>Touched fields: {Object.keys(form.touched).length}</p>
<p>Error count: {Object.keys(form.errors).length}</p>
</div>
</form>
```
## 🔧 Performance Features
- **Intelligent Caching**: Path compilation and field object caching with automatic cleanup
- **Debounced Validation**: Prevents excessive validation calls during rapid typing
- **Memory Management**: Automatic resource disposal and memory leak prevention
- **Optimized Reactivity**: Efficient Svelte 5 rune usage for minimal re-renders
## 📄 License
MIT