@vertz/ui 0.2.0 → 0.2.2
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 +339 -857
- package/dist/css/public.d.ts +24 -27
- package/dist/css/public.js +5 -1
- package/dist/form/public.d.ts +94 -38
- package/dist/form/public.js +5 -3
- package/dist/index.d.ts +754 -167
- package/dist/index.js +606 -84
- package/dist/internals.d.ts +192 -23
- package/dist/internals.js +151 -102
- package/dist/jsx-runtime/index.d.ts +44 -17
- package/dist/jsx-runtime/index.js +26 -7
- package/dist/query/public.d.ts +73 -7
- package/dist/query/public.js +12 -4
- package/dist/router/public.d.ts +199 -26
- package/dist/router/public.js +22 -7
- package/dist/shared/chunk-0xcmwgdb.js +288 -0
- package/dist/shared/{chunk-j8vzvne3.js → chunk-9e92w0wt.js} +4 -1
- package/dist/shared/chunk-g4rch80a.js +33 -0
- package/dist/shared/chunk-hh0dhmb4.js +528 -0
- package/dist/shared/{chunk-pgymxpn1.js → chunk-hrd0mft1.js} +136 -34
- package/dist/shared/chunk-jrtrk5z4.js +125 -0
- package/dist/shared/chunk-ka5ked7n.js +188 -0
- package/dist/shared/chunk-n91rwj2r.js +483 -0
- package/dist/shared/chunk-prj7nm08.js +67 -0
- package/dist/shared/chunk-q6cpe5k7.js +230 -0
- package/dist/shared/{chunk-f1ynwam4.js → chunk-qacth5ah.js} +162 -36
- package/dist/shared/chunk-ryb49346.js +374 -0
- package/dist/shared/chunk-v3yyf79g.js +48 -0
- package/dist/test/index.d.ts +67 -6
- package/dist/test/index.js +4 -3
- package/package.json +14 -9
- package/dist/shared/chunk-bp3v6s9j.js +0 -62
- package/dist/shared/chunk-d8h2eh8d.js +0 -141
- package/dist/shared/chunk-tsdpgmks.js +0 -98
- package/dist/shared/chunk-xd9d7q5p.js +0 -115
- package/dist/shared/chunk-zbbvx05f.js +0 -202
package/README.md
CHANGED
|
@@ -1,1138 +1,620 @@
|
|
|
1
1
|
# @vertz/ui
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
3
|
+
**Use `let` for state. Use `const` for derived. Write JSX. Done.**
|
|
53
4
|
|
|
54
5
|
```tsx
|
|
55
|
-
function
|
|
56
|
-
let
|
|
6
|
+
function Counter() {
|
|
7
|
+
let count = 0;
|
|
57
8
|
|
|
58
9
|
return (
|
|
59
10
|
<div>
|
|
60
|
-
<
|
|
61
|
-
<
|
|
62
|
-
value={name}
|
|
63
|
-
onInput={(e) => (name = (e.target as HTMLInputElement).value)}
|
|
64
|
-
/>
|
|
11
|
+
<p>Count: {count}</p>
|
|
12
|
+
<button onClick={() => count++}>Increment</button>
|
|
65
13
|
</div>
|
|
66
14
|
);
|
|
67
15
|
}
|
|
68
|
-
|
|
69
|
-
document.body.appendChild(App());
|
|
70
16
|
```
|
|
71
17
|
|
|
72
|
-
That's it. `
|
|
18
|
+
That's it. `count` is reactive. The compiler transforms your code into efficient reactive DOM updates. No virtual DOM, no hooks, no boilerplate.
|
|
73
19
|
|
|
74
20
|
---
|
|
75
21
|
|
|
76
|
-
##
|
|
22
|
+
## Installation
|
|
77
23
|
|
|
78
|
-
|
|
24
|
+
```bash
|
|
25
|
+
npm install @vertz/ui @vertz/ui-compiler
|
|
26
|
+
```
|
|
79
27
|
|
|
80
|
-
###
|
|
28
|
+
### Bun Setup
|
|
81
29
|
|
|
82
|
-
|
|
30
|
+
The `@vertz/ui-server/bun-plugin` handles compiler transforms, CSS extraction, and Fast Refresh automatically when using `Bun.serve()` with HTML imports or `vertz dev`.
|
|
83
31
|
|
|
84
|
-
|
|
85
|
-
// What you write:
|
|
86
|
-
function Counter() {
|
|
87
|
-
let count = 0;
|
|
88
|
-
return <span>{count}</span>;
|
|
89
|
-
}
|
|
32
|
+
---
|
|
90
33
|
|
|
91
|
-
|
|
92
|
-
function Counter() {
|
|
93
|
-
const count = signal(0);
|
|
94
|
-
const __el = __element("span");
|
|
95
|
-
__el.appendChild(__text(() => count.value));
|
|
96
|
-
return __el;
|
|
97
|
-
}
|
|
98
|
-
```
|
|
34
|
+
## The Basics
|
|
99
35
|
|
|
100
|
-
|
|
36
|
+
### Reactive State: `let`
|
|
37
|
+
|
|
38
|
+
Any `let` variable read in JSX becomes reactive:
|
|
101
39
|
|
|
102
40
|
```tsx
|
|
103
|
-
function
|
|
104
|
-
let
|
|
41
|
+
function TodoInput() {
|
|
42
|
+
let text = '';
|
|
105
43
|
|
|
106
44
|
return (
|
|
107
45
|
<div>
|
|
108
|
-
<
|
|
109
|
-
<
|
|
46
|
+
<input value={text} onInput={(e) => (text = e.target.value)} />
|
|
47
|
+
<p>You typed: {text}</p>
|
|
110
48
|
</div>
|
|
111
49
|
);
|
|
112
50
|
}
|
|
113
51
|
```
|
|
114
52
|
|
|
115
|
-
|
|
53
|
+
Assignments work naturally: `text = 'hello'`, `count++`, `items.push(item)`.
|
|
116
54
|
|
|
117
|
-
### `const`
|
|
55
|
+
### Derived State: `const`
|
|
118
56
|
|
|
119
|
-
A `const` that
|
|
57
|
+
A `const` that reads a reactive variable becomes computed (cached, lazy):
|
|
120
58
|
|
|
121
59
|
```tsx
|
|
122
|
-
|
|
123
|
-
function Pricing() {
|
|
60
|
+
function Cart() {
|
|
124
61
|
let quantity = 1;
|
|
125
|
-
|
|
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
|
-
```
|
|
62
|
+
let price = 10;
|
|
139
63
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
Destructuring also works:
|
|
143
|
-
|
|
144
|
-
```tsx
|
|
145
|
-
function Profile() {
|
|
146
|
-
let user = { name: 'Alice', age: 30 };
|
|
147
|
-
const { name, age } = user;
|
|
64
|
+
const total = quantity * price;
|
|
65
|
+
const formatted = `$${total}`;
|
|
148
66
|
|
|
149
|
-
return <
|
|
67
|
+
return <p>Total: {formatted}</p>;
|
|
150
68
|
}
|
|
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
69
|
```
|
|
164
70
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
```tsx
|
|
168
|
-
const title = "Hello";
|
|
169
|
-
<span>{title}</span>
|
|
170
|
-
// becomes: document.createTextNode(title)
|
|
171
|
-
```
|
|
71
|
+
`total` only recalculates when `quantity` or `price` change.
|
|
172
72
|
|
|
173
|
-
###
|
|
73
|
+
### Components
|
|
174
74
|
|
|
175
|
-
|
|
75
|
+
Components are plain functions returning DOM:
|
|
176
76
|
|
|
177
77
|
```tsx
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return (
|
|
182
|
-
<div className={isActive ? 'active' : 'inactive'}>
|
|
183
|
-
<button onClick={() => isActive = !isActive}>Toggle</button>
|
|
184
|
-
</div>
|
|
185
|
-
);
|
|
78
|
+
function Greeting({ name }) {
|
|
79
|
+
return <h1>Hello, {name}!</h1>;
|
|
186
80
|
}
|
|
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
81
|
|
|
194
|
-
```tsx
|
|
195
82
|
function App() {
|
|
196
|
-
let
|
|
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
|
-
}
|
|
83
|
+
let name = 'World';
|
|
248
84
|
|
|
249
|
-
// DO -- declarative JSX (let the compiler handle reactivity)
|
|
250
|
-
function Counter() {
|
|
251
|
-
let count = 0;
|
|
252
85
|
return (
|
|
253
86
|
<div>
|
|
254
|
-
<
|
|
255
|
-
<button onClick={() =>
|
|
87
|
+
<Greeting name={name} />
|
|
88
|
+
<button onClick={() => (name = 'Alice')}>Change Name</button>
|
|
256
89
|
</div>
|
|
257
90
|
);
|
|
258
91
|
}
|
|
259
92
|
```
|
|
260
93
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
---
|
|
264
|
-
|
|
265
|
-
## Components
|
|
266
|
-
|
|
267
|
-
Components are functions that return `HTMLElement` (or `Node`). There is no component class, no `render()` method.
|
|
94
|
+
### Mounting
|
|
268
95
|
|
|
269
|
-
|
|
96
|
+
Mount your app to the DOM with `mount()`:
|
|
270
97
|
|
|
271
98
|
```tsx
|
|
272
|
-
|
|
273
|
-
return <h1>Hello</h1>;
|
|
274
|
-
}
|
|
99
|
+
import { mount } from '@vertz/ui';
|
|
275
100
|
|
|
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
101
|
function App() {
|
|
301
|
-
let
|
|
302
|
-
return (
|
|
303
|
-
<div>
|
|
304
|
-
<Card title="Score" count={() => n} />
|
|
305
|
-
<button onClick={() => n++}>+</button>
|
|
306
|
-
</div>
|
|
307
|
-
);
|
|
102
|
+
let count = 0;
|
|
103
|
+
return <button onClick={() => count++}>Count: {count}</button>;
|
|
308
104
|
}
|
|
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
105
|
|
|
313
|
-
|
|
106
|
+
const { unmount, root } = mount(App, '#app');
|
|
107
|
+
```
|
|
314
108
|
|
|
315
|
-
|
|
109
|
+
**With options:**
|
|
316
110
|
|
|
317
111
|
```tsx
|
|
318
|
-
import {
|
|
112
|
+
import { mount } from '@vertz/ui';
|
|
113
|
+
import { defineTheme } from '@vertz/ui/css';
|
|
319
114
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
115
|
+
const theme = defineTheme({
|
|
116
|
+
colors: { primary: { 500: '#3b82f6' } },
|
|
117
|
+
});
|
|
323
118
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
return el;
|
|
331
|
-
}
|
|
119
|
+
mount(App, '#app', {
|
|
120
|
+
theme,
|
|
121
|
+
styles: ['body { margin: 0; }'],
|
|
122
|
+
onMount: (root) => console.log('Mounted to', root),
|
|
123
|
+
});
|
|
332
124
|
```
|
|
333
125
|
|
|
334
|
-
|
|
126
|
+
`mount(app, selector, options?)` accepts:
|
|
335
127
|
|
|
336
|
-
|
|
128
|
+
- `selector` — CSS selector string or `HTMLElement`
|
|
129
|
+
- `options.theme` — theme definition for CSS vars
|
|
130
|
+
- `options.styles` — global CSS strings to inject
|
|
131
|
+
- `options.hydration` — `'replace'` (default) or `false`
|
|
132
|
+
- `options.registry` — component registry for per-component hydration
|
|
133
|
+
- `options.onMount` — callback after mount completes
|
|
337
134
|
|
|
338
|
-
|
|
339
|
-
function App() {
|
|
340
|
-
return (
|
|
341
|
-
<div>
|
|
342
|
-
{Header()}
|
|
343
|
-
{MainContent()}
|
|
344
|
-
{Footer()}
|
|
345
|
-
</div>
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
---
|
|
135
|
+
Returns a `MountHandle` with `unmount()` and `root`.
|
|
351
136
|
|
|
352
|
-
|
|
137
|
+
### Lifecycle: `onMount`
|
|
353
138
|
|
|
354
|
-
|
|
139
|
+
Run code once when the component is created:
|
|
355
140
|
|
|
356
141
|
```tsx
|
|
357
|
-
|
|
358
|
-
let show = false;
|
|
142
|
+
import { onMount } from '@vertz/ui';
|
|
359
143
|
|
|
360
|
-
|
|
361
|
-
|
|
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:
|
|
144
|
+
function Timer() {
|
|
145
|
+
let seconds = 0;
|
|
370
146
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
147
|
+
onMount(() => {
|
|
148
|
+
const id = setInterval(() => seconds++, 1000);
|
|
149
|
+
return () => clearInterval(id);
|
|
150
|
+
});
|
|
374
151
|
|
|
375
|
-
return
|
|
376
|
-
<div>
|
|
377
|
-
{online ? <span className="green">Online</span> : <span className="gray">Offline</span>}
|
|
378
|
-
</div>
|
|
379
|
-
);
|
|
152
|
+
return <p>{seconds}s</p>;
|
|
380
153
|
}
|
|
381
154
|
```
|
|
382
155
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
### Manual Control
|
|
156
|
+
### Data Fetching: `query`
|
|
386
157
|
|
|
387
|
-
|
|
158
|
+
Fetch data reactively:
|
|
388
159
|
|
|
389
160
|
```tsx
|
|
390
|
-
import {
|
|
161
|
+
import { query } from '@vertz/ui';
|
|
391
162
|
|
|
392
|
-
function
|
|
393
|
-
let
|
|
163
|
+
function UserProfile() {
|
|
164
|
+
let userId = 1;
|
|
394
165
|
|
|
395
|
-
|
|
396
|
-
|
|
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>
|
|
166
|
+
const { data, loading } = query(() =>
|
|
167
|
+
fetch(`/api/users/${userId}`).then((r) => r.json())
|
|
404
168
|
);
|
|
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
169
|
|
|
427
170
|
return (
|
|
428
171
|
<div>
|
|
429
|
-
<
|
|
430
|
-
|
|
431
|
-
</ul>
|
|
432
|
-
<button onClick={() => {
|
|
433
|
-
todos = [...todos, { id: String(Date.now()), text: 'New todo' }];
|
|
434
|
-
}}>
|
|
435
|
-
Add
|
|
436
|
-
</button>
|
|
172
|
+
{loading.value ? 'Loading...' : <p>{data.value?.name}</p>}
|
|
173
|
+
<button onClick={() => userId++}>Next User</button>
|
|
437
174
|
</div>
|
|
438
175
|
);
|
|
439
176
|
}
|
|
440
177
|
```
|
|
441
178
|
|
|
442
|
-
|
|
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
|
-
```
|
|
179
|
+
---
|
|
479
180
|
|
|
480
|
-
|
|
181
|
+
## You're Done (Probably)
|
|
481
182
|
|
|
482
|
-
|
|
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)
|
|
183
|
+
**90% of apps only need the above.** The rest is for special cases.
|
|
486
184
|
|
|
487
185
|
---
|
|
488
186
|
|
|
489
187
|
## Styling
|
|
490
188
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
### `css()` -- Scoped Style Blocks
|
|
189
|
+
### `css` — Scoped Styles
|
|
494
190
|
|
|
495
191
|
```tsx
|
|
496
192
|
import { css } from '@vertz/ui/css';
|
|
497
193
|
|
|
498
194
|
const styles = css({
|
|
499
|
-
card: ['p:4', 'bg:
|
|
500
|
-
title: ['font:xl', 'weight:bold', '
|
|
195
|
+
card: ['p:4', 'bg:white', 'rounded:lg', 'shadow:md'],
|
|
196
|
+
title: ['font:xl', 'weight:bold', 'mb:2'],
|
|
501
197
|
});
|
|
502
198
|
|
|
503
|
-
function Card() {
|
|
199
|
+
function Card({ title, children }) {
|
|
504
200
|
return (
|
|
505
|
-
<div className={styles.
|
|
506
|
-
<h2 className={styles.
|
|
201
|
+
<div className={styles.card}>
|
|
202
|
+
<h2 className={styles.title}>{title}</h2>
|
|
203
|
+
{children}
|
|
507
204
|
</div>
|
|
508
205
|
);
|
|
509
206
|
}
|
|
510
207
|
```
|
|
511
208
|
|
|
512
|
-
|
|
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
|
|
209
|
+
### `variants` — Typed Variants
|
|
532
210
|
|
|
533
211
|
```tsx
|
|
534
212
|
import { variants } from '@vertz/ui/css';
|
|
535
213
|
|
|
536
214
|
const button = variants({
|
|
537
|
-
base: ['
|
|
215
|
+
base: ['px:4', 'py:2', 'rounded:md', 'font:medium'],
|
|
538
216
|
variants: {
|
|
539
217
|
intent: {
|
|
540
|
-
primary: ['bg:
|
|
541
|
-
secondary: ['bg:
|
|
218
|
+
primary: ['bg:blue.600', 'text:white'],
|
|
219
|
+
secondary: ['bg:gray.100', 'text:gray.800'],
|
|
542
220
|
},
|
|
543
221
|
size: {
|
|
544
|
-
sm: ['
|
|
545
|
-
|
|
546
|
-
lg: ['text:base', 'h:12'],
|
|
222
|
+
sm: ['px:2', 'py:1', 'text:sm'],
|
|
223
|
+
lg: ['px:6', 'py:3', 'text:lg'],
|
|
547
224
|
},
|
|
548
225
|
},
|
|
549
226
|
defaultVariants: { intent: 'primary', size: 'md' },
|
|
550
|
-
compoundVariants: [
|
|
551
|
-
{ intent: 'primary', size: 'sm', styles: ['px:2'] },
|
|
552
|
-
],
|
|
553
227
|
});
|
|
554
228
|
|
|
555
|
-
|
|
556
|
-
button({ intent
|
|
557
|
-
|
|
229
|
+
function Button({ intent, size, children }) {
|
|
230
|
+
return <button className={button({ intent, size })}>{children}</button>;
|
|
231
|
+
}
|
|
558
232
|
```
|
|
559
233
|
|
|
560
|
-
|
|
234
|
+
### `s` — Inline Dynamic Styles
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { s } from '@vertz/ui/css';
|
|
238
|
+
|
|
239
|
+
function ProgressBar({ percent }) {
|
|
240
|
+
return <div style={s([`w:${percent}%`, 'bg:green.500', 'h:4'])} />;
|
|
241
|
+
}
|
|
242
|
+
```
|
|
561
243
|
|
|
562
|
-
###
|
|
244
|
+
### Theming
|
|
563
245
|
|
|
564
246
|
```tsx
|
|
565
247
|
import { defineTheme, compileTheme, ThemeProvider } from '@vertz/ui/css';
|
|
566
248
|
|
|
567
249
|
const theme = defineTheme({
|
|
568
250
|
colors: {
|
|
569
|
-
primary: { 500: '#3b82f6', 600: '#2563eb'
|
|
251
|
+
primary: { 500: '#3b82f6', 600: '#2563eb' },
|
|
570
252
|
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
253
|
},
|
|
579
254
|
});
|
|
580
255
|
|
|
581
|
-
// Generate CSS custom properties:
|
|
582
256
|
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
257
|
|
|
599
|
-
|
|
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
|
-
});
|
|
258
|
+
ThemeProvider({ theme: 'dark', children: [<App />] });
|
|
612
259
|
```
|
|
613
260
|
|
|
614
|
-
|
|
261
|
+
---
|
|
615
262
|
|
|
616
|
-
|
|
263
|
+
## Forms
|
|
617
264
|
|
|
618
|
-
|
|
265
|
+
Bind forms to server actions with type-safe validation:
|
|
619
266
|
|
|
620
267
|
```tsx
|
|
621
|
-
import {
|
|
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.
|
|
268
|
+
import { form } from '@vertz/ui';
|
|
629
269
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
270
|
+
const createUser = Object.assign(
|
|
271
|
+
async (body: { name: string; email: string }) => {
|
|
272
|
+
const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(body) });
|
|
273
|
+
return res.json() as Promise<{ id: string }>;
|
|
274
|
+
},
|
|
275
|
+
{ url: '/api/users', method: 'POST' }
|
|
276
|
+
);
|
|
633
277
|
|
|
634
|
-
|
|
278
|
+
const userSchema = { /* validation schema */ };
|
|
635
279
|
|
|
636
|
-
|
|
637
|
-
|
|
280
|
+
function CreateUser() {
|
|
281
|
+
const f = form(createUser, {
|
|
282
|
+
schema: userSchema,
|
|
283
|
+
onSuccess: (result) => console.log('User created:', result.id),
|
|
284
|
+
});
|
|
638
285
|
|
|
639
|
-
|
|
640
|
-
|
|
286
|
+
return (
|
|
287
|
+
<form action={f.action} method={f.method} onSubmit={f.onSubmit}>
|
|
288
|
+
<input name="name" placeholder="Name" />
|
|
289
|
+
{f.name.error && <span class="error">{f.name.error}</span>}
|
|
641
290
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
);
|
|
291
|
+
<input name="email" type="email" placeholder="Email" />
|
|
292
|
+
{f.email.error && <span class="error">{f.email.error}</span>}
|
|
645
293
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
<span>{data.value?.name}</span>
|
|
651
|
-
<button onClick={() => userId++}>Next User</button>
|
|
652
|
-
<button onClick={refetch}>Refresh</button>
|
|
653
|
-
</div>
|
|
294
|
+
<button type="submit" disabled={f.submitting}>
|
|
295
|
+
{f.submitting.value ? 'Creating...' : 'Create User'}
|
|
296
|
+
</button>
|
|
297
|
+
</form>
|
|
654
298
|
);
|
|
655
299
|
}
|
|
656
300
|
```
|
|
657
301
|
|
|
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
302
|
---
|
|
684
303
|
|
|
685
304
|
## Routing
|
|
686
305
|
|
|
687
|
-
Import from `@vertz/ui/router` or the main `@vertz/ui` export.
|
|
688
|
-
|
|
689
|
-
### Define Routes
|
|
690
|
-
|
|
691
306
|
```tsx
|
|
692
|
-
import { defineRoutes, createRouter, createLink
|
|
307
|
+
import { defineRoutes, createRouter, createLink } from '@vertz/ui/router';
|
|
693
308
|
|
|
694
309
|
const routes = defineRoutes({
|
|
695
|
-
'/': {
|
|
696
|
-
component: () => HomePage(),
|
|
697
|
-
},
|
|
310
|
+
'/': { component: () => <HomePage /> },
|
|
698
311
|
'/users/:id': {
|
|
699
|
-
component: () => UserPage
|
|
700
|
-
loader: async ({ params
|
|
701
|
-
const res = await fetch(`/api/users/${params.id}
|
|
312
|
+
component: () => <UserPage />,
|
|
313
|
+
loader: async ({ params }) => {
|
|
314
|
+
const res = await fetch(`/api/users/${params.id}`);
|
|
702
315
|
return res.json();
|
|
703
316
|
},
|
|
704
|
-
errorComponent: (error) => <div>Failed: {error.message}</div>,
|
|
705
|
-
},
|
|
706
|
-
'/about': {
|
|
707
|
-
component: () => AboutPage(),
|
|
708
317
|
},
|
|
709
318
|
});
|
|
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
319
|
|
|
730
|
-
|
|
731
|
-
router.
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
### Link Component
|
|
735
|
-
|
|
736
|
-
```tsx
|
|
737
|
-
const Link = createLink(router.current, (url) => router.navigate(url));
|
|
320
|
+
const router = createRouter(routes);
|
|
321
|
+
const Link = createLink(router.current, router.navigate);
|
|
738
322
|
|
|
739
323
|
function Nav() {
|
|
740
324
|
return (
|
|
741
325
|
<nav>
|
|
742
|
-
|
|
743
|
-
|
|
326
|
+
<Link href="/">Home</Link>
|
|
327
|
+
<Link href="/users/1">User 1</Link>
|
|
744
328
|
</nav>
|
|
745
329
|
);
|
|
746
330
|
}
|
|
747
331
|
```
|
|
748
332
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
### Nested Routes and Outlets
|
|
333
|
+
---
|
|
752
334
|
|
|
753
|
-
|
|
754
|
-
import { createContext } from '@vertz/ui';
|
|
755
|
-
import { createOutlet, type OutletContext } from '@vertz/ui/router';
|
|
335
|
+
## Context
|
|
756
336
|
|
|
757
|
-
|
|
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
|
|
337
|
+
Share values without passing props:
|
|
776
338
|
|
|
777
339
|
```tsx
|
|
778
|
-
import {
|
|
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
|
-
```
|
|
340
|
+
import { createContext, useContext } from '@vertz/ui';
|
|
786
341
|
|
|
787
|
-
|
|
342
|
+
const ThemeContext = createContext<'light' | 'dark'>('light');
|
|
788
343
|
|
|
789
|
-
|
|
344
|
+
function App() {
|
|
345
|
+
let theme = 'light';
|
|
790
346
|
|
|
791
|
-
|
|
792
|
-
|
|
347
|
+
return (
|
|
348
|
+
<ThemeContext.Provider value={theme}>
|
|
349
|
+
<ThemeToggle />
|
|
350
|
+
</ThemeContext.Provider>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
793
353
|
|
|
794
|
-
|
|
795
|
-
|
|
354
|
+
function ThemeToggle() {
|
|
355
|
+
const theme = useContext(ThemeContext);
|
|
356
|
+
return <p>Current theme: {theme}</p>;
|
|
357
|
+
}
|
|
796
358
|
```
|
|
797
359
|
|
|
798
360
|
---
|
|
799
361
|
|
|
800
|
-
##
|
|
801
|
-
|
|
802
|
-
Import from `@vertz/ui/form` or the main `@vertz/ui` export.
|
|
362
|
+
## Error Handling
|
|
803
363
|
|
|
804
364
|
```tsx
|
|
805
|
-
import {
|
|
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
|
-
});
|
|
365
|
+
import { ErrorBoundary } from '@vertz/ui';
|
|
814
366
|
|
|
815
|
-
function
|
|
367
|
+
function App() {
|
|
816
368
|
return (
|
|
817
|
-
<
|
|
818
|
-
{
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
369
|
+
<ErrorBoundary
|
|
370
|
+
fallback={(error, retry) => (
|
|
371
|
+
<div>
|
|
372
|
+
<p>Error: {error.message}</p>
|
|
373
|
+
<button onClick={retry}>Retry</button>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
823
376
|
>
|
|
824
|
-
<
|
|
825
|
-
|
|
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>
|
|
377
|
+
{() => <RiskyComponent />}
|
|
378
|
+
</ErrorBoundary>
|
|
834
379
|
);
|
|
835
380
|
}
|
|
836
381
|
```
|
|
837
382
|
|
|
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
383
|
---
|
|
849
384
|
|
|
850
|
-
##
|
|
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.
|
|
385
|
+
## Advanced
|
|
886
386
|
|
|
887
|
-
###
|
|
387
|
+
### Watch
|
|
888
388
|
|
|
889
|
-
|
|
389
|
+
Watch a dependency and run a callback when it changes:
|
|
890
390
|
|
|
891
391
|
```tsx
|
|
892
|
-
import { watch
|
|
392
|
+
import { watch } from '@vertz/ui';
|
|
893
393
|
|
|
894
394
|
function Logger() {
|
|
895
395
|
let count = 0;
|
|
896
396
|
|
|
897
397
|
watch(
|
|
898
398
|
() => 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
|
-
}
|
|
399
|
+
(value) => console.log('count changed to', value)
|
|
904
400
|
);
|
|
905
401
|
|
|
906
|
-
return <button onClick={() => count++}
|
|
402
|
+
return <button onClick={() => count++}>Increment</button>;
|
|
907
403
|
}
|
|
908
404
|
```
|
|
909
405
|
|
|
910
|
-
|
|
406
|
+
### Refs
|
|
911
407
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
Access a DOM element after creation:
|
|
408
|
+
Access DOM elements after mount:
|
|
915
409
|
|
|
916
410
|
```tsx
|
|
917
411
|
import { ref, onMount } from '@vertz/ui';
|
|
918
412
|
|
|
919
|
-
function
|
|
413
|
+
function AutoFocus() {
|
|
920
414
|
const inputRef = ref<HTMLInputElement>();
|
|
921
415
|
|
|
922
416
|
onMount(() => {
|
|
923
417
|
inputRef.current?.focus();
|
|
924
418
|
});
|
|
925
419
|
|
|
926
|
-
|
|
927
|
-
const el = <input /> as HTMLInputElement;
|
|
928
|
-
inputRef.current = el;
|
|
929
|
-
|
|
930
|
-
return el;
|
|
420
|
+
return <input ref={inputRef} placeholder="Auto-focused" />;
|
|
931
421
|
}
|
|
932
422
|
```
|
|
933
423
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
Share values down the component tree without prop-drilling:
|
|
424
|
+
---
|
|
937
425
|
|
|
938
|
-
|
|
939
|
-
import { createContext, useContext } from '@vertz/ui';
|
|
426
|
+
## Testing
|
|
940
427
|
|
|
941
|
-
|
|
428
|
+
Import from `@vertz/ui/test`:
|
|
942
429
|
|
|
943
|
-
|
|
944
|
-
|
|
430
|
+
```tsx
|
|
431
|
+
import { renderTest, findByText, click, waitFor } from '@vertz/ui/test';
|
|
945
432
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
});
|
|
433
|
+
// Mount a component for testing
|
|
434
|
+
const { container, findByText, click, unmount } = renderTest(<Counter />);
|
|
949
435
|
|
|
950
|
-
|
|
951
|
-
|
|
436
|
+
// Query the DOM
|
|
437
|
+
const button = findByText('Increment');
|
|
438
|
+
await click(button);
|
|
439
|
+
const label = findByText('Count: 1');
|
|
952
440
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
return <div className={theme === 'dark' ? 'card-dark' : 'card-light'}>Themed</div>;
|
|
956
|
-
}
|
|
441
|
+
// Clean up
|
|
442
|
+
unmount();
|
|
957
443
|
```
|
|
958
444
|
|
|
959
|
-
|
|
445
|
+
### Query Helpers
|
|
960
446
|
|
|
961
|
-
|
|
447
|
+
| Export | Description |
|
|
448
|
+
|---|---|
|
|
449
|
+
| `findByTestId(id)` | Find element by `data-testid` — throws if not found |
|
|
450
|
+
| `findByText(text)` | Find element by text content — throws if not found |
|
|
451
|
+
| `queryByTestId(id)` | Find element by `data-testid` — returns `null` if not found |
|
|
452
|
+
| `queryByText(text)` | Find element by text content — returns `null` if not found |
|
|
453
|
+
| `waitFor(fn, options?)` | Retry an assertion until it passes |
|
|
962
454
|
|
|
963
|
-
|
|
455
|
+
### Interaction Helpers
|
|
964
456
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
<div>
|
|
973
|
-
<p>Something broke: {error.message}</p>
|
|
974
|
-
<button onClick={retry}>Retry</button>
|
|
975
|
-
</div>
|
|
976
|
-
),
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
```
|
|
457
|
+
| Export | Description |
|
|
458
|
+
|---|---|
|
|
459
|
+
| `click(el)` | Simulate a click event |
|
|
460
|
+
| `type(el, text)` | Simulate typing into an input |
|
|
461
|
+
| `press(key)` | Simulate a key press |
|
|
462
|
+
| `fillForm(form, values)` | Fill multiple form fields |
|
|
463
|
+
| `submitForm(form)` | Submit a form |
|
|
980
464
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
### Suspense
|
|
984
|
-
|
|
985
|
-
Handle async boundaries (components that throw promises):
|
|
465
|
+
### Route Testing
|
|
986
466
|
|
|
987
467
|
```tsx
|
|
988
|
-
import {
|
|
468
|
+
import { createTestRouter } from '@vertz/ui/test';
|
|
989
469
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
470
|
+
const { component, router, navigate } = await createTestRouter(
|
|
471
|
+
{
|
|
472
|
+
'/': { component: () => <Home /> },
|
|
473
|
+
'/about': { component: () => <About /> },
|
|
474
|
+
},
|
|
475
|
+
{ initialPath: '/' }
|
|
476
|
+
);
|
|
997
477
|
|
|
998
|
-
|
|
478
|
+
await navigate('/about');
|
|
479
|
+
```
|
|
999
480
|
|
|
1000
481
|
---
|
|
1001
482
|
|
|
1002
|
-
##
|
|
483
|
+
## JSX Runtime
|
|
1003
484
|
|
|
1004
|
-
`@vertz/
|
|
485
|
+
The `@vertz/ui/jsx-runtime` subpath provides the JSX factory used by the compiler. This is configured automatically by the Bun plugin — you don't need to set it up manually.
|
|
1005
486
|
|
|
1006
|
-
|
|
1007
|
-
npm install @vertz/primitives
|
|
1008
|
-
```
|
|
487
|
+
---
|
|
1009
488
|
|
|
1010
|
-
|
|
489
|
+
## Gotchas
|
|
1011
490
|
|
|
1012
|
-
###
|
|
491
|
+
### `onMount` runs synchronously
|
|
1013
492
|
|
|
1014
|
-
|
|
493
|
+
`onMount` fires during component initialization, not after DOM insertion. This means the DOM node exists but may not be in the document yet. If you need to measure layout or interact with the painted DOM, use `requestAnimationFrame` inside `onMount`:
|
|
1015
494
|
|
|
1016
495
|
```tsx
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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!'),
|
|
496
|
+
onMount(() => {
|
|
497
|
+
// DOM node exists but may not be painted yet
|
|
498
|
+
requestAnimationFrame(() => {
|
|
499
|
+
// Now it's safe to measure layout
|
|
1028
500
|
});
|
|
1029
|
-
|
|
1030
|
-
root.textContent = 'Click me';
|
|
1031
|
-
root.classList.add(styles.classNames.btn);
|
|
1032
|
-
|
|
1033
|
-
return root;
|
|
1034
|
-
}
|
|
501
|
+
});
|
|
1035
502
|
```
|
|
1036
503
|
|
|
1037
|
-
|
|
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
|
-
---
|
|
504
|
+
### Cleanup uses the return-callback pattern
|
|
1046
505
|
|
|
1047
|
-
|
|
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
|
|
506
|
+
Register cleanup logic by returning a function from `onMount`. This runs when the component unmounts:
|
|
1052
507
|
|
|
1053
508
|
```tsx
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
}
|
|
509
|
+
onMount(() => {
|
|
510
|
+
const id = setInterval(() => seconds++, 1000);
|
|
511
|
+
return () => clearInterval(id);
|
|
512
|
+
});
|
|
1079
513
|
```
|
|
1080
514
|
|
|
1081
|
-
###
|
|
515
|
+
### Primitives are uncontrolled only
|
|
1082
516
|
|
|
1083
|
-
|
|
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
|
-
```
|
|
517
|
+
`@vertz/ui-primitives` components (Dialog, Select, Tabs, etc.) currently only support uncontrolled mode with `defaultValue` + callbacks. Controlled mode (where a parent prop overrides internal state) is not yet supported.
|
|
1100
518
|
|
|
1101
|
-
|
|
519
|
+
### Popover has no focus trap
|
|
1102
520
|
|
|
1103
|
-
|
|
1104
|
-
const dispose = effect(() => {
|
|
1105
|
-
console.log('count is', count);
|
|
1106
|
-
});
|
|
521
|
+
`Popover` focuses the first element on open but does not trap focus. Tab will move focus outside the popover. This is correct for non-modal popovers (tooltips, menus), but if you need modal behavior with a focus trap, use `Dialog` instead.
|
|
1107
522
|
|
|
1108
|
-
|
|
1109
|
-
dispose();
|
|
1110
|
-
```
|
|
523
|
+
---
|
|
1111
524
|
|
|
1112
|
-
|
|
525
|
+
## What You Don't Need to Know
|
|
1113
526
|
|
|
1114
|
-
|
|
527
|
+
- How the compiler transforms your code
|
|
528
|
+
- Internal signal implementation details
|
|
529
|
+
- The reactive graph structure
|
|
530
|
+
- How dependency tracking works under the hood
|
|
1115
531
|
|
|
1116
|
-
|
|
1117
|
-
import { batch } from '@vertz/ui';
|
|
532
|
+
**Write ordinary JavaScript. The compiler handles the rest.**
|
|
1118
533
|
|
|
1119
|
-
|
|
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
|
-
```
|
|
534
|
+
---
|
|
1126
535
|
|
|
1127
|
-
|
|
536
|
+
## API Reference
|
|
537
|
+
|
|
538
|
+
### Lifecycle
|
|
539
|
+
|
|
540
|
+
| Export | Description |
|
|
541
|
+
|---|---|
|
|
542
|
+
| `onMount` | Run code once when a component mounts (return a function for cleanup) |
|
|
543
|
+
| `watch` | Watch a dependency and run a callback on change |
|
|
544
|
+
|
|
545
|
+
### Components
|
|
546
|
+
|
|
547
|
+
| Export | Description |
|
|
548
|
+
|---|---|
|
|
549
|
+
| `createContext` | Create a context for dependency injection |
|
|
550
|
+
| `useContext` | Read a context value |
|
|
551
|
+
| `children` | Access resolved children |
|
|
552
|
+
| `ref` | Create a ref for DOM element access |
|
|
553
|
+
| `ErrorBoundary` | Catch errors in a component tree |
|
|
554
|
+
| `Suspense` | Show fallback while async content loads |
|
|
555
|
+
|
|
556
|
+
### Mounting
|
|
557
|
+
|
|
558
|
+
| Export | Description |
|
|
559
|
+
|---|---|
|
|
560
|
+
| `mount` | Mount an app to a DOM element |
|
|
561
|
+
|
|
562
|
+
### CSS (`@vertz/ui/css`)
|
|
563
|
+
|
|
564
|
+
| Export | Description |
|
|
565
|
+
|---|---|
|
|
566
|
+
| `css` | Create scoped styles |
|
|
567
|
+
| `variants` | Create typed variant styles |
|
|
568
|
+
| `s` | Inline dynamic styles |
|
|
569
|
+
| `defineTheme` | Define a theme |
|
|
570
|
+
| `compileTheme` | Compile a theme to CSS |
|
|
571
|
+
| `ThemeProvider` | Provide a theme to descendants |
|
|
572
|
+
| `globalCss` | Inject global CSS |
|
|
573
|
+
|
|
574
|
+
### Forms
|
|
575
|
+
|
|
576
|
+
| Export | Description |
|
|
577
|
+
|---|---|
|
|
578
|
+
| `form` | Create a form bound to an SDK method |
|
|
579
|
+
| `formDataToObject` | Convert FormData to a plain object |
|
|
580
|
+
| `validate` | Run schema validation |
|
|
581
|
+
|
|
582
|
+
### Data
|
|
583
|
+
|
|
584
|
+
| Export | Description |
|
|
585
|
+
|---|---|
|
|
586
|
+
| `query` | Reactive data fetching |
|
|
587
|
+
|
|
588
|
+
### Routing (`@vertz/ui/router`)
|
|
589
|
+
|
|
590
|
+
| Export | Description |
|
|
591
|
+
|---|---|
|
|
592
|
+
| `defineRoutes` | Define route configuration |
|
|
593
|
+
| `createRouter` | Create a router instance |
|
|
594
|
+
| `createLink` | Create a `<Link>` component |
|
|
595
|
+
| `createOutlet` | Create a route outlet |
|
|
596
|
+
| `parseSearchParams` | Parse URL search parameters |
|
|
597
|
+
| `useSearchParams` | Reactive search parameters |
|
|
598
|
+
|
|
599
|
+
### Testing (`@vertz/ui/test`)
|
|
600
|
+
|
|
601
|
+
| Export | Description |
|
|
602
|
+
|---|---|
|
|
603
|
+
| `renderTest` | Mount a component for testing |
|
|
604
|
+
| `findByTestId` | Find element by `data-testid` (throws) |
|
|
605
|
+
| `findByText` | Find element by text content (throws) |
|
|
606
|
+
| `queryByTestId` | Find element by `data-testid` (nullable) |
|
|
607
|
+
| `queryByText` | Find element by text content (nullable) |
|
|
608
|
+
| `waitFor` | Retry an assertion until it passes |
|
|
609
|
+
| `click` | Simulate a click |
|
|
610
|
+
| `type` | Simulate typing |
|
|
611
|
+
| `press` | Simulate a key press |
|
|
612
|
+
| `fillForm` | Fill multiple form fields |
|
|
613
|
+
| `submitForm` | Submit a form |
|
|
614
|
+
| `createTestRouter` | Create a router for testing |
|
|
1128
615
|
|
|
1129
|
-
|
|
616
|
+
---
|
|
1130
617
|
|
|
1131
|
-
|
|
1132
|
-
import { untrack } from '@vertz/ui';
|
|
618
|
+
## License
|
|
1133
619
|
|
|
1134
|
-
|
|
1135
|
-
const tracked = count; // subscribes to count
|
|
1136
|
-
const notTracked = untrack(() => other); // reads other without subscribing
|
|
1137
|
-
});
|
|
1138
|
-
```
|
|
620
|
+
MIT
|