@classic-homes/offline-svelte
v0.1.3
Published
Svelte bindings for offline persistence and caching
Downloads
66
Readme
@classic-homes/offline-svelte
Svelte 5 composables for offline persistence and caching. Provides reactive file storage, form draft persistence, and data caching using Svelte's runes.
Features
- useFileStorage: Reactive file storage with validation and previews
- usePersistedForm: Automatic form draft persistence
- useOfflineCache: Data caching with TTL and stale-while-revalidate
- Svelte 5 Runes: Built with
$stateand$effectfor optimal reactivity - TypeScript: Full type safety with generics
Installation
npm install @classic-homes/offline-svelte @classic-homes/offline-coreQuick Start
<script lang="ts">
import { onMount } from 'svelte';
import { initOfflineStorage } from '@classic-homes/offline-core';
import { useFileStorage, usePersistedForm, useOfflineCache } from '@classic-homes/offline-svelte';
// Initialize storage at app startup (e.g., in +layout.svelte)
onMount(async () => {
await initOfflineStorage();
});
</script>Composables
useFileStorage
Manage file attachments with validation, preview generation, and persistence.
<script lang="ts">
import { useFileStorage } from '@classic-homes/offline-svelte';
const documents = useFileStorage({
formKey: 'contact-form',
fieldName: 'documents',
maxFiles: 5,
maxSizeBytes: 10 * 1024 * 1024, // 10MB
acceptedTypes: ['application/pdf', 'image/*'],
onValidationError: (errors) => {
errors.forEach((e) => toast.error(e.message));
},
});
async function handleDrop(files: File[]) {
await documents.addFiles(files);
}
</script>
{#if documents.isLoading}
<Spinner />
{:else}
<ul>
{#each documents.files as file (file.id)}
<li>
{#if file.previewUrl}
<img src={file.previewUrl} alt={file.filename} />
{/if}
<span>{file.filename}</span>
<button onclick={() => documents.removeFile(file.id)}>Remove</button>
</li>
{/each}
</ul>
{/if}
<DropZone ondrop={handleDrop} />Options
| Option | Type | Description |
| ------------------- | ---------- | ------------------------------------------------ |
| formKey | string | Form identifier |
| fieldName | string | Field name within the form |
| maxFiles | number | Maximum number of files |
| maxSizeBytes | number | Maximum file size in bytes |
| acceptedTypes | string[] | Accepted MIME types (supports wildcards) |
| expirationMs | number | File expiration time |
| generatePreviews | boolean | Generate preview URLs for images (default: true) |
| onValidationError | function | Callback for validation errors |
Return Value
| Property | Type | Description |
| ------------------- | ----------------- | -------------------------------- |
| files | PersistedFile[] | Current files (reactive) |
| fileIds | string[] | File IDs for form submission |
| isLoading | boolean | Loading state |
| error | Error \| null | Current error |
| addFiles | function | Add files (validates and stores) |
| removeFile | function | Remove a file by ID |
| clearFiles | function | Remove all files |
| reload | function | Reload from storage |
| getFilesForSubmit | function | Get File objects for FormData |
usePersistedForm
Automatically persist form state across page reloads.
<script lang="ts">
import { usePersistedForm } from '@classic-homes/offline-svelte';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string(),
});
const form = usePersistedForm({
formKey: 'contact-form',
defaultValues: { name: '', email: '', message: '' },
schema,
debounceMs: 500,
excludeFields: ['password'],
});
async function handleSubmit() {
if (!form.isValid) return;
await submitForm(form.values);
await form.clearDraft();
}
</script>
<form onsubmit|preventDefault={handleSubmit}>
{#if form.hasDraft}
<button type="button" onclick={form.restoreDraft}> Restore draft </button>
{/if}
<input
name="name"
value={form.values.name}
oninput={(e) => form.setValue('name', e.currentTarget.value)}
/>
{#if form.errors.name}
<span class="error">{form.errors.name}</span>
{/if}
<input
name="email"
value={form.values.email}
oninput={(e) => form.setValue('email', e.currentTarget.value)}
/>
{#if form.errors.email}
<span class="error">{form.errors.email}</span>
{/if}
<textarea
name="message"
value={form.values.message}
oninput={(e) => form.setValue('message', e.currentTarget.value)}
></textarea>
<button type="submit" disabled={!form.isValid || form.isSubmitting}> Submit </button>
</form>Options
| Option | Type | Description |
| --------------- | ----------- | ------------------------------------------ |
| formKey | string | Unique form identifier |
| defaultValues | T | Default form values |
| schema | ZodSchema | Optional Zod validation schema |
| debounceMs | number | Debounce time for auto-save (default: 500) |
| excludeFields | string[] | Fields to exclude from persistence |
| expirationMs | number | Draft expiration time |
| autoRestore | boolean | Auto-restore on mount (default: false) |
Return Value
| Property | Type | Description |
| -------------- | ---------------- | -------------------------------- |
| values | T | Current form values (reactive) |
| errors | FieldErrors<T> | Validation errors by field |
| isValid | boolean | Whether form passes validation |
| isDirty | boolean | Whether form has unsaved changes |
| hasDraft | boolean | Whether a draft exists |
| draftInfo | object | Draft metadata |
| setValue | function | Set a field value |
| setValues | function | Set multiple values |
| reset | function | Reset to default values |
| saveDraft | function | Manually save draft |
| restoreDraft | function | Restore from draft |
| clearDraft | function | Delete the draft |
useOfflineCache
Cache data with TTL and stale-while-revalidate support.
<script lang="ts">
import { useOfflineCache } from '@classic-homes/offline-svelte';
interface User {
id: string;
name: string;
email: string;
}
let { userId } = $props<{ userId: string }>();
const userCache = useOfflineCache<User>({
key: `user:${userId}`,
ttl: 5 * 60 * 1000, // 5 minutes
tags: ['user'],
staleWhileRevalidate: true,
fetcher: () => fetchUser(userId), // Auto-fetch on mount
});
// Or manually fetch
$effect(() => {
userCache.get(() => fetchUser(userId));
});
</script>
{#if userCache.isLoading}
<Spinner />
{:else if userCache.error}
<Error error={userCache.error} />
{:else if userCache.data}
<div>
<h1>{userCache.data.name}</h1>
{#if userCache.isStale}
<span>Updating...</span>
{/if}
<button onclick={() => userCache.refresh(() => fetchUser(userId))}> Refresh </button>
</div>
{/if}Options
| Option | Type | Description |
| ---------------------- | ---------- | ------------------------------------- |
| key | string | Cache key |
| ttl | number | Time to live in milliseconds |
| tags | string[] | Tags for bulk invalidation |
| staleWhileRevalidate | boolean | Return stale data while fetching |
| staleTime | number | How long stale data is acceptable |
| fetcher | function | Auto-fetch function on initialization |
Return Value
| Property | Type | Description |
| -------------- | ---------------- | ---------------------- |
| data | T \| null | Cached data (reactive) |
| isLoading | boolean | Loading state |
| isStale | boolean | Whether data is stale |
| error | Error \| null | Current error |
| ttlRemaining | number \| null | Time until expiration |
| get | function | Get cached or fetch |
| set | function | Set cache value |
| invalidate | function | Invalidate this key |
| refresh | function | Force re-fetch |
| has | function | Check if cached |
Utility Functions
<script lang="ts">
import { invalidateCacheByTag, invalidateAllCache } from '@classic-homes/offline-svelte';
async function handleLogout() {
// Invalidate all user-related cache entries
await invalidateCacheByTag('user');
}
async function clearAllCache() {
await invalidateAllCache();
}
</script>Subpath Exports
Import individual composables for better tree-shaking:
// Import specific composable
import { useFileStorage } from '@classic-homes/offline-svelte/useFileStorage';
import { usePersistedForm } from '@classic-homes/offline-svelte/usePersistedForm';
import { useOfflineCache } from '@classic-homes/offline-svelte/useOfflineCache';TypeScript
All composables are fully typed with generics:
<script lang="ts">
interface User {
id: string;
name: string;
email: string;
}
const userCache = useOfflineCache<User>({ key: 'user' });
// userCache.data is User | null
const form = usePersistedForm<User>({
formKey: 'user',
defaultValues: { id: '', name: '', email: '' },
});
// form.values is User
// form.setValue('name', value) - 'name' is typed
</script>Svelte 5 Runes
The composables use Svelte 5's runes for reactivity:
$statefor reactive values$effectfor side effects and cleanup- Getter functions for derived values
This means the returned properties are reactive without needing stores:
<script lang="ts">
const cache = useOfflineCache({ key: 'data' });
// cache.data, cache.isLoading, etc. are reactive
// No need for $: reactive statements
</script>
<!-- Automatically updates when data changes -->
{#if cache.data}
<pre>{JSON.stringify(cache.data, null, 2)}</pre>
{/if}Testing
npm test # Run tests
npm run test:watch # Watch mode
npm run test:coverage # With coverageLicense
MIT
