@vertz/ui 0.2.0
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 +1138 -0
- package/dist/css/public.d.ts +157 -0
- package/dist/css/public.js +18 -0
- package/dist/form/public.d.ts +111 -0
- package/dist/form/public.js +11 -0
- package/dist/index.d.ts +700 -0
- package/dist/index.js +261 -0
- package/dist/internals.d.ts +414 -0
- package/dist/internals.js +268 -0
- package/dist/jsx-runtime/index.d.ts +40 -0
- package/dist/jsx-runtime/index.js +50 -0
- package/dist/query/public.d.ts +58 -0
- package/dist/query/public.js +7 -0
- package/dist/router/public.d.ts +214 -0
- package/dist/router/public.js +21 -0
- package/dist/shared/chunk-bp3v6s9j.js +62 -0
- package/dist/shared/chunk-d8h2eh8d.js +141 -0
- package/dist/shared/chunk-f1ynwam4.js +872 -0
- package/dist/shared/chunk-j8vzvne3.js +153 -0
- package/dist/shared/chunk-pgymxpn1.js +308 -0
- package/dist/shared/chunk-tsdpgmks.js +98 -0
- package/dist/shared/chunk-xd9d7q5p.js +115 -0
- package/dist/shared/chunk-zbbvx05f.js +202 -0
- package/dist/test/index.d.ts +268 -0
- package/dist/test/index.js +236 -0
- package/package.json +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
# @vertz/ui
|
|
2
|
+
|
|
3
|
+
A compiler-driven UI framework with fine-grained reactivity. Write plain variables and JSX -- the compiler transforms them into efficient reactive DOM operations. No virtual DOM.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Quick Start](#quick-start)
|
|
8
|
+
- [Reactivity](#reactivity)
|
|
9
|
+
- [Components](#components)
|
|
10
|
+
- [Conditional Rendering](#conditional-rendering)
|
|
11
|
+
- [List Rendering](#list-rendering)
|
|
12
|
+
- [Styling](#styling)
|
|
13
|
+
- [Data Fetching](#data-fetching)
|
|
14
|
+
- [Routing](#routing)
|
|
15
|
+
- [Forms](#forms)
|
|
16
|
+
- [Lifecycle](#lifecycle)
|
|
17
|
+
- [Primitives](#primitives)
|
|
18
|
+
- [When to Use effect()](#when-to-use-effect)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @vertz/ui @vertz/ui-compiler
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Vite Config
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// vite.config.ts
|
|
34
|
+
import vertz from '@vertz/ui-compiler/vite';
|
|
35
|
+
import { defineConfig } from 'vite';
|
|
36
|
+
|
|
37
|
+
export default defineConfig({
|
|
38
|
+
plugins: [vertz()],
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The plugin transforms all `.tsx` and `.jsx` files by default. You can customize with `include` and `exclude` globs:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
vertz({
|
|
46
|
+
include: ['**/*.tsx'],
|
|
47
|
+
exclude: ['**/vendor/**'],
|
|
48
|
+
cssExtraction: true, // default: true in production
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Hello World
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
function App() {
|
|
56
|
+
let name = 'World';
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div>
|
|
60
|
+
<h1>Hello, {name}!</h1>
|
|
61
|
+
<input
|
|
62
|
+
value={name}
|
|
63
|
+
onInput={(e) => (name = (e.target as HTMLInputElement).value)}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
document.body.appendChild(App());
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
That's it. `name` is reactive. Typing in the input updates the heading. No hooks, no store setup, no subscriptions.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Reactivity
|
|
77
|
+
|
|
78
|
+
This is the core mental model of the framework. The compiler does the heavy lifting -- you write normal-looking code and get fine-grained reactive DOM updates.
|
|
79
|
+
|
|
80
|
+
### `let` = Reactive State
|
|
81
|
+
|
|
82
|
+
Any `let` declaration inside a component that is read in JSX becomes a signal:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
// What you write:
|
|
86
|
+
function Counter() {
|
|
87
|
+
let count = 0;
|
|
88
|
+
return <span>{count}</span>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// What the compiler produces:
|
|
92
|
+
function Counter() {
|
|
93
|
+
const count = signal(0);
|
|
94
|
+
const __el = __element("span");
|
|
95
|
+
__el.appendChild(__text(() => count.value));
|
|
96
|
+
return __el;
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Assignments work naturally:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
function Counter() {
|
|
104
|
+
let count = 0;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div>
|
|
108
|
+
<span>{count}</span>
|
|
109
|
+
<button onClick={() => count++}>+</button>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The compiler transforms `count++` to `count.value++`, `count = 5` to `count.value = 5`, and `count += 1` to `count.value += 1`.
|
|
116
|
+
|
|
117
|
+
### `const` = Derived State
|
|
118
|
+
|
|
119
|
+
A `const` that references a signal becomes a computed:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
// What you write:
|
|
123
|
+
function Pricing() {
|
|
124
|
+
let quantity = 1;
|
|
125
|
+
const total = 10 * quantity;
|
|
126
|
+
const formatted = `$${total}`;
|
|
127
|
+
|
|
128
|
+
return <span>{formatted}</span>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// What the compiler produces:
|
|
132
|
+
function Pricing() {
|
|
133
|
+
const quantity = signal(1);
|
|
134
|
+
const total = computed(() => 10 * quantity.value);
|
|
135
|
+
const formatted = computed(() => `$${total.value}`);
|
|
136
|
+
// ...
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Computeds are lazy and cached -- they only recompute when their dependencies change.
|
|
141
|
+
|
|
142
|
+
Destructuring also works:
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
function Profile() {
|
|
146
|
+
let user = { name: 'Alice', age: 30 };
|
|
147
|
+
const { name, age } = user;
|
|
148
|
+
|
|
149
|
+
return <span>{name} - {age}</span>;
|
|
150
|
+
}
|
|
151
|
+
// Compiler produces:
|
|
152
|
+
// const name = computed(() => user.name)
|
|
153
|
+
// const age = computed(() => user.age)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### JSX Text Interpolation
|
|
157
|
+
|
|
158
|
+
`{expr}` in JSX creates a reactive text node when `expr` depends on signals:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<span>{count}</span>
|
|
162
|
+
// becomes: __text(() => count.value)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Static expressions produce plain text nodes:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
const title = "Hello";
|
|
169
|
+
<span>{title}</span>
|
|
170
|
+
// becomes: document.createTextNode(title)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### JSX Reactive Attributes
|
|
174
|
+
|
|
175
|
+
Attributes that depend on signals auto-update:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
function App() {
|
|
179
|
+
let isActive = false;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className={isActive ? 'active' : 'inactive'}>
|
|
183
|
+
<button onClick={() => isActive = !isActive}>Toggle</button>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
// The compiler wraps the reactive className in __attr(el, "className", () => ...)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Mutations
|
|
191
|
+
|
|
192
|
+
The compiler intercepts mutations on signal-backed variables and generates peek/notify calls so the reactivity system is notified:
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
function App() {
|
|
196
|
+
let items = ['a', 'b'];
|
|
197
|
+
|
|
198
|
+
// .push(), .splice(), etc. all work:
|
|
199
|
+
items.push('c');
|
|
200
|
+
// compiles to: (items.peek().push('c'), items.notify())
|
|
201
|
+
|
|
202
|
+
// Property assignment:
|
|
203
|
+
let user = { name: 'Alice' };
|
|
204
|
+
user.name = 'Bob';
|
|
205
|
+
// compiles to: (user.peek().name = 'Bob', user.notify())
|
|
206
|
+
|
|
207
|
+
// Index assignment:
|
|
208
|
+
items[0] = 'z';
|
|
209
|
+
// compiles to: (items.peek()[0] = 'z', items.notify())
|
|
210
|
+
|
|
211
|
+
// Object.assign:
|
|
212
|
+
Object.assign(user, { age: 30 });
|
|
213
|
+
// compiles to: (Object.assign(user.peek(), { age: 30 }), user.notify())
|
|
214
|
+
|
|
215
|
+
// delete:
|
|
216
|
+
let config = { debug: true };
|
|
217
|
+
delete config.debug;
|
|
218
|
+
// compiles to: (delete config.peek().debug, config.notify())
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Event Handlers
|
|
223
|
+
|
|
224
|
+
`onClick`, `onInput`, etc. are transformed to `__on(el, "click", handler)`:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
<button onClick={() => count++}>+</button>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### DO vs DON'T
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
// DON'T -- imperative DOM manipulation
|
|
234
|
+
// This ignores the compiler entirely. You're doing its job by hand, badly.
|
|
235
|
+
import { signal, effect } from '@vertz/ui';
|
|
236
|
+
|
|
237
|
+
function Counter() {
|
|
238
|
+
const count = signal(0);
|
|
239
|
+
const label = document.createElement('span');
|
|
240
|
+
effect(() => { label.textContent = String(count.value); });
|
|
241
|
+
const btn = document.createElement('button');
|
|
242
|
+
btn.onclick = () => { count.value++; };
|
|
243
|
+
btn.textContent = '+';
|
|
244
|
+
const div = document.createElement('div');
|
|
245
|
+
div.append(label, btn);
|
|
246
|
+
return div;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// DO -- declarative JSX (let the compiler handle reactivity)
|
|
250
|
+
function Counter() {
|
|
251
|
+
let count = 0;
|
|
252
|
+
return (
|
|
253
|
+
<div>
|
|
254
|
+
<span>{count}</span>
|
|
255
|
+
<button onClick={() => count++}>+</button>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The declarative version is shorter, clearer, and produces the same (or better) runtime code. The compiler generates exactly the reactive bindings needed -- no more, no less.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Components
|
|
266
|
+
|
|
267
|
+
Components are functions that return `HTMLElement` (or `Node`). There is no component class, no `render()` method.
|
|
268
|
+
|
|
269
|
+
### Basic Component
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
function Greeting() {
|
|
273
|
+
return <h1>Hello</h1>;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Use it:
|
|
277
|
+
document.body.appendChild(Greeting());
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Props
|
|
281
|
+
|
|
282
|
+
Props are plain objects. For reactive props (values that can change), use getter functions:
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
interface CardProps {
|
|
286
|
+
title: string; // static -- value captured once
|
|
287
|
+
count: () => number; // reactive -- getter re-evaluated on access
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function Card(props: CardProps) {
|
|
291
|
+
return (
|
|
292
|
+
<div>
|
|
293
|
+
<h2>{props.title}</h2>
|
|
294
|
+
<span>{props.count()}</span>
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Usage:
|
|
300
|
+
function App() {
|
|
301
|
+
let n = 0;
|
|
302
|
+
return (
|
|
303
|
+
<div>
|
|
304
|
+
<Card title="Score" count={() => n} />
|
|
305
|
+
<button onClick={() => n++}>+</button>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The `() => n` getter ensures `Card` re-reads the value reactively. A bare `n` would capture the value once and never update.
|
|
312
|
+
|
|
313
|
+
### Children
|
|
314
|
+
|
|
315
|
+
Components can accept children via the `children` helper:
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
import { children, type ChildrenAccessor } from '@vertz/ui';
|
|
319
|
+
|
|
320
|
+
interface PanelProps {
|
|
321
|
+
children: ChildrenAccessor;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function Panel(props: PanelProps) {
|
|
325
|
+
const getChildren = children(props.children);
|
|
326
|
+
const el = <div className="panel" />;
|
|
327
|
+
for (const child of getChildren()) {
|
|
328
|
+
(el as HTMLElement).appendChild(child);
|
|
329
|
+
}
|
|
330
|
+
return el;
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Composition
|
|
335
|
+
|
|
336
|
+
Components compose by returning DOM nodes. No special syntax needed:
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
function App() {
|
|
340
|
+
return (
|
|
341
|
+
<div>
|
|
342
|
+
{Header()}
|
|
343
|
+
{MainContent()}
|
|
344
|
+
{Footer()}
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Conditional Rendering
|
|
353
|
+
|
|
354
|
+
Use standard JSX conditional patterns. The compiler transforms them into efficient reactive DOM operations with automatic disposal:
|
|
355
|
+
|
|
356
|
+
```tsx
|
|
357
|
+
function Toggle() {
|
|
358
|
+
let show = false;
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<div>
|
|
362
|
+
{show && <p>Now you see me</p>}
|
|
363
|
+
<button onClick={() => show = !show}>Toggle</button>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Ternaries work too:
|
|
370
|
+
|
|
371
|
+
```tsx
|
|
372
|
+
function StatusBadge() {
|
|
373
|
+
let online = true;
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div>
|
|
377
|
+
{online ? <span className="green">Online</span> : <span className="gray">Offline</span>}
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Under the hood, the compiler transforms these into `__conditional()` calls that manage DOM insertion, replacement, and cleanup automatically. When the condition changes, the old branch is disposed and the new branch is rendered in place.
|
|
384
|
+
|
|
385
|
+
### Manual Control
|
|
386
|
+
|
|
387
|
+
For advanced use cases where you need direct access to the dispose function or more control over the condition lifecycle, you can use `__conditional()` from `@vertz/ui/internals` directly:
|
|
388
|
+
|
|
389
|
+
```tsx
|
|
390
|
+
import { __conditional } from '@vertz/ui/internals';
|
|
391
|
+
|
|
392
|
+
function Toggle() {
|
|
393
|
+
let show = false;
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<div>
|
|
397
|
+
{__conditional(
|
|
398
|
+
() => show,
|
|
399
|
+
() => <p>Now you see me</p>,
|
|
400
|
+
() => <p>Now you don't</p>
|
|
401
|
+
)}
|
|
402
|
+
<button onClick={() => show = !show}>Toggle</button>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`__conditional` takes three arguments:
|
|
409
|
+
|
|
410
|
+
1. `condFn: () => boolean` -- reactive condition
|
|
411
|
+
2. `trueFn: () => Node` -- rendered when true
|
|
412
|
+
3. `falseFn: () => Node` -- rendered when false
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## List Rendering
|
|
417
|
+
|
|
418
|
+
Use `.map()` in JSX with a `key` prop for efficient keyed reconciliation. The compiler transforms it into optimized list operations:
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
function TodoList() {
|
|
422
|
+
let todos = [
|
|
423
|
+
{ id: '1', text: 'Learn vertz' },
|
|
424
|
+
{ id: '2', text: 'Build something' },
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<div>
|
|
429
|
+
<ul>
|
|
430
|
+
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
|
|
431
|
+
</ul>
|
|
432
|
+
<button onClick={() => {
|
|
433
|
+
todos = [...todos, { id: String(Date.now()), text: 'New todo' }];
|
|
434
|
+
}}>
|
|
435
|
+
Add
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The `key` prop is extracted by the compiler for efficient keyed reconciliation -- existing DOM nodes are reused and reordered, not recreated. Always provide a stable, unique key for each item.
|
|
443
|
+
|
|
444
|
+
### Manual Control
|
|
445
|
+
|
|
446
|
+
For advanced use cases where you need direct access to the list lifecycle or want to work with the signal directly, you can use `__list()` from `@vertz/ui/internals`:
|
|
447
|
+
|
|
448
|
+
```tsx
|
|
449
|
+
import { signal } from '@vertz/ui';
|
|
450
|
+
import { __list } from '@vertz/ui/internals';
|
|
451
|
+
|
|
452
|
+
function TodoList() {
|
|
453
|
+
const todosSignal = signal([
|
|
454
|
+
{ id: '1', text: 'Learn vertz' },
|
|
455
|
+
{ id: '2', text: 'Build something' },
|
|
456
|
+
]);
|
|
457
|
+
|
|
458
|
+
const container = <ul /> as HTMLElement;
|
|
459
|
+
|
|
460
|
+
__list(
|
|
461
|
+
container,
|
|
462
|
+
todosSignal,
|
|
463
|
+
(todo) => todo.id, // key function
|
|
464
|
+
(todo) => <li>{todo.text}</li> // render function (called once per key)
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<div>
|
|
469
|
+
{container}
|
|
470
|
+
<button onClick={() => {
|
|
471
|
+
todosSignal.value = [...todosSignal.value, { id: String(Date.now()), text: 'New todo' }];
|
|
472
|
+
}}>
|
|
473
|
+
Add
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
`__list` arguments:
|
|
481
|
+
|
|
482
|
+
1. `container: HTMLElement` -- parent element
|
|
483
|
+
2. `items: Signal<T[]>` -- reactive array
|
|
484
|
+
3. `keyFn: (item: T) => string | number` -- unique key extractor
|
|
485
|
+
4. `renderFn: (item: T) => Node` -- creates DOM for each item (called once per key)
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## Styling
|
|
490
|
+
|
|
491
|
+
Import from `@vertz/ui/css` or from the main `@vertz/ui` export.
|
|
492
|
+
|
|
493
|
+
### `css()` -- Scoped Style Blocks
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
496
|
+
import { css } from '@vertz/ui/css';
|
|
497
|
+
|
|
498
|
+
const styles = css({
|
|
499
|
+
card: ['p:4', 'bg:background', 'rounded:lg'],
|
|
500
|
+
title: ['font:xl', 'weight:bold', 'text:foreground'],
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
function Card() {
|
|
504
|
+
return (
|
|
505
|
+
<div className={styles.classNames.card}>
|
|
506
|
+
<h2 className={styles.classNames.title}>Hello</h2>
|
|
507
|
+
</div>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Shorthand syntax: `property:value` maps to CSS custom properties and design tokens. Pseudo-states are supported:
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
const button = css({
|
|
516
|
+
root: ['p:4', 'bg:primary', 'hover:bg:primary.700', 'rounded:md'],
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Object form for complex selectors:
|
|
521
|
+
|
|
522
|
+
```tsx
|
|
523
|
+
const fancy = css({
|
|
524
|
+
card: [
|
|
525
|
+
'p:4', 'bg:background',
|
|
526
|
+
{ '&::after': ['content:empty', 'block'] },
|
|
527
|
+
],
|
|
528
|
+
});
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### `variants()` -- Typed Component Variants
|
|
532
|
+
|
|
533
|
+
```tsx
|
|
534
|
+
import { variants } from '@vertz/ui/css';
|
|
535
|
+
|
|
536
|
+
const button = variants({
|
|
537
|
+
base: ['flex', 'font:medium', 'rounded:md'],
|
|
538
|
+
variants: {
|
|
539
|
+
intent: {
|
|
540
|
+
primary: ['bg:primary.600', 'text:foreground'],
|
|
541
|
+
secondary: ['bg:background', 'text:muted'],
|
|
542
|
+
},
|
|
543
|
+
size: {
|
|
544
|
+
sm: ['text:xs', 'h:8'],
|
|
545
|
+
md: ['text:sm', 'h:10'],
|
|
546
|
+
lg: ['text:base', 'h:12'],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
defaultVariants: { intent: 'primary', size: 'md' },
|
|
550
|
+
compoundVariants: [
|
|
551
|
+
{ intent: 'primary', size: 'sm', styles: ['px:2'] },
|
|
552
|
+
],
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Returns a className string:
|
|
556
|
+
button({ intent: 'secondary', size: 'sm' }); // => "base_abc secondary_def sm_ghi"
|
|
557
|
+
button(); // => uses defaults
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
The variant function is fully typed -- TypeScript infers the allowed values for `intent` and `size`.
|
|
561
|
+
|
|
562
|
+
### `defineTheme()` and `ThemeProvider`
|
|
563
|
+
|
|
564
|
+
```tsx
|
|
565
|
+
import { defineTheme, compileTheme, ThemeProvider } from '@vertz/ui/css';
|
|
566
|
+
|
|
567
|
+
const theme = defineTheme({
|
|
568
|
+
colors: {
|
|
569
|
+
primary: { 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' },
|
|
570
|
+
background: { DEFAULT: '#ffffff', _dark: '#111827' },
|
|
571
|
+
foreground: { DEFAULT: '#111827', _dark: '#f9fafb' },
|
|
572
|
+
},
|
|
573
|
+
spacing: {
|
|
574
|
+
1: '0.25rem',
|
|
575
|
+
2: '0.5rem',
|
|
576
|
+
4: '1rem',
|
|
577
|
+
8: '2rem',
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Generate CSS custom properties:
|
|
582
|
+
const compiled = compileTheme(theme);
|
|
583
|
+
// compiled.css contains:
|
|
584
|
+
// :root { --color-primary-500: #3b82f6; --color-background: #ffffff; ... }
|
|
585
|
+
// [data-theme="dark"] { --color-background: #111827; ... }
|
|
586
|
+
|
|
587
|
+
// Apply theme to a subtree:
|
|
588
|
+
const app = ThemeProvider({
|
|
589
|
+
theme: 'dark',
|
|
590
|
+
children: [MyApp()],
|
|
591
|
+
});
|
|
592
|
+
document.body.appendChild(app);
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Contextual tokens use `DEFAULT` for the base value and `_dark` for the dark variant. The `ThemeProvider` sets `data-theme` on a wrapper element.
|
|
596
|
+
|
|
597
|
+
### `globalCss()` -- Global Styles
|
|
598
|
+
|
|
599
|
+
```tsx
|
|
600
|
+
import { globalCss } from '@vertz/ui/css';
|
|
601
|
+
|
|
602
|
+
globalCss({
|
|
603
|
+
'*, *::before, *::after': {
|
|
604
|
+
boxSizing: 'border-box',
|
|
605
|
+
margin: '0',
|
|
606
|
+
},
|
|
607
|
+
body: {
|
|
608
|
+
fontFamily: 'system-ui, sans-serif',
|
|
609
|
+
lineHeight: '1.5',
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Properties use camelCase and are converted to kebab-case. CSS custom properties (`--*`) are passed through as-is.
|
|
615
|
+
|
|
616
|
+
### `s()` -- Inline Styles
|
|
617
|
+
|
|
618
|
+
For truly dynamic styles that can't be static:
|
|
619
|
+
|
|
620
|
+
```tsx
|
|
621
|
+
import { s } from '@vertz/ui/css';
|
|
622
|
+
|
|
623
|
+
function Bar(props: { width: number }) {
|
|
624
|
+
return <div style={s([`w:${props.width}px`, 'h:4', 'bg:primary.500'])} />;
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
Pseudo-states are not supported in `s()` -- use `css()` for those.
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## Data Fetching
|
|
633
|
+
|
|
634
|
+
Import from `@vertz/ui/query` or the main `@vertz/ui` export.
|
|
635
|
+
|
|
636
|
+
```tsx
|
|
637
|
+
import { query } from '@vertz/ui/query';
|
|
638
|
+
|
|
639
|
+
function UserProfile() {
|
|
640
|
+
let userId = 1;
|
|
641
|
+
|
|
642
|
+
const { data, loading, error, refetch } = query(
|
|
643
|
+
() => fetch(`/api/users/${userId}`).then(r => r.json()),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
return (
|
|
647
|
+
<div>
|
|
648
|
+
{/* data, loading, error are signals -- read .value in reactive contexts */}
|
|
649
|
+
<span>{loading.value ? 'Loading...' : ''}</span>
|
|
650
|
+
<span>{data.value?.name}</span>
|
|
651
|
+
<button onClick={() => userId++}>Next User</button>
|
|
652
|
+
<button onClick={refetch}>Refresh</button>
|
|
653
|
+
</div>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
The thunk runs inside an effect, so reactive dependencies read before the `await` are automatically tracked. When `userId` changes, the query re-fetches.
|
|
659
|
+
|
|
660
|
+
### Options
|
|
661
|
+
|
|
662
|
+
```tsx
|
|
663
|
+
const result = query(() => fetchData(), {
|
|
664
|
+
initialData: cachedValue, // skip initial fetch
|
|
665
|
+
debounce: 300, // debounce re-fetches (ms)
|
|
666
|
+
enabled: true, // set false to disable
|
|
667
|
+
key: 'custom-cache-key', // explicit cache key
|
|
668
|
+
cache: myCustomCache, // custom CacheStore implementation
|
|
669
|
+
});
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Cleanup
|
|
673
|
+
|
|
674
|
+
```tsx
|
|
675
|
+
const { dispose } = query(() => fetchData());
|
|
676
|
+
|
|
677
|
+
// Stop the reactive effect and clean up in-flight requests:
|
|
678
|
+
dispose();
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
`revalidate()` is an alias for `refetch()`.
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## Routing
|
|
686
|
+
|
|
687
|
+
Import from `@vertz/ui/router` or the main `@vertz/ui` export.
|
|
688
|
+
|
|
689
|
+
### Define Routes
|
|
690
|
+
|
|
691
|
+
```tsx
|
|
692
|
+
import { defineRoutes, createRouter, createLink, createOutlet } from '@vertz/ui/router';
|
|
693
|
+
|
|
694
|
+
const routes = defineRoutes({
|
|
695
|
+
'/': {
|
|
696
|
+
component: () => HomePage(),
|
|
697
|
+
},
|
|
698
|
+
'/users/:id': {
|
|
699
|
+
component: () => UserPage(),
|
|
700
|
+
loader: async ({ params, signal }) => {
|
|
701
|
+
const res = await fetch(`/api/users/${params.id}`, { signal });
|
|
702
|
+
return res.json();
|
|
703
|
+
},
|
|
704
|
+
errorComponent: (error) => <div>Failed: {error.message}</div>,
|
|
705
|
+
},
|
|
706
|
+
'/about': {
|
|
707
|
+
component: () => AboutPage(),
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### Create Router
|
|
713
|
+
|
|
714
|
+
```tsx
|
|
715
|
+
const router = createRouter(routes, window.location.pathname + window.location.search);
|
|
716
|
+
|
|
717
|
+
// Reactive state:
|
|
718
|
+
router.current; // Signal<RouteMatch | null>
|
|
719
|
+
router.loaderData; // Signal<unknown[]>
|
|
720
|
+
router.loaderError; // Signal<Error | null>
|
|
721
|
+
router.searchParams; // Signal<Record<string, unknown>>
|
|
722
|
+
|
|
723
|
+
// Navigation:
|
|
724
|
+
await router.navigate('/users/42');
|
|
725
|
+
await router.navigate('/home', { replace: true });
|
|
726
|
+
|
|
727
|
+
// Re-run loaders:
|
|
728
|
+
await router.revalidate();
|
|
729
|
+
|
|
730
|
+
// Cleanup:
|
|
731
|
+
router.dispose();
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### Link Component
|
|
735
|
+
|
|
736
|
+
```tsx
|
|
737
|
+
const Link = createLink(router.current, (url) => router.navigate(url));
|
|
738
|
+
|
|
739
|
+
function Nav() {
|
|
740
|
+
return (
|
|
741
|
+
<nav>
|
|
742
|
+
{Link({ href: '/', children: 'Home', activeClass: 'active' })}
|
|
743
|
+
{Link({ href: '/about', children: 'About', activeClass: 'active' })}
|
|
744
|
+
</nav>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Links intercept clicks for SPA navigation (modifier-key clicks still open new tabs). The `activeClass` is applied reactively when the link's `href` matches the current path.
|
|
750
|
+
|
|
751
|
+
### Nested Routes and Outlets
|
|
752
|
+
|
|
753
|
+
```tsx
|
|
754
|
+
import { createContext } from '@vertz/ui';
|
|
755
|
+
import { createOutlet, type OutletContext } from '@vertz/ui/router';
|
|
756
|
+
|
|
757
|
+
const outletCtx = createContext<OutletContext>();
|
|
758
|
+
const Outlet = createOutlet(outletCtx);
|
|
759
|
+
|
|
760
|
+
const routes = defineRoutes({
|
|
761
|
+
'/dashboard': {
|
|
762
|
+
component: () => DashboardLayout(),
|
|
763
|
+
children: {
|
|
764
|
+
'/': {
|
|
765
|
+
component: () => DashboardHome(),
|
|
766
|
+
},
|
|
767
|
+
'/settings': {
|
|
768
|
+
component: () => DashboardSettings(),
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### Search Params
|
|
776
|
+
|
|
777
|
+
```tsx
|
|
778
|
+
import { parseSearchParams, useSearchParams } from '@vertz/ui/router';
|
|
779
|
+
|
|
780
|
+
// Parse raw URLSearchParams, optionally through a schema:
|
|
781
|
+
const params = parseSearchParams(new URLSearchParams('?page=1&sort=name'), mySchema);
|
|
782
|
+
|
|
783
|
+
// Read reactively inside an effect or computed:
|
|
784
|
+
const search = useSearchParams(router.searchParams);
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### Type-Safe Params
|
|
788
|
+
|
|
789
|
+
Route params are extracted from the path pattern at the type level:
|
|
790
|
+
|
|
791
|
+
```tsx
|
|
792
|
+
import type { ExtractParams } from '@vertz/ui/router';
|
|
793
|
+
|
|
794
|
+
type Params = ExtractParams<'/users/:id/posts/:postId'>;
|
|
795
|
+
// => { id: string; postId: string }
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
## Forms
|
|
801
|
+
|
|
802
|
+
Import from `@vertz/ui/form` or the main `@vertz/ui` export.
|
|
803
|
+
|
|
804
|
+
```tsx
|
|
805
|
+
import { form } from '@vertz/ui/form';
|
|
806
|
+
import type { SdkMethod } from '@vertz/ui/form';
|
|
807
|
+
|
|
808
|
+
// An SDK method with endpoint metadata (typically from @vertz/codegen):
|
|
809
|
+
declare const createUser: SdkMethod<{ name: string; email: string }, { id: string }>;
|
|
810
|
+
|
|
811
|
+
const userForm = form(createUser, {
|
|
812
|
+
schema: userSchema, // any object with parse(data): T
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
function CreateUserForm() {
|
|
816
|
+
return (
|
|
817
|
+
<form
|
|
818
|
+
{...userForm.attrs()}
|
|
819
|
+
onSubmit={userForm.handleSubmit({
|
|
820
|
+
onSuccess: (result) => console.log('Created:', result.id),
|
|
821
|
+
onError: (errors) => console.log('Errors:', errors),
|
|
822
|
+
})}
|
|
823
|
+
>
|
|
824
|
+
<input name="name" />
|
|
825
|
+
{userForm.error('name') && <span className="error">{userForm.error('name')}</span>}
|
|
826
|
+
|
|
827
|
+
<input name="email" type="email" />
|
|
828
|
+
{userForm.error('email') && <span className="error">{userForm.error('email')}</span>}
|
|
829
|
+
|
|
830
|
+
<button type="submit" disabled={userForm.submitting.value}>
|
|
831
|
+
{userForm.submitting.value ? 'Saving...' : 'Create'}
|
|
832
|
+
</button>
|
|
833
|
+
</form>
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
### API
|
|
839
|
+
|
|
840
|
+
- `form(sdkMethod, { schema })` -- creates a form instance
|
|
841
|
+
- `.attrs()` -- returns `{ action, method }` for progressive enhancement
|
|
842
|
+
- `.handleSubmit({ onSuccess?, onError? })` -- returns an event handler or accepts `FormData` directly
|
|
843
|
+
- `.error(field)` -- returns the error message for a field (reactive)
|
|
844
|
+
- `.submitting` -- `Signal<boolean>` for loading state
|
|
845
|
+
|
|
846
|
+
The schema can be any object with a `parse(data: unknown): T` method (compatible with `@vertz/schema`). On failure, if the error has a `fieldErrors` property, those are surfaced per-field. Otherwise, a generic `_form` error is set.
|
|
847
|
+
|
|
848
|
+
---
|
|
849
|
+
|
|
850
|
+
## Lifecycle
|
|
851
|
+
|
|
852
|
+
### `onMount(callback)`
|
|
853
|
+
|
|
854
|
+
Runs once when the component is created. Does not re-run on signal changes. Supports `onCleanup` inside for teardown:
|
|
855
|
+
|
|
856
|
+
```tsx
|
|
857
|
+
import { onMount, onCleanup } from '@vertz/ui';
|
|
858
|
+
|
|
859
|
+
function Timer() {
|
|
860
|
+
let elapsed = 0;
|
|
861
|
+
|
|
862
|
+
onMount(() => {
|
|
863
|
+
const id = setInterval(() => elapsed++, 1000);
|
|
864
|
+
onCleanup(() => clearInterval(id));
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
return <span>{elapsed}s</span>;
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
### `onCleanup(fn)`
|
|
872
|
+
|
|
873
|
+
Registers a teardown function with the current disposal scope. Called in LIFO order when the scope is disposed:
|
|
874
|
+
|
|
875
|
+
```tsx
|
|
876
|
+
import { onCleanup } from '@vertz/ui';
|
|
877
|
+
|
|
878
|
+
function WebSocketView() {
|
|
879
|
+
const ws = new WebSocket('wss://example.com');
|
|
880
|
+
onCleanup(() => ws.close());
|
|
881
|
+
// ...
|
|
882
|
+
}
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
Must be called inside a disposal scope (`effect()`, `watch()`, `onMount()`, or a `pushScope()/popScope()` block). Throws `DisposalScopeError` if called outside a scope.
|
|
886
|
+
|
|
887
|
+
### `watch(dep, callback)`
|
|
888
|
+
|
|
889
|
+
Watches a reactive dependency and runs the callback whenever it changes. Runs immediately with the current value:
|
|
890
|
+
|
|
891
|
+
```tsx
|
|
892
|
+
import { watch, onCleanup } from '@vertz/ui';
|
|
893
|
+
|
|
894
|
+
function Logger() {
|
|
895
|
+
let count = 0;
|
|
896
|
+
|
|
897
|
+
watch(
|
|
898
|
+
() => count,
|
|
899
|
+
(value) => {
|
|
900
|
+
console.log('count changed to', value);
|
|
901
|
+
const id = setTimeout(() => console.log('delayed log', value), 1000);
|
|
902
|
+
onCleanup(() => clearTimeout(id));
|
|
903
|
+
}
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
return <button onClick={() => count++}>+</button>;
|
|
907
|
+
}
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
The `dep` function is the only tracked dependency. The callback runs untracked, so signal reads inside it don't create additional subscriptions. Before each re-run, any `onCleanup` registered in the previous callback execution runs first.
|
|
911
|
+
|
|
912
|
+
### `ref()`
|
|
913
|
+
|
|
914
|
+
Access a DOM element after creation:
|
|
915
|
+
|
|
916
|
+
```tsx
|
|
917
|
+
import { ref, onMount } from '@vertz/ui';
|
|
918
|
+
|
|
919
|
+
function FocusInput() {
|
|
920
|
+
const inputRef = ref<HTMLInputElement>();
|
|
921
|
+
|
|
922
|
+
onMount(() => {
|
|
923
|
+
inputRef.current?.focus();
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// Assign ref.current after element creation:
|
|
927
|
+
const el = <input /> as HTMLInputElement;
|
|
928
|
+
inputRef.current = el;
|
|
929
|
+
|
|
930
|
+
return el;
|
|
931
|
+
}
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
### Context
|
|
935
|
+
|
|
936
|
+
Share values down the component tree without prop-drilling:
|
|
937
|
+
|
|
938
|
+
```tsx
|
|
939
|
+
import { createContext, useContext } from '@vertz/ui';
|
|
940
|
+
|
|
941
|
+
const ThemeCtx = createContext<'light' | 'dark'>('light');
|
|
942
|
+
|
|
943
|
+
function App() {
|
|
944
|
+
const el = document.createDocumentFragment();
|
|
945
|
+
|
|
946
|
+
ThemeCtx.Provider('dark', () => {
|
|
947
|
+
el.appendChild(ThemedCard());
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
return el;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function ThemedCard() {
|
|
954
|
+
const theme = useContext(ThemeCtx); // => 'dark'
|
|
955
|
+
return <div className={theme === 'dark' ? 'card-dark' : 'card-light'}>Themed</div>;
|
|
956
|
+
}
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
`useContext` works in both synchronous component code and inside `effect`/`watch` callbacks (the context scope is captured when the effect is created).
|
|
960
|
+
|
|
961
|
+
### ErrorBoundary
|
|
962
|
+
|
|
963
|
+
Catch errors thrown by child components:
|
|
964
|
+
|
|
965
|
+
```tsx
|
|
966
|
+
import { ErrorBoundary } from '@vertz/ui';
|
|
967
|
+
|
|
968
|
+
function App() {
|
|
969
|
+
return ErrorBoundary({
|
|
970
|
+
children: () => RiskyComponent(),
|
|
971
|
+
fallback: (error, retry) => (
|
|
972
|
+
<div>
|
|
973
|
+
<p>Something broke: {error.message}</p>
|
|
974
|
+
<button onClick={retry}>Retry</button>
|
|
975
|
+
</div>
|
|
976
|
+
),
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
The `retry` function re-invokes `children()` and swaps the result into the DOM if it succeeds.
|
|
982
|
+
|
|
983
|
+
### Suspense
|
|
984
|
+
|
|
985
|
+
Handle async boundaries (components that throw promises):
|
|
986
|
+
|
|
987
|
+
```tsx
|
|
988
|
+
import { Suspense } from '@vertz/ui';
|
|
989
|
+
|
|
990
|
+
function App() {
|
|
991
|
+
return Suspense({
|
|
992
|
+
children: () => AsyncComponent(),
|
|
993
|
+
fallback: () => <div>Loading...</div>,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
If `children()` throws a `Promise`, the fallback is rendered. When the promise resolves, `children()` is called again and the result replaces the fallback in the DOM. Non-promise errors are re-thrown for `ErrorBoundary` to catch.
|
|
999
|
+
|
|
1000
|
+
---
|
|
1001
|
+
|
|
1002
|
+
## Primitives
|
|
1003
|
+
|
|
1004
|
+
`@vertz/primitives` provides headless, WAI-ARIA compliant UI components. These are intentionally imperative -- they create pre-wired DOM elements with proper ARIA attributes, keyboard handling, and state management.
|
|
1005
|
+
|
|
1006
|
+
```bash
|
|
1007
|
+
npm install @vertz/primitives
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
Available components: `Accordion`, `Button`, `Checkbox`, `Combobox`, `Dialog`, `Menu`, `Popover`, `Progress`, `Radio`, `Select`, `Slider`, `Switch`, `Tabs`, `Toast`, `Tooltip`.
|
|
1011
|
+
|
|
1012
|
+
### Usage Pattern
|
|
1013
|
+
|
|
1014
|
+
Primitives return DOM elements and reactive state. Compose them with your JSX:
|
|
1015
|
+
|
|
1016
|
+
```tsx
|
|
1017
|
+
import { Button } from '@vertz/primitives';
|
|
1018
|
+
import { css } from '@vertz/ui/css';
|
|
1019
|
+
|
|
1020
|
+
const styles = css({
|
|
1021
|
+
btn: ['px:4', 'py:2', 'bg:primary.600', 'text:foreground', 'rounded:md'],
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
function MyButton() {
|
|
1025
|
+
const { root, state } = Button.Root({
|
|
1026
|
+
disabled: false,
|
|
1027
|
+
onPress: () => console.log('pressed!'),
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
root.textContent = 'Click me';
|
|
1031
|
+
root.classList.add(styles.classNames.btn);
|
|
1032
|
+
|
|
1033
|
+
return root;
|
|
1034
|
+
}
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
Primitives handle:
|
|
1038
|
+
- ARIA roles and attributes (`role="button"`, `aria-pressed`, `aria-expanded`, etc.)
|
|
1039
|
+
- Keyboard interaction (Enter/Space for buttons, arrow keys for menus, Escape for dialogs)
|
|
1040
|
+
- Focus management
|
|
1041
|
+
- State via signals (`state.disabled`, `state.pressed`, `state.open`, etc.)
|
|
1042
|
+
|
|
1043
|
+
You provide the styling. They provide the behavior and accessibility.
|
|
1044
|
+
|
|
1045
|
+
---
|
|
1046
|
+
|
|
1047
|
+
## When to Use `effect()`
|
|
1048
|
+
|
|
1049
|
+
`effect()` is a low-level reactive primitive. In most cases, the compiler handles reactivity for you through JSX. Reach for `effect()` only when you need side effects that the compiler cannot express:
|
|
1050
|
+
|
|
1051
|
+
### Appropriate Uses
|
|
1052
|
+
|
|
1053
|
+
```tsx
|
|
1054
|
+
import { effect, onCleanup } from '@vertz/ui';
|
|
1055
|
+
|
|
1056
|
+
function Analytics() {
|
|
1057
|
+
let page = '/home';
|
|
1058
|
+
|
|
1059
|
+
// Side effect: send analytics
|
|
1060
|
+
effect(() => {
|
|
1061
|
+
sendPageView(page);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// Third-party library integration
|
|
1065
|
+
effect(() => {
|
|
1066
|
+
chart.updateData(chartData);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
// DOM operations the compiler can't handle
|
|
1070
|
+
effect(() => {
|
|
1071
|
+
element.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// localStorage sync
|
|
1075
|
+
effect(() => {
|
|
1076
|
+
localStorage.setItem('preference', preference);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
### NOT Appropriate
|
|
1082
|
+
|
|
1083
|
+
```tsx
|
|
1084
|
+
// DON'T: manual DOM text updates -- use JSX interpolation instead
|
|
1085
|
+
effect(() => { span.textContent = String(count); });
|
|
1086
|
+
// DO:
|
|
1087
|
+
<span>{count}</span>
|
|
1088
|
+
|
|
1089
|
+
// DON'T: manual attribute updates -- use JSX attributes instead
|
|
1090
|
+
effect(() => { div.className = isActive ? 'on' : 'off'; });
|
|
1091
|
+
// DO:
|
|
1092
|
+
<div className={isActive ? 'on' : 'off'} />
|
|
1093
|
+
|
|
1094
|
+
// DON'T: manual child rendering -- use JSX conditionals and .map() instead
|
|
1095
|
+
effect(() => {
|
|
1096
|
+
container.innerHTML = '';
|
|
1097
|
+
if (show) container.appendChild(createChild());
|
|
1098
|
+
});
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
`effect()` returns a dispose function. It auto-registers with the current disposal scope, so cleanup happens automatically when the parent scope is disposed:
|
|
1102
|
+
|
|
1103
|
+
```tsx
|
|
1104
|
+
const dispose = effect(() => {
|
|
1105
|
+
console.log('count is', count);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// Manual cleanup if needed:
|
|
1109
|
+
dispose();
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
### `batch()`
|
|
1113
|
+
|
|
1114
|
+
Group multiple signal writes to avoid redundant effect runs:
|
|
1115
|
+
|
|
1116
|
+
```tsx
|
|
1117
|
+
import { batch } from '@vertz/ui';
|
|
1118
|
+
|
|
1119
|
+
batch(() => {
|
|
1120
|
+
firstName = 'Jane';
|
|
1121
|
+
lastName = 'Doe';
|
|
1122
|
+
age = 30;
|
|
1123
|
+
});
|
|
1124
|
+
// Effects that depend on any of these signals run once, not three times.
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
### `untrack()`
|
|
1128
|
+
|
|
1129
|
+
Read a signal without subscribing to it:
|
|
1130
|
+
|
|
1131
|
+
```tsx
|
|
1132
|
+
import { untrack } from '@vertz/ui';
|
|
1133
|
+
|
|
1134
|
+
effect(() => {
|
|
1135
|
+
const tracked = count; // subscribes to count
|
|
1136
|
+
const notTracked = untrack(() => other); // reads other without subscribing
|
|
1137
|
+
});
|
|
1138
|
+
```
|