svstate 0.0.1 โ 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +817 -0
- package/dist/proxy.js +5 -5
- package/dist/state.svelte.js +2 -3
- package/dist/validators.js +4 -4
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BCsabaEngine
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
# ๐ svstate
|
|
2
|
+
|
|
3
|
+
### Supercharged `$state()` for Svelte 5
|
|
4
|
+
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://svelte.dev/)
|
|
7
|
+
[](https://opensource.org/licenses/ISC)
|
|
8
|
+
[]()
|
|
9
|
+
[]()
|
|
10
|
+
|
|
11
|
+
> **Deep reactive proxy with validation, snapshot/undo, and side effects โ built for complex, real-world applications.**
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<img src="svstate.png" alt="svstate" />
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<a href="https://bcsabaengine.github.io/svstate/"><strong>๐ฎ Live Demo</strong></a> ยท
|
|
19
|
+
<a href="#-installation">Installation</a> ยท
|
|
20
|
+
<a href="#-core-features">Features</a> ยท
|
|
21
|
+
<a href="#-complete-examples">Examples</a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## ๐ค The Problem
|
|
27
|
+
|
|
28
|
+
Svelte 5's `$state()` is fantastic for simple use cases. A login form? Easy. A settings toggle? Trivial.
|
|
29
|
+
|
|
30
|
+
But what about **real enterprise applications**?
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// โ A simple user/password form is NOT your problem
|
|
34
|
+
const loginForm = $state({ username: '', password: '' });
|
|
35
|
+
|
|
36
|
+
// โ
THIS is your problem โ a complex ERP customer page
|
|
37
|
+
const customer = $state({
|
|
38
|
+
name: 'Acme Corp',
|
|
39
|
+
taxId: 'US-12345678',
|
|
40
|
+
creditLimit: 50000,
|
|
41
|
+
addresses: [
|
|
42
|
+
{ type: 'billing', street: '123 Main St', city: 'New York', zip: '10001' },
|
|
43
|
+
{ type: 'shipping', street: '456 Oak Ave', city: 'Boston', zip: '02101' }
|
|
44
|
+
],
|
|
45
|
+
contacts: [
|
|
46
|
+
{ name: 'John Doe', email: 'john@acme.com', phone: '555-1234', isPrimary: true },
|
|
47
|
+
{ name: 'Jane Smith', email: 'jane@acme.com', phone: '555-5678', isPrimary: false }
|
|
48
|
+
],
|
|
49
|
+
billing: {
|
|
50
|
+
paymentTerms: 'NET30',
|
|
51
|
+
currency: 'USD',
|
|
52
|
+
bankAccount: { iban: 'US12345678901234567890', swift: 'BOFA1234' }
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**With native Svelte 5, you're missing:**
|
|
58
|
+
|
|
59
|
+
- โ No automatic change detection for nested properties
|
|
60
|
+
- โ No built-in validation that mirrors your data structure
|
|
61
|
+
- โ No way to know _which_ property changed and react to it
|
|
62
|
+
- โ No undo/redo for complex editing workflows
|
|
63
|
+
- โ No dirty tracking across the entire object tree
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## โจ The Solution: svstate
|
|
68
|
+
|
|
69
|
+
**svstate** wraps your state in a deep reactive proxy that:
|
|
70
|
+
|
|
71
|
+
- ๐ **Detects changes** at any nesting level (`customer.billing.bankAccount.iban`)
|
|
72
|
+
- โ
**Validates** with a structure that mirrors your data
|
|
73
|
+
- โก **Fires effects** when any property changes (with full context)
|
|
74
|
+
- โช **Snapshots & undo** for complex editing workflows
|
|
75
|
+
- ๐ฏ **Tracks dirty state** automatically
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { createSvState, stringValidator, numberValidator } from 'svstate';
|
|
79
|
+
|
|
80
|
+
const { data, state, rollback, reset, execute } = createSvState(customer, {
|
|
81
|
+
validator: (source) => ({
|
|
82
|
+
/* validation that mirrors your structure */
|
|
83
|
+
}),
|
|
84
|
+
effect: ({ snapshot, property, currentValue, oldValue }) => {
|
|
85
|
+
console.log(`${property} changed from ${oldValue} to ${currentValue}`);
|
|
86
|
+
snapshot(`Changed ${property}`); // Create undo point
|
|
87
|
+
},
|
|
88
|
+
action: async () => {
|
|
89
|
+
/* Save to API */
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Deep binding just works!
|
|
94
|
+
data.billing.bankAccount.iban = 'NEW-IBAN'; // โ
Detected, validated, snapshot created
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## ๐ฆ Installation
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm install svstate
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Requirements:** Node.js โฅ20, Svelte 5
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## ๐ฏ Core Features
|
|
110
|
+
|
|
111
|
+
### 1๏ธโฃ Validation โ Structure-Aware, Real-Time
|
|
112
|
+
|
|
113
|
+
Validation in svstate mirrors your data structure exactly. When you have nested objects, your validation errors have the same shape. No more flattening, no more path strings.
|
|
114
|
+
|
|
115
|
+
**Built-in fluent validators** handle common patterns with chainable methods:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { createSvState, stringValidator, numberValidator, dateValidator } from 'svstate';
|
|
119
|
+
|
|
120
|
+
const {
|
|
121
|
+
data,
|
|
122
|
+
state: { errors, hasErrors }
|
|
123
|
+
} = createSvState(
|
|
124
|
+
{
|
|
125
|
+
email: '',
|
|
126
|
+
age: 0,
|
|
127
|
+
birthDate: new Date(),
|
|
128
|
+
tags: []
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
validator: (source) => ({
|
|
132
|
+
// Fluent API: chain validations, get first error
|
|
133
|
+
email: stringValidator(source.email, 'trim') // 'trim' preprocesses input
|
|
134
|
+
.required()
|
|
135
|
+
.email()
|
|
136
|
+
.maxLength(100)
|
|
137
|
+
.getError(),
|
|
138
|
+
|
|
139
|
+
age: numberValidator(source.age).required().integer().between(18, 120).getError(),
|
|
140
|
+
|
|
141
|
+
birthDate: dateValidator(source.birthDate).required().past().minAge(18).getError(),
|
|
142
|
+
|
|
143
|
+
tags: arrayValidator(source.tags).minLength(1).maxLength(10).unique().getError()
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// In your template:
|
|
149
|
+
// $errors?.email โ "Required" | "Invalid email format" | ""
|
|
150
|
+
// $hasErrors โ true/false
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Key features:**
|
|
154
|
+
|
|
155
|
+
- ๐ Automatic re-validation on any change (debounced via microtask)
|
|
156
|
+
- ๐ Error structure matches data structure exactly
|
|
157
|
+
- ๐งน String preprocessing: `'trim'`, `'normalize'`, `'upper'`, `'lower'`
|
|
158
|
+
- โก First-error-wins: `getError()` returns the first failure
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### 2๏ธโฃ Effect โ React to Every Change
|
|
163
|
+
|
|
164
|
+
JavaScript objects don't have property change events. **svstate fixes this.** The `effect` callback fires whenever _any_ property changes, giving you full context:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const { data } = createSvState(formData, {
|
|
168
|
+
effect: ({ target, property, currentValue, oldValue, snapshot }) => {
|
|
169
|
+
// 'property' is the dot-notation path: "address.city", "contacts.0.email"
|
|
170
|
+
console.log(`${property}: ${oldValue} โ ${currentValue}`);
|
|
171
|
+
|
|
172
|
+
// Create undo point on significant changes
|
|
173
|
+
if (property.startsWith('billing')) {
|
|
174
|
+
snapshot(`Modified billing: ${property}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Trigger side effects
|
|
178
|
+
if (property === 'country') {
|
|
179
|
+
loadTaxRates(currentValue);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Use cases:**
|
|
186
|
+
|
|
187
|
+
- ๐ธ Create snapshots for undo/redo
|
|
188
|
+
- ๐ Analytics tracking
|
|
189
|
+
- ๐ Cross-field updates (computed fields)
|
|
190
|
+
- ๐ Trigger API calls on specific changes
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
### 3๏ธโฃ Action โ Submit to Backend with Loading States
|
|
195
|
+
|
|
196
|
+
Each svstate instance has **one action** โ typically for submitting data to your backend, REST API, or cloud database (Supabase, Firebase, etc.). The `actionInProgress` store lets you show loading spinners and disable UI while waiting for the server response. This is why async support is essential.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
const { data, execute, state: { actionInProgress, actionError } } = createSvState(
|
|
200
|
+
formData,
|
|
201
|
+
{
|
|
202
|
+
validator: (source) => ({ /* ... */ }),
|
|
203
|
+
action: async (params) => {
|
|
204
|
+
// Submit to your backend, Supabase, Firebase, etc.
|
|
205
|
+
const response = await fetch('/api/customers', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
body: JSON.stringify(data)
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) throw new Error('Save failed');
|
|
210
|
+
},
|
|
211
|
+
actionCompleted: (error) => {
|
|
212
|
+
if (!error) showToast('Saved successfully!');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Show loading state while waiting for server
|
|
218
|
+
<button onclick={() => execute()} disabled={$hasErrors || $actionInProgress}>
|
|
219
|
+
{$actionInProgress ? 'Saving...' : 'Save'}
|
|
220
|
+
</button>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Action parameters** โ When you need different submit behaviors from multiple places (e.g., "Save Draft" vs "Publish", or "Save" vs "Save & Close"), pass parameters to `execute()`:
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const { data, execute } = createSvState(articleData, {
|
|
227
|
+
action: async (params?: { draft?: boolean; redirect?: string }) => {
|
|
228
|
+
await supabase.from('articles').upsert({
|
|
229
|
+
...data,
|
|
230
|
+
status: params?.draft ? 'draft' : 'published',
|
|
231
|
+
published_at: params?.draft ? null : new Date()
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (params?.redirect) goto(params.redirect);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Multiple submit buttons with different behaviors
|
|
239
|
+
<button onclick={() => execute({ draft: true })}>
|
|
240
|
+
Save Draft
|
|
241
|
+
</button>
|
|
242
|
+
|
|
243
|
+
<button onclick={() => execute({ draft: false, redirect: '/articles' })}>
|
|
244
|
+
Publish & Go Back
|
|
245
|
+
</button>
|
|
246
|
+
|
|
247
|
+
<button onclick={() => execute()}>
|
|
248
|
+
Publish
|
|
249
|
+
</button>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Key features:**
|
|
253
|
+
|
|
254
|
+
- ๐ฏ **One action per state** โ focused on data submission
|
|
255
|
+
- โณ **`actionInProgress`** โ show spinners, disable inputs while waiting
|
|
256
|
+
- ๐ **Action parameters** โ different behaviors from multiple submit points
|
|
257
|
+
- ๐ Prevents concurrent execution by default
|
|
258
|
+
- โ `actionError` store captures failures
|
|
259
|
+
- ๐ Successful action resets dirty state and snapshots
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
### 4๏ธโฃ Undo โ Snapshot-Based Time Travel
|
|
264
|
+
|
|
265
|
+
Complex forms need undo. svstate provides a snapshot system that captures state at meaningful moments:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
const {
|
|
269
|
+
data,
|
|
270
|
+
rollback,
|
|
271
|
+
reset,
|
|
272
|
+
state: { snapshots }
|
|
273
|
+
} = createSvState(formData, {
|
|
274
|
+
effect: ({ snapshot, property }) => {
|
|
275
|
+
// Create snapshot on each change
|
|
276
|
+
// Same title = replaces previous (debouncing)
|
|
277
|
+
snapshot(`Changed ${property}`);
|
|
278
|
+
|
|
279
|
+
// Use snapshot(title, false) to always create new
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Undo last change
|
|
284
|
+
rollback();
|
|
285
|
+
|
|
286
|
+
// Undo 3 changes
|
|
287
|
+
rollback(3);
|
|
288
|
+
|
|
289
|
+
// Reset to initial state
|
|
290
|
+
reset();
|
|
291
|
+
|
|
292
|
+
// Show history
|
|
293
|
+
$snapshots.forEach((s, i) => console.log(`${i}: ${s.title}`));
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Key features:**
|
|
297
|
+
|
|
298
|
+
- ๐ธ `snapshot(title, replace?)` โ create undo points
|
|
299
|
+
- โช `rollback(steps)` โ undo N changes
|
|
300
|
+
- ๐ `reset()` โ return to initial state
|
|
301
|
+
- ๐ `snapshots` store โ access full history
|
|
302
|
+
- ๐ Smart deduplication: same title replaces previous snapshot
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
### 5๏ธโฃ Options โ Fine-Tune Behavior
|
|
307
|
+
|
|
308
|
+
Customize svstate behavior with options:
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
const { data } = createSvState(formData, actuators, {
|
|
312
|
+
// Reset isDirty after successful action (default: true)
|
|
313
|
+
resetDirtyOnAction: true,
|
|
314
|
+
|
|
315
|
+
// Debounce validation in ms (default: 0 = microtask)
|
|
316
|
+
debounceValidation: 300,
|
|
317
|
+
|
|
318
|
+
// Allow concurrent action executions (default: false)
|
|
319
|
+
allowConcurrentActions: false,
|
|
320
|
+
|
|
321
|
+
// Keep actionError until next action (default: false)
|
|
322
|
+
persistActionError: false
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
| Option | Default | Description |
|
|
327
|
+
| ------------------------ | ------- | ---------------------------------------- |
|
|
328
|
+
| `resetDirtyOnAction` | `true` | Clear dirty flag after successful action |
|
|
329
|
+
| `debounceValidation` | `0` | Delay validation (0 = next microtask) |
|
|
330
|
+
| `allowConcurrentActions` | `false` | Block execute() while action runs |
|
|
331
|
+
| `persistActionError` | `false` | Clear error on next change or action |
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## ๐๏ธ Complete Examples
|
|
336
|
+
|
|
337
|
+
### Example 1: ERP Customer Form with Nested Addresses
|
|
338
|
+
|
|
339
|
+
A complex customer management form with 3-level nesting, validation, undo, and API save:
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
<script lang="ts">
|
|
343
|
+
import { createSvState, stringValidator, numberValidator, arrayValidator } from 'svstate';
|
|
344
|
+
|
|
345
|
+
// ๐ Complex nested data structure
|
|
346
|
+
const initialCustomer = {
|
|
347
|
+
name: '',
|
|
348
|
+
taxId: '',
|
|
349
|
+
creditLimit: 0,
|
|
350
|
+
address: {
|
|
351
|
+
street: '',
|
|
352
|
+
city: '',
|
|
353
|
+
zip: '',
|
|
354
|
+
country: ''
|
|
355
|
+
},
|
|
356
|
+
contacts: [
|
|
357
|
+
{ name: '', email: '', phone: '', isPrimary: true }
|
|
358
|
+
],
|
|
359
|
+
billing: {
|
|
360
|
+
paymentTerms: 'NET30',
|
|
361
|
+
currency: 'USD',
|
|
362
|
+
bankAccount: {
|
|
363
|
+
iban: '',
|
|
364
|
+
swift: ''
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// ๐ Create supercharged state
|
|
370
|
+
const {
|
|
371
|
+
data, // Deep reactive proxy
|
|
372
|
+
execute, // Trigger async action
|
|
373
|
+
rollback, // Undo changes
|
|
374
|
+
reset, // Reset to initial
|
|
375
|
+
state: {
|
|
376
|
+
errors, // Validation errors (same structure as data)
|
|
377
|
+
hasErrors, // Quick boolean check
|
|
378
|
+
isDirty, // Has anything changed?
|
|
379
|
+
actionInProgress, // Is action running?
|
|
380
|
+
actionError, // Last action error
|
|
381
|
+
snapshots // Undo history
|
|
382
|
+
}
|
|
383
|
+
} = createSvState(initialCustomer, {
|
|
384
|
+
|
|
385
|
+
// โ
Validator mirrors data structure exactly
|
|
386
|
+
validator: (source) => ({
|
|
387
|
+
name: stringValidator(source.name, 'trim')
|
|
388
|
+
.required()
|
|
389
|
+
.minLength(2)
|
|
390
|
+
.maxLength(100)
|
|
391
|
+
.getError(),
|
|
392
|
+
|
|
393
|
+
taxId: stringValidator(source.taxId, 'trim', 'upper')
|
|
394
|
+
.required()
|
|
395
|
+
.regexp(/^[A-Z]{2}-\d{8}$/, 'Format: XX-12345678')
|
|
396
|
+
.getError(),
|
|
397
|
+
|
|
398
|
+
creditLimit: numberValidator(source.creditLimit)
|
|
399
|
+
.required()
|
|
400
|
+
.min(0)
|
|
401
|
+
.max(1_000_000)
|
|
402
|
+
.getError(),
|
|
403
|
+
|
|
404
|
+
// ๐ Nested address validation
|
|
405
|
+
address: {
|
|
406
|
+
street: stringValidator(source.address.street, 'trim')
|
|
407
|
+
.required()
|
|
408
|
+
.minLength(5)
|
|
409
|
+
.getError(),
|
|
410
|
+
city: stringValidator(source.address.city, 'trim')
|
|
411
|
+
.required()
|
|
412
|
+
.getError(),
|
|
413
|
+
zip: stringValidator(source.address.zip, 'trim')
|
|
414
|
+
.required()
|
|
415
|
+
.minLength(5)
|
|
416
|
+
.getError(),
|
|
417
|
+
country: stringValidator(source.address.country)
|
|
418
|
+
.required()
|
|
419
|
+
.inArray(['US', 'CA', 'UK', 'DE', 'FR'])
|
|
420
|
+
.getError()
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
// ๐ Array validation
|
|
424
|
+
contacts: arrayValidator(source.contacts)
|
|
425
|
+
.required()
|
|
426
|
+
.minLength(1)
|
|
427
|
+
.getError(),
|
|
428
|
+
|
|
429
|
+
// ๐ณ 3-level nested billing validation
|
|
430
|
+
billing: {
|
|
431
|
+
paymentTerms: stringValidator(source.billing.paymentTerms)
|
|
432
|
+
.required()
|
|
433
|
+
.inArray(['NET15', 'NET30', 'NET60', 'COD'])
|
|
434
|
+
.getError(),
|
|
435
|
+
currency: stringValidator(source.billing.currency)
|
|
436
|
+
.required()
|
|
437
|
+
.inArray(['USD', 'EUR', 'GBP'])
|
|
438
|
+
.getError(),
|
|
439
|
+
bankAccount: {
|
|
440
|
+
iban: stringValidator(source.billing.bankAccount.iban, 'trim', 'upper')
|
|
441
|
+
.required()
|
|
442
|
+
.minLength(15)
|
|
443
|
+
.maxLength(34)
|
|
444
|
+
.getError(),
|
|
445
|
+
swift: stringValidator(source.billing.bankAccount.swift, 'trim', 'upper')
|
|
446
|
+
.required()
|
|
447
|
+
.minLength(8)
|
|
448
|
+
.maxLength(11)
|
|
449
|
+
.getError()
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}),
|
|
453
|
+
|
|
454
|
+
// โก Effect fires on every change
|
|
455
|
+
effect: ({ snapshot, property, currentValue, oldValue }) => {
|
|
456
|
+
// Create undo point with descriptive title
|
|
457
|
+
const fieldName = property.split('.').pop();
|
|
458
|
+
snapshot(`Changed ${fieldName}`);
|
|
459
|
+
|
|
460
|
+
// Log for debugging
|
|
461
|
+
console.log(`[svstate] ${property}: "${oldValue}" โ "${currentValue}"`);
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
// ๐ Async save action
|
|
465
|
+
action: async () => {
|
|
466
|
+
const response = await fetch('/api/customers', {
|
|
467
|
+
method: 'POST',
|
|
468
|
+
headers: { 'Content-Type': 'application/json' },
|
|
469
|
+
body: JSON.stringify(data)
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
const error = await response.json();
|
|
474
|
+
throw new Error(error.message || 'Failed to save customer');
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
// โ
Called after action (success or failure)
|
|
479
|
+
actionCompleted: (error) => {
|
|
480
|
+
if (error) {
|
|
481
|
+
console.error('Save failed:', error);
|
|
482
|
+
} else {
|
|
483
|
+
console.log('Customer saved successfully!');
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
</script>
|
|
488
|
+
|
|
489
|
+
<!-- ๐ Template with deep bindings -->
|
|
490
|
+
<form onsubmit|preventDefault={() => execute()}>
|
|
491
|
+
<!-- Basic fields -->
|
|
492
|
+
<input bind:value={data.name} placeholder="Company Name" />
|
|
493
|
+
{#if $errors?.name}<span class="error">{$errors.name}</span>{/if}
|
|
494
|
+
|
|
495
|
+
<!-- 2-level nested: address.city -->
|
|
496
|
+
<input bind:value={data.address.city} placeholder="City" />
|
|
497
|
+
{#if $errors?.address?.city}<span class="error">{$errors.address.city}</span>{/if}
|
|
498
|
+
|
|
499
|
+
<!-- 3-level nested: billing.bankAccount.iban -->
|
|
500
|
+
<input bind:value={data.billing.bankAccount.iban} placeholder="IBAN" />
|
|
501
|
+
{#if $errors?.billing?.bankAccount?.iban}
|
|
502
|
+
<span class="error">{$errors.billing.bankAccount.iban}</span>
|
|
503
|
+
{/if}
|
|
504
|
+
|
|
505
|
+
<!-- Action buttons -->
|
|
506
|
+
<div class="actions">
|
|
507
|
+
<button type="submit" disabled={$hasErrors || $actionInProgress}>
|
|
508
|
+
{$actionInProgress ? 'Saving...' : 'Save Customer'}
|
|
509
|
+
</button>
|
|
510
|
+
|
|
511
|
+
<button type="button" onclick={() => rollback()} disabled={$snapshots.length <= 1}>
|
|
512
|
+
Undo ({$snapshots.length - 1})
|
|
513
|
+
</button>
|
|
514
|
+
|
|
515
|
+
<button type="button" onclick={reset} disabled={!$isDirty}>
|
|
516
|
+
Reset
|
|
517
|
+
</button>
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
{#if $actionError}
|
|
521
|
+
<div class="error-banner">{$actionError.message}</div>
|
|
522
|
+
{/if}
|
|
523
|
+
</form>
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
528
|
+
### Example 2: Product Inventory with Array Management
|
|
529
|
+
|
|
530
|
+
Managing arrays of items with validation at both array and item level:
|
|
531
|
+
|
|
532
|
+
```typescript
|
|
533
|
+
<script lang="ts">
|
|
534
|
+
import { createSvState, stringValidator, numberValidator, arrayValidator } from 'svstate';
|
|
535
|
+
|
|
536
|
+
// ๐ฆ Product with inventory items
|
|
537
|
+
const initialProduct = {
|
|
538
|
+
sku: '',
|
|
539
|
+
name: '',
|
|
540
|
+
description: '',
|
|
541
|
+
price: 0,
|
|
542
|
+
inventory: [
|
|
543
|
+
{ warehouseId: 'WH-001', quantity: 0, reorderPoint: 10 }
|
|
544
|
+
],
|
|
545
|
+
tags: [] as string[]
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const {
|
|
549
|
+
data,
|
|
550
|
+
rollback,
|
|
551
|
+
state: { errors, hasErrors, isDirty, snapshots }
|
|
552
|
+
} = createSvState(initialProduct, {
|
|
553
|
+
|
|
554
|
+
validator: (source) => ({
|
|
555
|
+
sku: stringValidator(source.sku, 'trim', 'upper')
|
|
556
|
+
.required()
|
|
557
|
+
.regexp(/^[A-Z]{3}-\d{4}$/, 'Format: ABC-1234')
|
|
558
|
+
.getError(),
|
|
559
|
+
|
|
560
|
+
name: stringValidator(source.name, 'trim')
|
|
561
|
+
.required()
|
|
562
|
+
.minLength(3)
|
|
563
|
+
.maxLength(100)
|
|
564
|
+
.getError(),
|
|
565
|
+
|
|
566
|
+
description: stringValidator(source.description, 'trim')
|
|
567
|
+
.maxLength(500)
|
|
568
|
+
.getError(),
|
|
569
|
+
|
|
570
|
+
price: numberValidator(source.price)
|
|
571
|
+
.required()
|
|
572
|
+
.positive()
|
|
573
|
+
.decimal(2) // Max 2 decimal places
|
|
574
|
+
.getError(),
|
|
575
|
+
|
|
576
|
+
// ๐ Validate the array itself
|
|
577
|
+
inventory: arrayValidator(source.inventory)
|
|
578
|
+
.required()
|
|
579
|
+
.minLength(1)
|
|
580
|
+
.getError(),
|
|
581
|
+
|
|
582
|
+
// ๐ท๏ธ Tags must be unique
|
|
583
|
+
tags: arrayValidator(source.tags)
|
|
584
|
+
.maxLength(10)
|
|
585
|
+
.unique()
|
|
586
|
+
.getError()
|
|
587
|
+
}),
|
|
588
|
+
|
|
589
|
+
effect: ({ snapshot, property, currentValue }) => {
|
|
590
|
+
// Create snapshots for significant changes
|
|
591
|
+
if (property === 'price') {
|
|
592
|
+
snapshot(`Price: $${currentValue}`);
|
|
593
|
+
} else if (property.startsWith('inventory')) {
|
|
594
|
+
snapshot(`Updated inventory`);
|
|
595
|
+
} else {
|
|
596
|
+
snapshot(`Changed ${property}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ๐ง Array manipulation functions
|
|
602
|
+
function addWarehouse() {
|
|
603
|
+
data.inventory.push({
|
|
604
|
+
warehouseId: `WH-${String(data.inventory.length + 1).padStart(3, '0')}`,
|
|
605
|
+
quantity: 0,
|
|
606
|
+
reorderPoint: 10
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function removeWarehouse(index: number) {
|
|
611
|
+
data.inventory.splice(index, 1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function addTag(tag: string) {
|
|
615
|
+
if (tag && !data.tags.includes(tag)) {
|
|
616
|
+
data.tags.push(tag);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function removeTag(index: number) {
|
|
621
|
+
data.tags.splice(index, 1);
|
|
622
|
+
}
|
|
623
|
+
</script>
|
|
624
|
+
|
|
625
|
+
<!-- Product form -->
|
|
626
|
+
<div class="product-form">
|
|
627
|
+
<input bind:value={data.sku} placeholder="SKU (ABC-1234)" />
|
|
628
|
+
{#if $errors?.sku}<span class="error">{$errors.sku}</span>{/if}
|
|
629
|
+
|
|
630
|
+
<input bind:value={data.name} placeholder="Product Name" />
|
|
631
|
+
<input type="number" bind:value={data.price} step="0.01" placeholder="Price" />
|
|
632
|
+
|
|
633
|
+
<!-- ๐ฆ Inventory locations (array) -->
|
|
634
|
+
<section class="inventory">
|
|
635
|
+
<h3>Inventory Locations</h3>
|
|
636
|
+
{#if $errors?.inventory}
|
|
637
|
+
<span class="error">{$errors.inventory}</span>
|
|
638
|
+
{/if}
|
|
639
|
+
|
|
640
|
+
{#each data.inventory as item, index}
|
|
641
|
+
<div class="inventory-row">
|
|
642
|
+
<input bind:value={item.warehouseId} placeholder="Warehouse ID" />
|
|
643
|
+
<input type="number" bind:value={item.quantity} placeholder="Qty" />
|
|
644
|
+
<input type="number" bind:value={item.reorderPoint} placeholder="Reorder at" />
|
|
645
|
+
<button onclick={() => removeWarehouse(index)}>Remove</button>
|
|
646
|
+
</div>
|
|
647
|
+
{/each}
|
|
648
|
+
|
|
649
|
+
<button onclick={addWarehouse}>+ Add Warehouse</button>
|
|
650
|
+
</section>
|
|
651
|
+
|
|
652
|
+
<!-- ๐ท๏ธ Tags (simple array) -->
|
|
653
|
+
<section class="tags">
|
|
654
|
+
<h3>Tags</h3>
|
|
655
|
+
{#if $errors?.tags}<span class="error">{$errors.tags}</span>{/if}
|
|
656
|
+
|
|
657
|
+
<div class="tag-list">
|
|
658
|
+
{#each data.tags as tag, index}
|
|
659
|
+
<span class="tag">
|
|
660
|
+
{tag}
|
|
661
|
+
<button onclick={() => removeTag(index)}>ร</button>
|
|
662
|
+
</span>
|
|
663
|
+
{/each}
|
|
664
|
+
</div>
|
|
665
|
+
|
|
666
|
+
<input
|
|
667
|
+
placeholder="Add tag..."
|
|
668
|
+
onkeydown={(e) => {
|
|
669
|
+
if (e.key === 'Enter') {
|
|
670
|
+
addTag(e.currentTarget.value);
|
|
671
|
+
e.currentTarget.value = '';
|
|
672
|
+
}
|
|
673
|
+
}}
|
|
674
|
+
/>
|
|
675
|
+
</section>
|
|
676
|
+
|
|
677
|
+
<!-- Status bar -->
|
|
678
|
+
<div class="status">
|
|
679
|
+
<span class:dirty={$isDirty}>{$isDirty ? 'Modified' : 'Saved'}</span>
|
|
680
|
+
<span>{$snapshots.length} snapshots</span>
|
|
681
|
+
<button onclick={() => rollback()} disabled={$snapshots.length <= 1}>
|
|
682
|
+
Undo
|
|
683
|
+
</button>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
## ๐งฐ API Reference
|
|
691
|
+
|
|
692
|
+
### `createSvState(init, actuators?, options?)`
|
|
693
|
+
|
|
694
|
+
Creates a supercharged state object.
|
|
695
|
+
|
|
696
|
+
**Returns:**
|
|
697
|
+
| Property | Type | Description |
|
|
698
|
+
|----------|------|-------------|
|
|
699
|
+
| `data` | `T` | Deep reactive proxy โ bind directly |
|
|
700
|
+
| `execute(params?)` | `(P?) => Promise<void>` | Run the configured action |
|
|
701
|
+
| `rollback(steps?)` | `(n?: number) => void` | Undo N changes (default: 1) |
|
|
702
|
+
| `reset()` | `() => void` | Return to initial state |
|
|
703
|
+
| `state.errors` | `Readable<V>` | Validation errors store |
|
|
704
|
+
| `state.hasErrors` | `Readable<boolean>` | Quick error check |
|
|
705
|
+
| `state.isDirty` | `Readable<boolean>` | Has state changed? |
|
|
706
|
+
| `state.actionInProgress` | `Readable<boolean>` | Is action running? |
|
|
707
|
+
| `state.actionError` | `Readable<Error>` | Last action error |
|
|
708
|
+
| `state.snapshots` | `Readable<Snapshot[]>` | Undo history |
|
|
709
|
+
|
|
710
|
+
### Built-in Validators
|
|
711
|
+
|
|
712
|
+
svstate ships with four fluent validator builders that cover the most common validation scenarios. Each validator uses a chainable API โ call validation methods in sequence and finish with `getError()` to retrieve the first error message (or an empty string if valid).
|
|
713
|
+
|
|
714
|
+
String validators support optional preprocessing (`'trim'`, `'normalize'`, `'upper'`, `'lower'`) applied before validation. All validators return descriptive error messages that you can customize or use as-is.
|
|
715
|
+
|
|
716
|
+
| Validator | Methods |
|
|
717
|
+
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
718
|
+
| `stringValidator(input, ...prepares)` | `required()`, `minLength(n)`, `maxLength(n)`, `email()`, `regexp(re)`, `inArray(arr)`, `startsWith(s)`, `endsWith(s)`, `contains(s)`, `noSpace()`, `uppercase()`, `lowercase()`, `alphanumeric()`, `numeric()`, `website(mode)` |
|
|
719
|
+
| `numberValidator(input)` | `required()`, `min(n)`, `max(n)`, `between(min, max)`, `integer()`, `positive()`, `negative()`, `nonNegative()`, `multipleOf(n)`, `decimal(places)`, `percentage()` |
|
|
720
|
+
| `arrayValidator(input)` | `required()`, `minLength(n)`, `maxLength(n)`, `unique()` |
|
|
721
|
+
| `dateValidator(input)` | `required()`, `before(date)`, `after(date)`, `between(start, end)`, `past()`, `future()`, `weekday()`, `weekend()`, `minAge(years)`, `maxAge(years)` |
|
|
722
|
+
|
|
723
|
+
### TypeScript Types
|
|
724
|
+
|
|
725
|
+
svstate exports TypeScript types to help you write type-safe external validator and effect functions. This is useful when you want to define these functions outside the `createSvState` call or reuse them across multiple state instances.
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
import type { Validator, EffectContext, Snapshot, SnapshotFunction, SvStateOptions } from 'svstate';
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
| Type | Description |
|
|
732
|
+
| ------------------ | --------------------------------------------------------------------------------------------------- |
|
|
733
|
+
| `Validator` | Nested object type for validation errors โ leaf values are error strings (empty = valid) |
|
|
734
|
+
| `EffectContext<T>` | Context object passed to effect callbacks: `{ snapshot, target, property, currentValue, oldValue }` |
|
|
735
|
+
| `SnapshotFunction` | Type for the `snapshot(title, replace?)` function used in effects |
|
|
736
|
+
| `Snapshot<T>` | Shape of a snapshot entry: `{ title: string; data: T }` |
|
|
737
|
+
| `SvStateOptions` | Configuration options type for `createSvState` |
|
|
738
|
+
|
|
739
|
+
**Example: External validator and effect functions**
|
|
740
|
+
|
|
741
|
+
```typescript
|
|
742
|
+
import { createSvState, stringValidator, type Validator, type EffectContext } from 'svstate';
|
|
743
|
+
|
|
744
|
+
// Define types for your data
|
|
745
|
+
type UserData = {
|
|
746
|
+
name: string;
|
|
747
|
+
email: string;
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
type UserErrors = {
|
|
751
|
+
name: string;
|
|
752
|
+
email: string;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// External validator function with proper typing
|
|
756
|
+
const validateUser = (source: UserData): UserErrors => ({
|
|
757
|
+
name: stringValidator(source.name, 'trim').required().minLength(2).getError(),
|
|
758
|
+
email: stringValidator(source.email, 'trim').required().email().getError()
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// External effect function with proper typing
|
|
762
|
+
const userEffect = ({ snapshot, property, currentValue }: EffectContext<UserData>) => {
|
|
763
|
+
console.log(`${property} changed to ${currentValue}`);
|
|
764
|
+
snapshot(`Updated ${property}`);
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// Use the external functions
|
|
768
|
+
const { data, state } = createSvState<UserData, UserErrors, object>(
|
|
769
|
+
{ name: '', email: '' },
|
|
770
|
+
{ validator: validateUser, effect: userEffect }
|
|
771
|
+
);
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
---
|
|
775
|
+
|
|
776
|
+
## ๐จ Why svstate?
|
|
777
|
+
|
|
778
|
+
| Feature | Native Svelte 5 | svstate |
|
|
779
|
+
| ---------------------- | ------------------ | --------------- |
|
|
780
|
+
| Simple flat objects | โ
Great | โ
Great |
|
|
781
|
+
| Deep nested objects | โ ๏ธ Manual tracking | โ
Automatic |
|
|
782
|
+
| Property change events | โ Not available | โ
Full context |
|
|
783
|
+
| Structured validation | โ DIY | โ
Mirrors data |
|
|
784
|
+
| Undo/Redo | โ DIY | โ
Built-in |
|
|
785
|
+
| Dirty tracking | โ DIY | โ
Automatic |
|
|
786
|
+
| Action loading states | โ DIY | โ
Built-in |
|
|
787
|
+
|
|
788
|
+
**svstate is for:**
|
|
789
|
+
|
|
790
|
+
- ๐ข Enterprise applications with complex forms
|
|
791
|
+
- ๐ ERP, CRM, admin dashboards
|
|
792
|
+
- ๐ Multi-step wizards
|
|
793
|
+
- ๐ Applications needing undo/redo
|
|
794
|
+
- โ
Any form beyond username/password
|
|
795
|
+
|
|
796
|
+
---
|
|
797
|
+
|
|
798
|
+
## ๐ Resources
|
|
799
|
+
|
|
800
|
+
- ๐ฎ [Live Demo](https://bcsabaengine.github.io/svstate/) โ Try it in your browser
|
|
801
|
+
- ๐ [Documentation](https://github.com/BCsabaEngine/svstate)
|
|
802
|
+
- ๐ [Report Issues](https://github.com/BCsabaEngine/svstate/issues)
|
|
803
|
+
- ๐ฌ [Discussions](https://github.com/BCsabaEngine/svstate/discussions)
|
|
804
|
+
|
|
805
|
+
---
|
|
806
|
+
|
|
807
|
+
## ๐ License
|
|
808
|
+
|
|
809
|
+
ISC ยฉ [BCsabaEngine](https://github.com/BCsabaEngine)
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
<p align="center">
|
|
814
|
+
<b>Stop fighting with state. Start building features.</b>
|
|
815
|
+
<br><br>
|
|
816
|
+
โญ Star us on GitHub if svstate helps your project!
|
|
817
|
+
</p>
|
package/dist/proxy.js
CHANGED
|
@@ -13,8 +13,8 @@ const isProxiable = (value) => typeof value === 'object' &&
|
|
|
13
13
|
!(value instanceof Promise);
|
|
14
14
|
const ChangeProxy = (source, changed) => {
|
|
15
15
|
const createProxy = (target, parentPath) => new Proxy(target, {
|
|
16
|
-
get(object, property
|
|
17
|
-
const value =
|
|
16
|
+
get(object, property) {
|
|
17
|
+
const value = object[property];
|
|
18
18
|
if (isProxiable(value)) {
|
|
19
19
|
const pathSegment = Number.isInteger(Number(property)) ? '' : String(property);
|
|
20
20
|
const childPath = pathSegment ? (parentPath ? `${parentPath}.${pathSegment}` : pathSegment) : parentPath;
|
|
@@ -22,10 +22,10 @@ const ChangeProxy = (source, changed) => {
|
|
|
22
22
|
}
|
|
23
23
|
return value;
|
|
24
24
|
},
|
|
25
|
-
set(object, property, incomingValue
|
|
26
|
-
const oldValue =
|
|
25
|
+
set(object, property, incomingValue) {
|
|
26
|
+
const oldValue = object[property];
|
|
27
27
|
if (oldValue !== incomingValue) {
|
|
28
|
-
|
|
28
|
+
object[property] = incomingValue;
|
|
29
29
|
const pathSegment = Number.isInteger(Number(property)) ? '' : String(property);
|
|
30
30
|
const fullPath = pathSegment ? (parentPath ? `${parentPath}.${pathSegment}` : pathSegment) : parentPath;
|
|
31
31
|
changed(data, fullPath, incomingValue, oldValue);
|
package/dist/state.svelte.js
CHANGED
|
@@ -13,9 +13,8 @@ const deepClone = (object) => {
|
|
|
13
13
|
if (Array.isArray(object))
|
|
14
14
|
return object.map((item) => deepClone(item));
|
|
15
15
|
const cloned = {};
|
|
16
|
-
for (const key
|
|
17
|
-
|
|
18
|
-
cloned[key] = deepClone(object[key]);
|
|
16
|
+
for (const key of Object.keys(object))
|
|
17
|
+
cloned[key] = deepClone(object[key]);
|
|
19
18
|
return cloned;
|
|
20
19
|
};
|
|
21
20
|
const defaultOptions = {
|
package/dist/validators.js
CHANGED
|
@@ -7,8 +7,8 @@ exports.dateValidator = dateValidator;
|
|
|
7
7
|
const prepareOps = {
|
|
8
8
|
trim: (s) => s.trim(),
|
|
9
9
|
normalize: (s) => s.replaceAll(/\s{2,}/g, ' '),
|
|
10
|
-
upper: (s) => s.
|
|
11
|
-
lower: (s) => s.
|
|
10
|
+
upper: (s) => s.toUpperCase(),
|
|
11
|
+
lower: (s) => s.toLowerCase()
|
|
12
12
|
};
|
|
13
13
|
function stringValidator(input, ...prepares) {
|
|
14
14
|
let error = '';
|
|
@@ -39,12 +39,12 @@ function stringValidator(input, ...prepares) {
|
|
|
39
39
|
return builder;
|
|
40
40
|
},
|
|
41
41
|
uppercase() {
|
|
42
|
-
if (!error && processedInput !== processedInput.
|
|
42
|
+
if (!error && processedInput !== processedInput.toUpperCase())
|
|
43
43
|
setError('Uppercase only');
|
|
44
44
|
return builder;
|
|
45
45
|
},
|
|
46
46
|
lowercase() {
|
|
47
|
-
if (!error && processedInput !== processedInput.
|
|
47
|
+
if (!error && processedInput !== processedInput.toLowerCase())
|
|
48
48
|
setError('Lowercase only');
|
|
49
49
|
return builder;
|
|
50
50
|
},
|
package/package.json
CHANGED