aberdeen 1.4.0 → 1.4.3
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/README.md +27 -97
- package/dist/aberdeen.d.ts +16 -32
- package/dist/aberdeen.js.map +2 -2
- package/dist-min/aberdeen.js.map +2 -2
- package/html-to-aberdeen +79 -6
- package/package.json +3 -2
- package/skill/SKILL.md +285 -0
- package/skill/references/prediction.md +45 -0
- package/skill/references/routing.md +81 -0
- package/skill/references/transitions.md +52 -0
- package/src/aberdeen.ts +16 -32
package/html-to-aberdeen
CHANGED
|
@@ -227,13 +227,77 @@ function convertHTMLToAberdeen(html) {
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
// Process a node and return Aberdeen code
|
|
230
|
+
const CSS_PROPERTY_TO_SHORTCUT = {
|
|
231
|
+
'margin': 'm',
|
|
232
|
+
'margin-top': 'mt',
|
|
233
|
+
'margin-bottom': 'mb',
|
|
234
|
+
'margin-left': 'ml',
|
|
235
|
+
'margin-right': 'mr',
|
|
236
|
+
'padding': 'p',
|
|
237
|
+
'padding-top': 'pt',
|
|
238
|
+
'padding-bottom': 'pb',
|
|
239
|
+
'padding-left': 'pl',
|
|
240
|
+
'padding-right': 'pr',
|
|
241
|
+
'width': 'w',
|
|
242
|
+
'height': 'h',
|
|
243
|
+
'background': 'bg',
|
|
244
|
+
'color': 'fg',
|
|
245
|
+
'border-radius': 'r',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
function kebabToCamel(str) {
|
|
249
|
+
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function convertStyleToAberdeen(styleString) {
|
|
253
|
+
const rules = styleString.split(';').map(s => s.trim()).filter(Boolean);
|
|
254
|
+
const resultParts = [];
|
|
255
|
+
|
|
256
|
+
const props = {};
|
|
257
|
+
for (const rule of rules) {
|
|
258
|
+
const colonIndex = rule.indexOf(':');
|
|
259
|
+
if (colonIndex === -1) continue;
|
|
260
|
+
const key = rule.substring(0, colonIndex).trim();
|
|
261
|
+
const value = rule.substring(colonIndex + 1).trim();
|
|
262
|
+
props[key] = value;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const addPart = (key, value) => {
|
|
266
|
+
if (value.includes(' ')) {
|
|
267
|
+
resultParts.push(`${key}:"${value}"`);
|
|
268
|
+
} else {
|
|
269
|
+
resultParts.push(`${key}:${value}`);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const handleGroup = (prop1, prop2, shortcut) => {
|
|
274
|
+
if (props[prop1] && props[prop2] && props[prop1] === props[prop2]) {
|
|
275
|
+
addPart(shortcut, props[prop1]);
|
|
276
|
+
delete props[prop1];
|
|
277
|
+
delete props[prop2];
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
handleGroup('margin-top', 'margin-bottom', 'mv');
|
|
282
|
+
handleGroup('margin-left', 'margin-right', 'mh');
|
|
283
|
+
handleGroup('padding-top', 'padding-bottom', 'pv');
|
|
284
|
+
handleGroup('padding-left', 'padding-right', 'ph');
|
|
285
|
+
|
|
286
|
+
for (const [key, value] of Object.entries(props)) {
|
|
287
|
+
const shortcut = CSS_PROPERTY_TO_SHORTCUT[key] || kebabToCamel(key);
|
|
288
|
+
addPart(shortcut, value);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return resultParts.join(' ');
|
|
292
|
+
}
|
|
293
|
+
|
|
230
294
|
function processNode(node, indentLevel = 0) {
|
|
231
295
|
const indent = ' '.repeat(indentLevel);
|
|
232
296
|
|
|
233
297
|
// Handle text nodes
|
|
234
298
|
if (node.type === 'text') {
|
|
235
299
|
const text = node.content.trim();
|
|
236
|
-
return text ? `${indent}$('
|
|
300
|
+
return text ? `${indent}$('#${escapeString(text)}');\n` : ``;
|
|
237
301
|
}
|
|
238
302
|
|
|
239
303
|
// Handle comments
|
|
@@ -273,8 +337,17 @@ function processElement(node, indentLevel) {
|
|
|
273
337
|
|
|
274
338
|
result += tagName + classes;
|
|
275
339
|
|
|
276
|
-
// Add
|
|
277
|
-
const
|
|
340
|
+
// Add style shorthand
|
|
341
|
+
const styleAttr = element.attributes.find(attr => attr.name === 'style');
|
|
342
|
+
if (styleAttr) {
|
|
343
|
+
const styleShorthand = convertStyleToAberdeen(styleAttr.value);
|
|
344
|
+
if (styleShorthand) {
|
|
345
|
+
result += ' ' + styleShorthand;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Add attributes (excluding class and style)
|
|
350
|
+
const attributes = element.attributes.filter(attr => attr.name !== 'class' && attr.name !== 'style');
|
|
278
351
|
const { attrString, separateArgs } = buildAttributeString(attributes);
|
|
279
352
|
result += attrString;
|
|
280
353
|
|
|
@@ -287,8 +360,8 @@ function processElement(node, indentLevel) {
|
|
|
287
360
|
const textContent = element.children[0].content.trim();
|
|
288
361
|
if (i === chain.length - 1) {
|
|
289
362
|
// If it's the last element in the chain and there are no other children,
|
|
290
|
-
// use the '
|
|
291
|
-
result += '
|
|
363
|
+
// use the '#' syntax for text
|
|
364
|
+
result += '#' + escapeString(textContent);
|
|
292
365
|
} else {
|
|
293
366
|
// Treat text like any other attribute
|
|
294
367
|
const textAttr = { name: 'text', value: textContent };
|
|
@@ -379,7 +452,7 @@ function buildAttributeString(attributes) {
|
|
|
379
452
|
|
|
380
453
|
if (value === '') {
|
|
381
454
|
// Boolean attribute
|
|
382
|
-
attrString += ` ${attr.name}`;
|
|
455
|
+
attrString += ` ${attr.name}=""`;
|
|
383
456
|
} else if (value.includes('"')) {
|
|
384
457
|
// Contains double quotes - must use separate argument
|
|
385
458
|
attrString += ` ${attr.name}=`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aberdeen",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"author": "Frank van Viegen",
|
|
5
5
|
"main": "dist-min/aberdeen.js",
|
|
6
6
|
"devDependencies": {
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"files": [
|
|
42
42
|
"dist",
|
|
43
43
|
"dist-min",
|
|
44
|
-
"src"
|
|
44
|
+
"src",
|
|
45
|
+
"skill"
|
|
45
46
|
],
|
|
46
47
|
"bin": {
|
|
47
48
|
"html-to-aberdeen": "./html-to-aberdeen"
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aberdeen
|
|
3
|
+
description: Expert guidance for building reactive UIs with the Aberdeen library. Covers element creation with $, reactive state with proxy(), efficient lists with onEach(), two-way binding, CSS shortcuts, and advanced features like routing, transitions, and optimistic updates.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Aberdeen
|
|
7
|
+
|
|
8
|
+
Reactive UI library using fine-grained reactivity via JS Proxies. No virtual DOM.
|
|
9
|
+
|
|
10
|
+
## Imports
|
|
11
|
+
```typescript
|
|
12
|
+
import { $, proxy, onEach, ref, mount, insertCss, insertGlobalCss, cssVars, derive, map, multiMap, partition, count, isEmpty, clean, invertString } from 'aberdeen';
|
|
13
|
+
import { grow, shrink } from 'aberdeen/transitions'; // Optional
|
|
14
|
+
import * as route from 'aberdeen/route'; // Optional
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Element Creation: `$`
|
|
18
|
+
```typescript
|
|
19
|
+
// Tag, classes, text content
|
|
20
|
+
$('div.container.active#Hello World');
|
|
21
|
+
|
|
22
|
+
// Nested elements in single call (each becomes child of previous)
|
|
23
|
+
$('div.wrapper mt:@3 span.icon');
|
|
24
|
+
|
|
25
|
+
// Attributes/properties via string syntax (preferred)
|
|
26
|
+
$('input placeholder=Name value=initial');
|
|
27
|
+
|
|
28
|
+
// Dynamic values: end key with `=`, next arg is value
|
|
29
|
+
$('input placeholder="Something containing spaces" value=', userInput);
|
|
30
|
+
$('button text=', `Count: ${state.count}`);
|
|
31
|
+
|
|
32
|
+
// Event handlers
|
|
33
|
+
$('button text=Click click=', () => console.log('clicked'));
|
|
34
|
+
|
|
35
|
+
// Nested content via function (creates reactive scope)
|
|
36
|
+
$('ul', () => {
|
|
37
|
+
$('li#Item 1');
|
|
38
|
+
$('li#Item 2');
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Never concatenate user data into strings.** Use dynamic syntax:
|
|
43
|
+
```typescript
|
|
44
|
+
// WRONG - XSS risk and breaks on special chars
|
|
45
|
+
$(`input value=${userData}`);
|
|
46
|
+
|
|
47
|
+
// CORRECT
|
|
48
|
+
$('input value=', userData);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### String Syntax Reference
|
|
52
|
+
| Syntax | Meaning |
|
|
53
|
+
|--------|--------|
|
|
54
|
+
| `tag` | Element name (creates child, becomes current element) |
|
|
55
|
+
| `.class` | Add CSS class |
|
|
56
|
+
| `#text` | Text content (rest of string) |
|
|
57
|
+
| `prop:value` | Inline CSS style |
|
|
58
|
+
| `attr=value` | Attribute with static string value |
|
|
59
|
+
| `prop:` or `attr=` | Next argument is CSS prop/attribute/property/event listener |
|
|
60
|
+
|
|
61
|
+
### Object Syntax (alternative)
|
|
62
|
+
```typescript
|
|
63
|
+
// Equivalent to string syntax, useful for complex cases
|
|
64
|
+
// Note how the '$' prefix is used for CSS properties
|
|
65
|
+
$('input', { placeholder: 'Name', value: userData, $color: 'red' });
|
|
66
|
+
$('button', { click: handler, '.active': isActive });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### CSS Property Shortcuts
|
|
70
|
+
| Short | Full | Short | Full |
|
|
71
|
+
|-------|------|-------|------|
|
|
72
|
+
| `m` | margin | `p` | padding |
|
|
73
|
+
| `mt`,`mb`,`ml`,`mr` | marginTop/Bottom/Left/Right | `pt`,`pb`,`pl`,`pr` | paddingTop/... |
|
|
74
|
+
| `mv` | marginTop + marginBottom | `pv` | paddingTop + paddingBottom |
|
|
75
|
+
| `mh` | marginLeft + marginRight | `ph` | paddingLeft + paddingRight |
|
|
76
|
+
| `w` | width | `h` | height |
|
|
77
|
+
| `bg` | background | `fg` | color |
|
|
78
|
+
| `r` | borderRadius | | |
|
|
79
|
+
|
|
80
|
+
### CSS Variables (`@`)
|
|
81
|
+
Values starting with `@` reference `cssVars`. Predefined spacing scale:
|
|
82
|
+
| Var | Value | Var | Value |
|
|
83
|
+
|-----|-------|-----|-------|
|
|
84
|
+
| `@1` | 0.25rem | `@4` | 2rem |
|
|
85
|
+
| `@2` | 0.5rem | `@5` | 4rem |
|
|
86
|
+
| `@3` | 1rem | `@n` | 2^(n-3) rem |
|
|
87
|
+
|
|
88
|
+
**Best practice:** Use `@3` and `@4` for most margins/paddings. For new projects, define color variables:
|
|
89
|
+
```typescript
|
|
90
|
+
cssVars.primary = '#3b82f6';
|
|
91
|
+
cssVars.danger = '#ef4444';
|
|
92
|
+
$('button bg:@primary fg:white#Save');
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Reactive State: `proxy()`
|
|
96
|
+
```typescript
|
|
97
|
+
// Objects (preserves type!)
|
|
98
|
+
const state = proxy({ name: 'Alice', count: 0 });
|
|
99
|
+
|
|
100
|
+
// Primitives get wrapped in { value: T }
|
|
101
|
+
const flag = proxy(true);
|
|
102
|
+
flag.value = false;
|
|
103
|
+
|
|
104
|
+
// Class instances work great - use for typed state!
|
|
105
|
+
class Todo {
|
|
106
|
+
constructor(public text: string, public done = false) {}
|
|
107
|
+
toggle() { this.done = !this.done; }
|
|
108
|
+
}
|
|
109
|
+
const todo: Todo = proxy(new Todo('Learn Aberdeen'));
|
|
110
|
+
todo.toggle(); // Reactive method call!
|
|
111
|
+
|
|
112
|
+
// Arrays
|
|
113
|
+
const items = proxy<string[]>([]);
|
|
114
|
+
items.push('new');
|
|
115
|
+
delete items[0];
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Reactive Scopes
|
|
119
|
+
Functions passed to `$` create **scopes**. When proxy data accessed inside changes:
|
|
120
|
+
1. All effects from previous run are **cleaned** (DOM elements removed, `clean()` callbacks run)
|
|
121
|
+
2. Function re-runs
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
$('div', () => {
|
|
125
|
+
// Re-runs when state.name changes, replacing the h1
|
|
126
|
+
$(`h1#Hello ${state.name}`);
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Granular Updates
|
|
131
|
+
Split scopes for minimal DOM updates:
|
|
132
|
+
```typescript
|
|
133
|
+
$('div', () => {
|
|
134
|
+
$('h1', () => $(`#${state.title}`)); // Only title text re-renders
|
|
135
|
+
$('p', () => $(`#${state.body}`)); // Only body text re-renders
|
|
136
|
+
// Or
|
|
137
|
+
$('p#', ref(state, 'body')); // Two-way maps {body: x} to {value: x}, which will be read reactively by $
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Passing Observables Directly
|
|
142
|
+
Avoid subscribing in parent scope:
|
|
143
|
+
```typescript
|
|
144
|
+
$('div', () => {
|
|
145
|
+
// Not great: reruns this scope when state.count changes
|
|
146
|
+
$('span text=', state.count); // Subscribes here!
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
$('div', () => {
|
|
150
|
+
// Good: only text node updates, this function does not rerun
|
|
151
|
+
$('span text=', ref(state, 'count')); // Passes observable, subscribes internally
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Or just use a single-value proxy `const count = proxy(0);` and pass it directly `$('span text=', count);`.
|
|
156
|
+
|
|
157
|
+
### Manual Cleanup with `clean()`
|
|
158
|
+
Register cleanup for non-$ side effects:
|
|
159
|
+
```typescript
|
|
160
|
+
$(() => {
|
|
161
|
+
if (!reactive.value) return;
|
|
162
|
+
const timer = setInterval(() => console.log('tick'), 1000);
|
|
163
|
+
clean(() => clearInterval(timer)); // Runs on scope cleanup
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Lists: `onEach()`
|
|
168
|
+
```typescript
|
|
169
|
+
onEach(items, (item, index) => {
|
|
170
|
+
$('li', () => $(`#${item.text}`));
|
|
171
|
+
}, item => item.id); // Optional sort key
|
|
172
|
+
```
|
|
173
|
+
- Renders only changed items efficiently
|
|
174
|
+
- Sort key: `number | string | [number|string, ...]` or `undefined` to hide item
|
|
175
|
+
- Use `invertString(str)` for descending string sort
|
|
176
|
+
- Works on arrays, objects, and Maps
|
|
177
|
+
|
|
178
|
+
## Two-Way Binding
|
|
179
|
+
```typescript
|
|
180
|
+
$('input bind=', ref(state, 'name'));
|
|
181
|
+
$('input type=checkbox bind=', ref(state, 'active'));
|
|
182
|
+
$('select bind=', ref(state, 'choice'), () => {
|
|
183
|
+
$('option value=a#Option A');
|
|
184
|
+
$('option value=b#Option B');
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Derived Values
|
|
189
|
+
|
|
190
|
+
### `derive()` - Derived primitives
|
|
191
|
+
```typescript
|
|
192
|
+
const doubled: { value: number } = derive(() => state.count * 2);
|
|
193
|
+
$('span text=', doubled);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Collection functions
|
|
197
|
+
```typescript
|
|
198
|
+
// count() - returns { value: number } proxy
|
|
199
|
+
const total: { value: number } = count(items);
|
|
200
|
+
|
|
201
|
+
// isEmpty() - returns boolean, re-runs scope only when emptiness changes
|
|
202
|
+
if (isEmpty(items)) $('p#No items');
|
|
203
|
+
|
|
204
|
+
// map() - returns proxied array/object of same shape
|
|
205
|
+
const names: string[] = map(users, u => u.active ? u.name : undefined);
|
|
206
|
+
|
|
207
|
+
// multiMap() - each input produces multiple outputs
|
|
208
|
+
const byId: Record<string, User> = multiMap(users, u => ({ [u.id]: u }));
|
|
209
|
+
|
|
210
|
+
// partition() - sort items into buckets
|
|
211
|
+
const byStatus: Record<string, Record<number, Task>> = partition(tasks, t => t.status);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Component-Local CSS
|
|
215
|
+
`insertCss` returns a unique class name (e.g., `.AbdStl1`). Call at **module top-level**, not inside render functions:
|
|
216
|
+
```typescript
|
|
217
|
+
// At top of file
|
|
218
|
+
const boxStyle = insertCss({
|
|
219
|
+
bg: '@primary',
|
|
220
|
+
r: '@2',
|
|
221
|
+
button: {
|
|
222
|
+
m: '@2',
|
|
223
|
+
'&:hover': { opacity: 0.8 }
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// In render code
|
|
228
|
+
$('div', boxStyle, 'button#Click');
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
For global styles (no class prefix):
|
|
232
|
+
```typescript
|
|
233
|
+
insertGlobalCss({
|
|
234
|
+
body: { m: 0, fontFamily: 'system-ui' },
|
|
235
|
+
'a': { fg: '@primary' }
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
CSS can be reactive when needed (e.g., theme switching):
|
|
240
|
+
```typescript
|
|
241
|
+
$(() => {
|
|
242
|
+
insertCss({ bg: theme.dark ? '#222' : '#fff' });
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Transitions
|
|
247
|
+
The `create` and `destroy` properties enable enter/leave animations:
|
|
248
|
+
```typescript
|
|
249
|
+
import { grow, shrink } from 'aberdeen/transitions';
|
|
250
|
+
|
|
251
|
+
// Built-in grow/shrink for smooth height/width animations
|
|
252
|
+
onEach(items, item => {
|
|
253
|
+
$('li create=', grow, 'destroy=', shrink, `#${item.text}`);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// CSS class-based transitions
|
|
257
|
+
$('div create=.fade-in destroy=.fade-out#Animated');
|
|
258
|
+
// On create: class added briefly then removed (after layout)
|
|
259
|
+
// On destroy: class added, element removed after 2s delay
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Only triggers for **top-level** elements of a (re-)running scope, not deeply nested children.
|
|
263
|
+
|
|
264
|
+
## HTML Conversion Tool
|
|
265
|
+
Convert HTML to Aberdeen syntax:
|
|
266
|
+
```bash
|
|
267
|
+
echo '<div class="box"><p>Hello</p></div>' | npx html-to-aberdeen
|
|
268
|
+
# Output: $('div.box', () => { $('p#Hello'); });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Advanced Features
|
|
272
|
+
- **Routing**: [references/routing.md](references/routing.md) - Browser history routing and path dispatching
|
|
273
|
+
- **Transitions**: [references/transitions.md](references/transitions.md) - Detailed animation patterns
|
|
274
|
+
- **Predictions**: [references/prediction.md](references/prediction.md) - Optimistic UI with auto-revert
|
|
275
|
+
|
|
276
|
+
## Best Practices
|
|
277
|
+
1. **Type everything:** Use TypeScript. `proxy()` preserves types; class instances work great.
|
|
278
|
+
2. **Use CSS variables:** Define `@primary`, `@secondary`, etc. in `cssVars` for colors.
|
|
279
|
+
3. **Use spacing scale:** Prefer `@3`, `@4` for margins/paddings over hardcoded values, for consistency and easy theming/scaling. Don't use when exact pixel values are needed.
|
|
280
|
+
4. **Minimize scope size:** Smaller reactive scopes = fewer DOM updates.
|
|
281
|
+
5. **Use `onEach` for lists:** Never iterate proxied arrays with `for`/`map` in render functions.
|
|
282
|
+
6. **Pass observables directly:** `$('span text=', observable)` not interpolation.
|
|
283
|
+
7. **Components are functions:** Just write functions that call `$`.
|
|
284
|
+
8. **Top-level CSS:** Call `insertCss` at module level, not in render functions.
|
|
285
|
+
9. **Dynamic values:** Always use `attr=', value` syntax for user data.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Prediction (Optimistic UI)
|
|
2
|
+
|
|
3
|
+
Apply UI changes immediately, auto-revert when server responds.
|
|
4
|
+
|
|
5
|
+
## API
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { applyPrediction, applyCanon } from 'aberdeen/prediction';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### `applyPrediction(func)`
|
|
12
|
+
Runs function and records all proxy changes as a "prediction".
|
|
13
|
+
Returns a `Patch` to use as `dropPatches` later, when the server responds.
|
|
14
|
+
|
|
15
|
+
### `applyCanon(func?, dropPatches?)`
|
|
16
|
+
1. Reverts all predictions
|
|
17
|
+
2. Runs `func` (typically applies server data)
|
|
18
|
+
3. Drops specified patches
|
|
19
|
+
4. Re-applies remaining predictions that still apply cleanly
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
```typescript
|
|
23
|
+
async function toggleTodo(todo: Todo) {
|
|
24
|
+
// Optimistic update
|
|
25
|
+
const patch = applyPrediction(() => {
|
|
26
|
+
todo.done = !todo.done;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const data = await api.updateTodo(todo.id, { done: todo.done });
|
|
31
|
+
|
|
32
|
+
// Server responded - apply canonical state
|
|
33
|
+
applyCanon(() => {
|
|
34
|
+
Object.assign(todo, data);
|
|
35
|
+
}, [patch]);
|
|
36
|
+
} catch {
|
|
37
|
+
// On error, just drop the prediction to revert
|
|
38
|
+
applyCanon(undefined, [patch]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## When to Use
|
|
44
|
+
- When you want immediate UI feedback for user actions for which a server is authoritative.
|
|
45
|
+
- As doing this manually for each such case is tedious, this should usually be integrated into the data updating/fetching layer.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Routing and Dispatching
|
|
2
|
+
|
|
3
|
+
## Router (`aberdeen/route`)
|
|
4
|
+
|
|
5
|
+
The `current` object is a reactive proxy of the current URL state.
|
|
6
|
+
|
|
7
|
+
### Properties
|
|
8
|
+
| Property | Type | Description |
|
|
9
|
+
|----------|------|-------------|
|
|
10
|
+
| `path` | `string` | Normalized path (e.g., `/users/123`) |
|
|
11
|
+
| `p` | `string[]` | Path segments (e.g., `['users', '123']`) |
|
|
12
|
+
| `search` | `Record<string,string>` | Query parameters |
|
|
13
|
+
| `hash` | `string` | URL hash including `#` |
|
|
14
|
+
| `state` | `Record<string,any>` | JSON-compatible state data |
|
|
15
|
+
| `nav` | `NavType` | How we got here: `load`, `back`, `forward`, `go`, `push` |
|
|
16
|
+
| `depth` | `number` | Navigation stack depth (starts at 1) |
|
|
17
|
+
|
|
18
|
+
### Navigation Functions
|
|
19
|
+
```typescript
|
|
20
|
+
import * as route from 'aberdeen/route';
|
|
21
|
+
|
|
22
|
+
route.go('/users/42'); // Navigate to new URL
|
|
23
|
+
route.go({ p: ['users', 42], hash: 'top' }); // Object form
|
|
24
|
+
route.push({ search: { tab: 'feed' } }); // Merge into current route
|
|
25
|
+
route.back(); // Go back in history
|
|
26
|
+
route.back({ path: '/home' }); // Back to matching entry, or replace
|
|
27
|
+
route.up(); // Go up one path level
|
|
28
|
+
route.persistScroll(); // Save/restore scroll position
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Reactive Routing Example
|
|
32
|
+
```typescript
|
|
33
|
+
import * as route from 'aberdeen/route';
|
|
34
|
+
|
|
35
|
+
$(() => {
|
|
36
|
+
const [section, id] = route.current.p;
|
|
37
|
+
if (section === 'users') drawUser(id);
|
|
38
|
+
else if (section === 'settings') drawSettings();
|
|
39
|
+
else drawHome();
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Dispatcher (`aberdeen/dispatcher`)
|
|
44
|
+
|
|
45
|
+
Type-safe path segment matching for complex routing.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { Dispatcher, matchRest } from 'aberdeen/dispatcher';
|
|
49
|
+
import * as route from 'aberdeen/route';
|
|
50
|
+
|
|
51
|
+
const d = new Dispatcher();
|
|
52
|
+
|
|
53
|
+
// Literal string match
|
|
54
|
+
d.addRoute('home', () => drawHome());
|
|
55
|
+
|
|
56
|
+
// Number extraction (uses built-in Number function)
|
|
57
|
+
d.addRoute('user', Number, (id) => drawUser(id));
|
|
58
|
+
|
|
59
|
+
// String extraction
|
|
60
|
+
d.addRoute('user', Number, 'post', String, (userId, postId) => {
|
|
61
|
+
drawPost(userId, postId);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Rest of path as array
|
|
65
|
+
d.addRoute('search', matchRest, (terms: string[]) => {
|
|
66
|
+
performSearch(terms);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Dispatch in reactive scope
|
|
70
|
+
$(() => {
|
|
71
|
+
if (!d.dispatch(route.current.p)) {
|
|
72
|
+
draw404();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Custom Matchers
|
|
78
|
+
```typescript
|
|
79
|
+
const uuid = (s: string) => /^[0-9a-f-]{36}$/.test(s) ? s : matchFailed;
|
|
80
|
+
d.addRoute('item', uuid, (id) => drawItem(id));
|
|
81
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Transitions
|
|
2
|
+
|
|
3
|
+
Animate elements entering/leaving the DOM via the `create` and `destroy` properties.
|
|
4
|
+
|
|
5
|
+
**Important:** Transitions only trigger for **top-level** elements of a scope being (re-)run. Deeply nested elements drawn as part of a larger redraw do not trigger transitions.
|
|
6
|
+
|
|
7
|
+
## Built-in Transitions
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { grow, shrink } from 'aberdeen/transitions';
|
|
11
|
+
|
|
12
|
+
// Apply to individual elements
|
|
13
|
+
$('div create=', grow, 'destroy=', shrink, '#Animated');
|
|
14
|
+
|
|
15
|
+
// Common with onEach for list animations
|
|
16
|
+
onEach(items, item => {
|
|
17
|
+
$('li create=', grow, 'destroy=', shrink, `#${item.text}`);
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- `grow`: Scales element from 0 to full size with margin animation
|
|
22
|
+
- `shrink`: Scales element to 0 and removes from DOM after animation
|
|
23
|
+
|
|
24
|
+
Both detect horizontal flex containers and animate width instead of height.
|
|
25
|
+
|
|
26
|
+
## CSS-Based Transitions
|
|
27
|
+
|
|
28
|
+
For custom transitions, use CSS class strings (dot-separated):
|
|
29
|
+
```typescript
|
|
30
|
+
const fadeStyle = insertCss({
|
|
31
|
+
transition: 'all 0.3s ease-out',
|
|
32
|
+
'&.hidden': { opacity: 0, transform: 'translateY(-10px)' }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Class added briefly on create (removed after layout)
|
|
36
|
+
// Class added on destroy (element removed after 2s delay)
|
|
37
|
+
$('div', fadeStyle, 'create=.hidden destroy=.hidden#Fading element');
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Custom Transition Functions
|
|
41
|
+
|
|
42
|
+
For full control, pass functions. For `destroy`, your function must remove the element:
|
|
43
|
+
```typescript
|
|
44
|
+
$('div create=', (el: HTMLElement) => {
|
|
45
|
+
// Animate on mount - element already in DOM
|
|
46
|
+
el.animate([{ opacity: 0 }, { opacity: 1 }], 300);
|
|
47
|
+
}, 'destroy=', (el: HTMLElement) => {
|
|
48
|
+
// YOU must remove the element when done
|
|
49
|
+
el.animate([{ opacity: 1 }, { opacity: 0 }], 300)
|
|
50
|
+
.finished.then(() => el.remove());
|
|
51
|
+
}, '#Custom animated');
|
|
52
|
+
```
|