objs-core 1.1.1 → 2.0.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/EXAMPLES.md +1637 -0
- package/README.md +345 -74
- package/SKILL.md +500 -0
- package/objs.built.js +2655 -0
- package/objs.built.min.js +67 -0
- package/objs.d.ts +455 -0
- package/objs.js +3802 -0
- package/package.json +70 -37
- package/objs.1.1.js +0 -1205
- package/objs.1.1.js.zip +0 -0
- package/objs.1.1.min.js +0 -2
- package/objs.1.1.min.js.zip +0 -0
- package/objs.npm.1.1.js +0 -16
package/EXAMPLES.md
ADDED
|
@@ -0,0 +1,1637 @@
|
|
|
1
|
+
# Objs v2.0 — Examples & Architecture Guide
|
|
2
|
+
|
|
3
|
+
All examples work as-is with `<script src="objs.js"></script>`.
|
|
4
|
+
Runnable paste-and-run code: [examples.js](examples.js).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
0. [Framework comparison & migration guide](#0-framework-comparison--migration-guide)
|
|
11
|
+
1. [How render works](#1-how-render-works)
|
|
12
|
+
2. [Single components — atoms](#2-single-components--atoms)
|
|
13
|
+
3. [Nesting & composition — three patterns](#3-nesting--composition)
|
|
14
|
+
4. [Design system architecture](#4-design-system-architecture)
|
|
15
|
+
5. [Real-world examples](#5-real-world-examples)
|
|
16
|
+
6. [React integration](#6-react-integration)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 0. Framework comparison & migration guide
|
|
21
|
+
|
|
22
|
+
Coming from React, Vue, or Solid? This section maps familiar patterns to their Objs equivalents, so you can start writing productive code immediately.
|
|
23
|
+
|
|
24
|
+
### Feature comparison
|
|
25
|
+
|
|
26
|
+
| | React 18 | Vue 3 | Solid | Objs 2.0 |
|
|
27
|
+
|---|---|---|---|---|
|
|
28
|
+
| **Min + gz size** | ~45 kB | ~22 kB | ~7 kB | ~6 kB |
|
|
29
|
+
| **DOM update model** | Virtual DOM diff | Virtual DOM diff | Fine-grained signals | Direct — explicit state calls |
|
|
30
|
+
| **Reactivity** | `useState` / hooks | `ref` / `reactive` | `createSignal` | None — you call update methods |
|
|
31
|
+
| **Component format** | JSX function | SFC `.vue` / `setup()` | JSX function | Plain JS `states` object |
|
|
32
|
+
| **Build step required** | Yes | Yes | Yes | No — works as a `<script>` tag |
|
|
33
|
+
| **TypeScript** | Full generics | Full generics | Full generics | `.d.ts` definitions |
|
|
34
|
+
| **SSR** | React Server / Next.js | Nuxt | SolidStart | Built-in `o.reactRender` |
|
|
35
|
+
| **Routing** | React Router | Vue Router | @solidjs/router | Built-in `o.route` |
|
|
36
|
+
| **State sharing** | Context / Zustand | Pinia | Signals / stores | Plain observer or `o.connectRedux` |
|
|
37
|
+
| **Data fetching** | `useEffect` + fetch | `onMounted` + fetch | `createResource` | `o.newLoader` + `.connect()` |
|
|
38
|
+
| **Testing** | Jest / RTL / Vitest | Vitest | Vitest | Built-in `o.addTest` |
|
|
39
|
+
| **QA selectors** | `data-testid` (manual) | `data-testid` (manual) | `data-testid` (manual) | Auto via `o.autotag` |
|
|
40
|
+
| **Action recording** | Playwright / Cypress | Playwright / Cypress | Playwright / Cypress | Built-in `o.startRecording` |
|
|
41
|
+
| **Dev/prod split** | Manual | Manual | Manual | Built-in `__DEV__` + `build.js` |
|
|
42
|
+
| **Integrates into existing projects** | `createRoot(el)` | `createApp().mount(el)` | `render(() => …, el)` | `.appendInside(el)` |
|
|
43
|
+
|
|
44
|
+
> **Key difference:** React, Vue, and Solid track state automatically and re-render when it changes.
|
|
45
|
+
> Objs does not — you explicitly call `component.stateMethod(data)`, which writes only the changed DOM nodes.
|
|
46
|
+
> This gives you O(1) updates without a reactivity system or virtual DOM.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
### Pattern 1 — Define and mount a component
|
|
51
|
+
|
|
52
|
+
The most basic operation: create a component with initial data and insert it into the page.
|
|
53
|
+
|
|
54
|
+
**React**
|
|
55
|
+
```jsx
|
|
56
|
+
function Badge({ count }) {
|
|
57
|
+
return <span className="badge">{count}</span>;
|
|
58
|
+
}
|
|
59
|
+
ReactDOM.createRoot(document.getElementById('nav')).render(<Badge count={3} />);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Vue 3**
|
|
63
|
+
```vue
|
|
64
|
+
<!-- Badge.vue -->
|
|
65
|
+
<template><span class="badge">{{ count }}</span></template>
|
|
66
|
+
<script setup>
|
|
67
|
+
defineProps(['count']);
|
|
68
|
+
</script>
|
|
69
|
+
```
|
|
70
|
+
```js
|
|
71
|
+
// main.js
|
|
72
|
+
import { createApp } from 'vue';
|
|
73
|
+
import Badge from './Badge.vue';
|
|
74
|
+
createApp(Badge, { count: 3 }).mount('#nav');
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Solid**
|
|
78
|
+
```jsx
|
|
79
|
+
import { render } from 'solid-js/web';
|
|
80
|
+
function Badge(props) {
|
|
81
|
+
return <span class="badge">{props.count}</span>;
|
|
82
|
+
}
|
|
83
|
+
render(() => <Badge count={3} />, document.getElementById('nav'));
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Objs**
|
|
87
|
+
```js
|
|
88
|
+
const badge = o.init({
|
|
89
|
+
name: 'Badge',
|
|
90
|
+
render: ({ count }) => ({ tag: 'span', class: 'badge', html: count }),
|
|
91
|
+
}).render({ count: 3 }).appendInside('#nav');
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### Pattern 2 — Counter (click to increment)
|
|
97
|
+
|
|
98
|
+
State that changes on user interaction.
|
|
99
|
+
|
|
100
|
+
**React**
|
|
101
|
+
```jsx
|
|
102
|
+
function Counter() {
|
|
103
|
+
const [count, setCount] = React.useState(0);
|
|
104
|
+
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Vue 3**
|
|
109
|
+
```vue
|
|
110
|
+
<template><button @click="count++">{{ count }}</button></template>
|
|
111
|
+
<script setup>
|
|
112
|
+
import { ref } from 'vue';
|
|
113
|
+
const count = ref(0);
|
|
114
|
+
</script>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Solid**
|
|
118
|
+
```jsx
|
|
119
|
+
import { createSignal } from 'solid-js';
|
|
120
|
+
function Counter() {
|
|
121
|
+
const [count, setCount] = createSignal(0);
|
|
122
|
+
return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Objs**
|
|
127
|
+
```js
|
|
128
|
+
// State lives in the element, not in a reactive variable
|
|
129
|
+
const counter = o.init({
|
|
130
|
+
name: 'Counter',
|
|
131
|
+
render: { tag: 'button', html: '0' },
|
|
132
|
+
inc: ({ self }) => { self.html(+self.el.textContent + 1); },
|
|
133
|
+
}).render().appendInside('#app');
|
|
134
|
+
|
|
135
|
+
counter.on('click', () => counter.inc());
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> In React/Vue/Solid, the framework schedules a re-render when state changes.
|
|
139
|
+
> In Objs, `inc()` writes `innerHTML` directly — no scheduler, no diff.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### Pattern 3 — Props / parameterized component
|
|
144
|
+
|
|
145
|
+
Pass data in at creation time.
|
|
146
|
+
|
|
147
|
+
**React**
|
|
148
|
+
```jsx
|
|
149
|
+
function Card({ title, price }) {
|
|
150
|
+
return (
|
|
151
|
+
<article>
|
|
152
|
+
<h3>{title}</h3>
|
|
153
|
+
<p className="price">${price}</p>
|
|
154
|
+
</article>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
<Card title="Laptop" price={999} />
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Vue 3**
|
|
161
|
+
```vue
|
|
162
|
+
<template>
|
|
163
|
+
<article>
|
|
164
|
+
<h3>{{ title }}</h3>
|
|
165
|
+
<p class="price">${{ price }}</p>
|
|
166
|
+
</article>
|
|
167
|
+
</template>
|
|
168
|
+
<script setup>
|
|
169
|
+
defineProps(['title', 'price']);
|
|
170
|
+
</script>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Solid**
|
|
174
|
+
```jsx
|
|
175
|
+
function Card(props) {
|
|
176
|
+
return (
|
|
177
|
+
<article>
|
|
178
|
+
<h3>{props.title}</h3>
|
|
179
|
+
<p class="price">${props.price}</p>
|
|
180
|
+
</article>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
<Card title="Laptop" price={999} />
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Objs**
|
|
187
|
+
```js
|
|
188
|
+
const cardStates = {
|
|
189
|
+
name: 'Card',
|
|
190
|
+
render: ({ title, price }) => ({
|
|
191
|
+
tag: 'article',
|
|
192
|
+
html: `<h3>${title}</h3><p class="price">$${price}</p>`,
|
|
193
|
+
}),
|
|
194
|
+
};
|
|
195
|
+
o.init(cardStates).render({ title: 'Laptop', price: 999 }).appendInside('#app');
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
### Pattern 4 — Targeted partial update
|
|
201
|
+
|
|
202
|
+
Update one part of a component without touching the rest.
|
|
203
|
+
|
|
204
|
+
In React/Vue, the framework diffs and patches. In Solid, only the signal's text node updates. In Objs, you explicitly name which DOM node to write to.
|
|
205
|
+
|
|
206
|
+
**React**
|
|
207
|
+
```jsx
|
|
208
|
+
// The whole Card function re-runs; React reconciles the output
|
|
209
|
+
function Card({ title }) {
|
|
210
|
+
const [price, setPrice] = React.useState(999);
|
|
211
|
+
return (
|
|
212
|
+
<article>
|
|
213
|
+
<h3>{title}</h3>
|
|
214
|
+
<p className="price">${price}</p>
|
|
215
|
+
</article>
|
|
216
|
+
);
|
|
217
|
+
// Caller: setPrice(89) — triggers rerender, React diffs and patches <p>
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Vue 3**
|
|
222
|
+
```vue
|
|
223
|
+
<template>
|
|
224
|
+
<article>
|
|
225
|
+
<h3>{{ title }}</h3>
|
|
226
|
+
<p class="price">${{ price }}</p>
|
|
227
|
+
</article>
|
|
228
|
+
</template>
|
|
229
|
+
<script setup>
|
|
230
|
+
import { ref } from 'vue';
|
|
231
|
+
defineProps(['title']);
|
|
232
|
+
const price = ref(999);
|
|
233
|
+
// Caller: price.value = 89 — Vue updates only the <p> text node
|
|
234
|
+
</script>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Solid**
|
|
238
|
+
```jsx
|
|
239
|
+
// Only the text node that reads price() is updated — no component re-run
|
|
240
|
+
function Card(props) {
|
|
241
|
+
const [price, setPrice] = createSignal(999);
|
|
242
|
+
return (
|
|
243
|
+
<article>
|
|
244
|
+
<h3>{props.title}</h3>
|
|
245
|
+
<p class="price">${price()}</p>
|
|
246
|
+
</article>
|
|
247
|
+
);
|
|
248
|
+
// Caller: setPrice(89) — only the text node inside <p> changes
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Objs**
|
|
253
|
+
```js
|
|
254
|
+
const cardStates = {
|
|
255
|
+
name: 'Card',
|
|
256
|
+
render: ({ title, price }) => ({
|
|
257
|
+
tag: 'article',
|
|
258
|
+
html: `<h3>${title}</h3><p class="price">$${price}</p>`,
|
|
259
|
+
}),
|
|
260
|
+
// Direct write — no reactivity system, no scheduler, no diff
|
|
261
|
+
setPrice: ({ self }, p) => { self.first('.price').html(`$${p}`); },
|
|
262
|
+
};
|
|
263
|
+
const card = o.init(cardStates).render({ title: 'Laptop', price: 999 }).appendInside('#app');
|
|
264
|
+
card.setPrice(89); // one innerHTML write, nothing else evaluated
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### Pattern 5 — List rendering
|
|
270
|
+
|
|
271
|
+
Render a collection of items. Handle adding and removing individual items without re-rendering the whole list.
|
|
272
|
+
|
|
273
|
+
**React**
|
|
274
|
+
```jsx
|
|
275
|
+
function ProductList({ products }) {
|
|
276
|
+
return (
|
|
277
|
+
<ul>
|
|
278
|
+
{products.map(p => <li key={p.id}>{p.name}</li>)}
|
|
279
|
+
</ul>
|
|
280
|
+
);
|
|
281
|
+
// When products changes, React re-diffs the entire list
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Vue 3**
|
|
286
|
+
```vue
|
|
287
|
+
<template>
|
|
288
|
+
<ul>
|
|
289
|
+
<li v-for="p in products" :key="p.id">{{ p.name }}</li>
|
|
290
|
+
</ul>
|
|
291
|
+
</template>
|
|
292
|
+
<script setup>
|
|
293
|
+
import { ref } from 'vue';
|
|
294
|
+
const products = ref([]);
|
|
295
|
+
// When products.value changes, Vue patches the list
|
|
296
|
+
</script>
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**Solid**
|
|
300
|
+
```jsx
|
|
301
|
+
import { createSignal } from 'solid-js';
|
|
302
|
+
import { For } from 'solid-js';
|
|
303
|
+
function ProductList() {
|
|
304
|
+
const [products, setProducts] = createSignal([]);
|
|
305
|
+
return (
|
|
306
|
+
<ul>
|
|
307
|
+
<For each={products()}>
|
|
308
|
+
{p => <li>{p.name}</li>}
|
|
309
|
+
</For>
|
|
310
|
+
</ul>
|
|
311
|
+
);
|
|
312
|
+
// For only patches the changed item rows
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Objs**
|
|
317
|
+
```js
|
|
318
|
+
const listStates = {
|
|
319
|
+
name: 'ProductList',
|
|
320
|
+
render: { tag: 'ul' },
|
|
321
|
+
// Initial load — creates all items, stores by ID
|
|
322
|
+
load: ({ self }, products) => {
|
|
323
|
+
self.el.innerHTML = '';
|
|
324
|
+
self.store.items = {};
|
|
325
|
+
products.forEach(p => {
|
|
326
|
+
const item = o.initState({ tag: 'li', html: p.name });
|
|
327
|
+
item.appendInside(self.el);
|
|
328
|
+
self.store.items[p.id] = item;
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
// Update one item — O(1), only that text node changes
|
|
332
|
+
updateName: ({ self }, { id, name }) => { self.store.items[id]?.html(name); },
|
|
333
|
+
// Remove one item — only that node is removed
|
|
334
|
+
remove: ({ self }, id) => { self.store.items[id]?.unmount(); delete self.store.items[id]; },
|
|
335
|
+
// Add one item — no list re-render
|
|
336
|
+
addItem: ({ self }, p) => {
|
|
337
|
+
const item = o.initState({ tag: 'li', html: p.name });
|
|
338
|
+
item.appendInside(self.el);
|
|
339
|
+
self.store.items[p.id] = item;
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
const list = o.init(listStates).render().appendInside('#app');
|
|
343
|
+
list.load(products);
|
|
344
|
+
list.updateName({ id: 42, name: 'New name' }); // one write
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
### Pattern 6 — Conditional rendering (show / hide)
|
|
350
|
+
|
|
351
|
+
Show or hide a field based on a checkbox.
|
|
352
|
+
|
|
353
|
+
**React**
|
|
354
|
+
```jsx
|
|
355
|
+
function Form() {
|
|
356
|
+
const [showCompany, setShowCompany] = React.useState(false);
|
|
357
|
+
return (
|
|
358
|
+
<div>
|
|
359
|
+
<label>
|
|
360
|
+
<input type="checkbox" onChange={e => setShowCompany(e.target.checked)} />
|
|
361
|
+
{' '}Business account
|
|
362
|
+
</label>
|
|
363
|
+
{showCompany && <input name="company" placeholder="Company name" />}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
// React unmounts / remounts the input element when showCompany toggles
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Vue 3**
|
|
371
|
+
```vue
|
|
372
|
+
<template>
|
|
373
|
+
<div>
|
|
374
|
+
<label><input type="checkbox" v-model="show" /> Business account</label>
|
|
375
|
+
<input v-if="show" name="company" placeholder="Company name" />
|
|
376
|
+
</div>
|
|
377
|
+
</template>
|
|
378
|
+
<script setup>
|
|
379
|
+
import { ref } from 'vue';
|
|
380
|
+
const show = ref(false);
|
|
381
|
+
// v-if removes / recreates the DOM node; v-show would toggle display
|
|
382
|
+
</script>
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**Solid**
|
|
386
|
+
```jsx
|
|
387
|
+
import { createSignal, Show } from 'solid-js';
|
|
388
|
+
function Form() {
|
|
389
|
+
const [show, setShow] = createSignal(false);
|
|
390
|
+
return (
|
|
391
|
+
<div>
|
|
392
|
+
<label>
|
|
393
|
+
<input type="checkbox" onChange={e => setShow(e.target.checked)} />
|
|
394
|
+
{' '}Business account
|
|
395
|
+
</label>
|
|
396
|
+
<Show when={show()}>
|
|
397
|
+
<input name="company" placeholder="Company name" />
|
|
398
|
+
</Show>
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**Objs**
|
|
405
|
+
```js
|
|
406
|
+
// The company field is a full atom — show/hide are state methods on it
|
|
407
|
+
const companyField = o.init(FieldStates).render({ name: 'company', label: 'Company name' });
|
|
408
|
+
companyField.hide(); // display:none on the atom's root element
|
|
409
|
+
|
|
410
|
+
const bizBox = o.initState({
|
|
411
|
+
tag: 'label', class: 'field',
|
|
412
|
+
html: '<input type="checkbox" class="biz-check"> Business account',
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
bizBox.first('.biz-check').on('change', e => {
|
|
416
|
+
e.target.checked ? companyField.show() : companyField.hide();
|
|
417
|
+
});
|
|
418
|
+
// DOM node is never removed — toggled with display:none/''
|
|
419
|
+
// To fully unmount and remount use .unmount() / .appendInside()
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
### Pattern 7 — Shared state between distant components
|
|
425
|
+
|
|
426
|
+
A cart badge in the header and an "Add to cart" button deep in a product card both need to read from and write to the same count.
|
|
427
|
+
|
|
428
|
+
**React** — lift state up / Context
|
|
429
|
+
```jsx
|
|
430
|
+
const CartContext = React.createContext(null);
|
|
431
|
+
|
|
432
|
+
function App() {
|
|
433
|
+
const [count, setCount] = React.useState(0);
|
|
434
|
+
const addItem = () => setCount(c => c + 1);
|
|
435
|
+
return (
|
|
436
|
+
<CartContext.Provider value={{ count, addItem }}>
|
|
437
|
+
<Header /> {/* reads count via useContext — rerenders on every count change */}
|
|
438
|
+
<ProductList /> {/* calls addItem — triggers App rerender → Header rerender */}
|
|
439
|
+
</CartContext.Provider>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
function Header() {
|
|
443
|
+
const { count } = React.useContext(CartContext);
|
|
444
|
+
return <nav><span className="badge">{count}</span></nav>;
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Vue 3** — Pinia store
|
|
449
|
+
```js
|
|
450
|
+
// store.js
|
|
451
|
+
import { defineStore } from 'pinia';
|
|
452
|
+
export const useCartStore = defineStore('cart', () => {
|
|
453
|
+
const count = ref(0);
|
|
454
|
+
const add = () => count.value++;
|
|
455
|
+
return { count, add };
|
|
456
|
+
});
|
|
457
|
+
// Header.vue: const { count } = storeToRefs(useCartStore()) — only badge text updates
|
|
458
|
+
// ProductCard.vue: useCartStore().add()
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**Solid** — shared signal
|
|
462
|
+
```js
|
|
463
|
+
// cart.js
|
|
464
|
+
import { createSignal } from 'solid-js';
|
|
465
|
+
const [cartCount, setCartCount] = createSignal(0);
|
|
466
|
+
export const addToCart = () => setCartCount(c => c + 1);
|
|
467
|
+
export { cartCount };
|
|
468
|
+
// Header.jsx: <span class="badge">{cartCount()}</span> — only this text node updates
|
|
469
|
+
// ProductCard.jsx: import { addToCart }; <button onClick={addToCart}>Add</button>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**Objs** — plain observer, no library
|
|
473
|
+
```js
|
|
474
|
+
// cart.js — just a plain object
|
|
475
|
+
const cartStore = { count: 0, listeners: [] };
|
|
476
|
+
export const addToCart = () => {
|
|
477
|
+
cartStore.count++;
|
|
478
|
+
cartStore.listeners.forEach(fn => fn(cartStore.count));
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// header.js — subscribe: only this one innerHTML write per add
|
|
482
|
+
const navBadge = o.init(BadgeStates).render({ count: 0 }).appendInside('.nav');
|
|
483
|
+
cartStore.listeners.push(n => navBadge.setCount(n));
|
|
484
|
+
|
|
485
|
+
// product-card.js — publish
|
|
486
|
+
card.first('.add-btn').on('click', () => addToCart());
|
|
487
|
+
// No parent re-renders. No context. No signals. One function call → one DOM write.
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
### Pattern 8 — Async data fetch
|
|
493
|
+
|
|
494
|
+
Load data from an API and render it. Handle the loading state.
|
|
495
|
+
|
|
496
|
+
**React**
|
|
497
|
+
```jsx
|
|
498
|
+
function ProductList() {
|
|
499
|
+
const [products, setProducts] = React.useState([]);
|
|
500
|
+
const [loading, setLoading] = React.useState(true);
|
|
501
|
+
React.useEffect(() => {
|
|
502
|
+
fetch('/api/products')
|
|
503
|
+
.then(r => r.json())
|
|
504
|
+
.then(data => { setProducts(data); setLoading(false); });
|
|
505
|
+
}, []);
|
|
506
|
+
if (loading) return <p>Loading…</p>;
|
|
507
|
+
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**Vue 3**
|
|
512
|
+
```vue
|
|
513
|
+
<template>
|
|
514
|
+
<p v-if="loading">Loading…</p>
|
|
515
|
+
<ul v-else>
|
|
516
|
+
<li v-for="p in products" :key="p.id">{{ p.name }}</li>
|
|
517
|
+
</ul>
|
|
518
|
+
</template>
|
|
519
|
+
<script setup>
|
|
520
|
+
import { ref, onMounted } from 'vue';
|
|
521
|
+
const products = ref([]);
|
|
522
|
+
const loading = ref(true);
|
|
523
|
+
onMounted(() =>
|
|
524
|
+
fetch('/api/products').then(r => r.json()).then(data => {
|
|
525
|
+
products.value = data;
|
|
526
|
+
loading.value = false;
|
|
527
|
+
})
|
|
528
|
+
);
|
|
529
|
+
</script>
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**Solid**
|
|
533
|
+
```jsx
|
|
534
|
+
import { createResource, Show, For } from 'solid-js';
|
|
535
|
+
function ProductList() {
|
|
536
|
+
const [products] = createResource(() =>
|
|
537
|
+
fetch('/api/products').then(r => r.json())
|
|
538
|
+
);
|
|
539
|
+
return (
|
|
540
|
+
<Show when={!products.loading} fallback={<p>Loading…</p>}>
|
|
541
|
+
<ul><For each={products()}>{p => <li>{p.name}</li>}</For></ul>
|
|
542
|
+
</Show>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
**Objs**
|
|
548
|
+
```js
|
|
549
|
+
const listStates = {
|
|
550
|
+
name: 'ProductList',
|
|
551
|
+
render: { tag: 'div', html: '<p class="loading">Loading…</p>' },
|
|
552
|
+
// Called by the loader when data arrives — success state
|
|
553
|
+
load: ({ self }, products) => {
|
|
554
|
+
self.el.innerHTML = '';
|
|
555
|
+
products.forEach(p => o.initState({ tag: 'p', html: p.name }).appendInside(self.el));
|
|
556
|
+
},
|
|
557
|
+
// Called by the loader on network error — fail state
|
|
558
|
+
loadFailed: ({ self }) => { self.first('.loading').html('Failed to load. Retry?'); },
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const list = o.init(listStates).render().appendInside('#app');
|
|
562
|
+
const loader = o.newLoader(o.get('/api/products'));
|
|
563
|
+
list.connect(loader, 'load', 'loadFailed');
|
|
564
|
+
// loader fires the request; .connect wires success → load, failure → loadFailed
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
### Concept map — if you think in framework X
|
|
570
|
+
|
|
571
|
+
| Your mental model | React | Vue 3 | Solid | Objs |
|
|
572
|
+
|---|---|---|---|---|
|
|
573
|
+
| Component definition | `function Foo(props)` | `<script setup>` + `<template>` | `function Foo(props)` | `states` object |
|
|
574
|
+
| Create & mount | `createRoot(el).render(<Foo/>)` | `createApp(Foo).mount(el)` | `render(()=><Foo/>, el)` | `o.init(states).render(props).appendInside(el)` |
|
|
575
|
+
| Reactive value | `const [v, setV] = useState(x)` | `const v = ref(x)` | `const [v, setV] = createSignal(x)` | State method writing directly to DOM |
|
|
576
|
+
| Update a value | `setV(newVal)` → re-render | `v.value = newVal` → patch | `setV(newVal)` → fine patch | `comp.setState(newVal)` → direct write |
|
|
577
|
+
| Read in template | `{v}` | `{{ v }}` | `{v()}` | Not needed — state methods write directly |
|
|
578
|
+
| Props | function parameter | `defineProps()` | function parameter | `render(props)` argument |
|
|
579
|
+
| Events | `onClick={handler}` | `@click="handler"` | `onClick={handler}` | `.on('click', handler)` |
|
|
580
|
+
| Child ref | `useRef()` | `ref="name"` | `let el` / `ref` | `self.store.child = childInstance` |
|
|
581
|
+
| Lifecycle: mount | `useEffect(fn, [])` | `onMounted(fn)` | `onMount(fn)` | `init` state method called after render |
|
|
582
|
+
| Lifecycle: unmount | `useEffect` return fn | `onBeforeUnmount(fn)` | `onCleanup(fn)` | `comp.unmount()` |
|
|
583
|
+
| Shared state | Context / Zustand | Pinia | Signals / store | Plain observer or `o.connectRedux` |
|
|
584
|
+
| Fetch on mount | `useEffect` + `useState` | `onMounted` + `ref` | `createResource` | `o.newLoader` + `.connect()` |
|
|
585
|
+
| Conditional render | `{flag && <El/>}` | `v-if="flag"` | `<Show when={flag}>` | `comp.show()` / `comp.hide()` |
|
|
586
|
+
| List render | `arr.map(x => <El key>)` | `v-for="x in arr"` | `<For each={arr}>` | `arr.forEach` in a state method |
|
|
587
|
+
| CSS class toggle | `className={flag?'a':'b'}` | `:class="{a: flag}"` | `classList={{a: flag}}` | `comp.toggleClass('a', flag)` |
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## 1. How render works
|
|
592
|
+
|
|
593
|
+
The `render` state is the creation state. It accepts five different forms.
|
|
594
|
+
|
|
595
|
+
### 1a. Plain object — static element
|
|
596
|
+
|
|
597
|
+
The simplest form. All keys become HTML attributes; special keys (`html`, `style`, `dataset`, `class`, `append`, `children`) are handled by `transform()`.
|
|
598
|
+
|
|
599
|
+
```js
|
|
600
|
+
const badge = o.init({
|
|
601
|
+
name: 'Badge',
|
|
602
|
+
render: { tag: 'span', class: 'badge', html: '3' },
|
|
603
|
+
update: ({ self }, n) => { self.html(n); },
|
|
604
|
+
}).render();
|
|
605
|
+
badge.appendInside('.nav');
|
|
606
|
+
badge.update(7); // writes only to this span's innerHTML
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### 1b. Function returning an object — dynamic attributes
|
|
610
|
+
|
|
611
|
+
The function receives `{self, o, i, ...originalProps}`. All extra keys passed to `render(props)` are merged in.
|
|
612
|
+
|
|
613
|
+
> **Note:** Inside all state functions, `self` is the ObjsInstance. Use `self.first()`, `self.html()` etc. directly — `o(self)` works too but is redundant wrapping.
|
|
614
|
+
|
|
615
|
+
```js
|
|
616
|
+
const buttonStates = {
|
|
617
|
+
name: 'Button',
|
|
618
|
+
render: ({ variant = 'default', size = 'md', label }) => ({
|
|
619
|
+
tag: 'button',
|
|
620
|
+
class: `btn btn--${variant} btn--${size}`,
|
|
621
|
+
html: label,
|
|
622
|
+
}),
|
|
623
|
+
setVariant: ({ self }, v) => { self.el.className = `btn btn--${v}`; },
|
|
624
|
+
setLabel: ({ self }, l) => { self.html(l); },
|
|
625
|
+
setDisabled:({ self }, v) => { v ? self.attr('disabled', 'true') : self.attr('disabled', null); },
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const btn = o.init(buttonStates).render({ variant: 'primary', label: 'Submit' });
|
|
629
|
+
btn.appendInside('#toolbar');
|
|
630
|
+
btn.setLabel('Saving…'); // only this element's innerHTML changes
|
|
631
|
+
btn.setDisabled(true); // only this element's disabled attribute changes
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### 1c. HTML string — unwrapped template
|
|
635
|
+
|
|
636
|
+
If the string contains exactly one root element it is unwrapped; otherwise a `div` wrapper is kept.
|
|
637
|
+
|
|
638
|
+
```js
|
|
639
|
+
// Single root element — unwrapped to <a>
|
|
640
|
+
o.initState('<a href="/" class="logo">Brand</a>').appendInside('header');
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### 1d. Multiple instances — one init, many elements
|
|
644
|
+
|
|
645
|
+
Pass an **array of props** to `render()`. One ObjsInstance manages all elements.
|
|
646
|
+
State methods operate on **all** elements by default. Use `.select(i)` for one.
|
|
647
|
+
|
|
648
|
+
```js
|
|
649
|
+
const navLinkStates = {
|
|
650
|
+
name: 'NavLink',
|
|
651
|
+
render: ({ label, path }) => ({ tag: 'a', class: 'nav-link', html: label, href: path }),
|
|
652
|
+
setActive: ({ self }, activePath) => {
|
|
653
|
+
// 'self.el' is the element being iterated — transform() handles the loop internally
|
|
654
|
+
// For multi-element operations, use self.find/self.forEach
|
|
655
|
+
self.forEach(({ el }) => {
|
|
656
|
+
el.classList.toggle('nav-link--active', el.getAttribute('href') === activePath);
|
|
657
|
+
});
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const links = o.init(navLinkStates).render([
|
|
662
|
+
{ label: 'Home', path: '/' },
|
|
663
|
+
{ label: 'Products', path: '/products' },
|
|
664
|
+
{ label: 'About', path: '/about' },
|
|
665
|
+
]);
|
|
666
|
+
// links.els = [<a>Home</a>, <a>Products</a>, <a>About</a>]
|
|
667
|
+
links.appendInside('.nav');
|
|
668
|
+
links.setActive('/products'); // updates all three (each checks its own href)
|
|
669
|
+
links.select(0).setActive('/'); // only first link
|
|
670
|
+
links.all(); // back to all-elements mode
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### 1e. The `append` key — live child components
|
|
674
|
+
|
|
675
|
+
Pass pre-built ObjsInstances into the render object. Their DOM elements are appended into the parent. Children are created once; only state methods need to update them later.
|
|
676
|
+
|
|
677
|
+
```js
|
|
678
|
+
const iconEl = o.initState({ tag: 'span', class: 'btn-icon', html: '🛒' });
|
|
679
|
+
const labelEl = o.initState({ tag: 'span', class: 'btn-label', html: 'Cart' });
|
|
680
|
+
|
|
681
|
+
const cartBtn = o.init({
|
|
682
|
+
name: 'CartButton',
|
|
683
|
+
render: { tag: 'button', class: 'cart-btn', append: [iconEl, labelEl] },
|
|
684
|
+
// Targeted update — only the label span changes
|
|
685
|
+
setCount: ({ self }, n) => { self.store.label.html(n > 0 ? `Cart (${n})` : 'Cart'); },
|
|
686
|
+
}).render();
|
|
687
|
+
|
|
688
|
+
cartBtn.store.label = labelEl; // store reference for later updates
|
|
689
|
+
cartBtn.appendInside('.nav');
|
|
690
|
+
cartBtn.setCount(3); // one innerHTML write on labelEl, nothing else touched
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### 1f. The `children` key — positional reconciliation
|
|
694
|
+
|
|
695
|
+
Unlike `append` (which always adds), `children` replaces DOM nodes at each position — like a minimal reconciler. Use it when the order or count of child components changes.
|
|
696
|
+
|
|
697
|
+
```js
|
|
698
|
+
const gridStates = {
|
|
699
|
+
name: 'CardGrid',
|
|
700
|
+
render: { tag: 'div', class: 'grid' },
|
|
701
|
+
// Returns an object — transform() applies it to the existing element
|
|
702
|
+
reconcile: ({ self }, cards) => ({ children: cards.map(c => c.el) }),
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const grid = o.init(gridStates).render().appendInside('#app');
|
|
706
|
+
const cards = products.map(p => o.init(cardStates).render(p));
|
|
707
|
+
grid.reconcile(cards);
|
|
708
|
+
|
|
709
|
+
// Reorder: only moved nodes get replaceWith(), unchanged nodes are untouched
|
|
710
|
+
cards.sort((a, b) => a.store.price - b.store.price);
|
|
711
|
+
grid.reconcile(cards);
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
### 1g. `ref` attributes and `self.refs`
|
|
717
|
+
|
|
718
|
+
Add a `ref="name"` attribute to any element in an HTML-string render. After `init`, every such element is available on the component as `component.refs.name` — an ObjsInstance wrapper, not a raw DOM node.
|
|
719
|
+
|
|
720
|
+
```js
|
|
721
|
+
const cardStates = {
|
|
722
|
+
name: 'ProductCard',
|
|
723
|
+
render: ({ title, price }) => ({
|
|
724
|
+
tag: 'article',
|
|
725
|
+
className: 'card',
|
|
726
|
+
html: `<h3 ref="title">${title}</h3>
|
|
727
|
+
<p ref="price">$${price}</p>
|
|
728
|
+
<button ref="addBtn">Add to cart</button>`,
|
|
729
|
+
}),
|
|
730
|
+
// Destructure refs for clean, selector-free access
|
|
731
|
+
setAdded: ({ self }) => {
|
|
732
|
+
const { addBtn } = self.refs;
|
|
733
|
+
addBtn.html('✓ Added').attr('disabled', '');
|
|
734
|
+
},
|
|
735
|
+
updatePrice: ({ self }, newPrice) => {
|
|
736
|
+
self.refs.price.html('$' + newPrice);
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const card = o.init(cardStates).render({ title: 'Widget', price: 9.99 }).appendInside('#app');
|
|
741
|
+
// card.refs.addBtn — ObjsInstance wrapping the <button ref="addBtn">
|
|
742
|
+
// card.refs.title — ObjsInstance wrapping the <h3 ref="title">
|
|
743
|
+
card.refs.addBtn.on('click', () => card.setAdded());
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
> In React, `useRef` accesses a single DOM node. Objs `refs` auto-collects all named children at init time — no `useRef` call per element, no `ref={myRef}` on every JSX tag.
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## 2. Single components — atoms
|
|
751
|
+
|
|
752
|
+
Atoms are self-contained components with no children. They define only their own element and targeted update states.
|
|
753
|
+
|
|
754
|
+
### Button atom
|
|
755
|
+
|
|
756
|
+
```js
|
|
757
|
+
const ButtonStates = {
|
|
758
|
+
name: 'Button',
|
|
759
|
+
render: ({ label, variant = 'default', size = 'md', disabled = false }) => ({
|
|
760
|
+
tag: 'button',
|
|
761
|
+
class: `btn btn--${variant} btn--${size}`,
|
|
762
|
+
html: label,
|
|
763
|
+
...(disabled ? { disabled: 'true' } : {}),
|
|
764
|
+
}),
|
|
765
|
+
setLabel: ({ self }, l) => { self.html(l); },
|
|
766
|
+
setVariant: ({ self }, v) => { self.addClass(`btn--${v}`); },
|
|
767
|
+
setDisabled: ({ self }, v) => { v ? self.attr('disabled', 'true') : self.attr('disabled', null); },
|
|
768
|
+
setLoading: ({ self }, v) => {
|
|
769
|
+
self.toggleClass('btn--loading', v);
|
|
770
|
+
v ? self.attr('disabled', 'true') : self.attr('disabled', null);
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### Badge atom
|
|
776
|
+
|
|
777
|
+
```js
|
|
778
|
+
const BadgeStates = {
|
|
779
|
+
name: 'Badge',
|
|
780
|
+
render: ({ count = 0, variant = 'primary' }) => ({
|
|
781
|
+
tag: 'span',
|
|
782
|
+
class: `badge badge--${variant}`,
|
|
783
|
+
html: String(count),
|
|
784
|
+
style: count === 0 ? 'display:none' : '',
|
|
785
|
+
}),
|
|
786
|
+
setCount: ({ self }, n) => {
|
|
787
|
+
self.html(n);
|
|
788
|
+
// Re-apply inline style — transform skips unchanged values
|
|
789
|
+
n === 0 ? self.css({ display: 'none' }) : self.css(null);
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### Input field atom
|
|
795
|
+
|
|
796
|
+
```js
|
|
797
|
+
const FieldStates = {
|
|
798
|
+
name: 'FormField',
|
|
799
|
+
render: ({ name, label, type = 'text', placeholder = '' }) => ({
|
|
800
|
+
tag: 'div',
|
|
801
|
+
class: 'field',
|
|
802
|
+
html: `<label class="field__label">${label}</label>
|
|
803
|
+
<input class="field__input" type="${type}" name="${name}" placeholder="${placeholder}">
|
|
804
|
+
<span class="field__error"></span>`,
|
|
805
|
+
}),
|
|
806
|
+
// Each state only touches its specific sub-element
|
|
807
|
+
setError: ({ self }, msg) => {
|
|
808
|
+
self.first('.field__input').addClass('field__input--error');
|
|
809
|
+
self.first('.field__error').html(msg || '');
|
|
810
|
+
},
|
|
811
|
+
setSuccess: ({ self }) => {
|
|
812
|
+
self.first('.field__input').removeClass('field__input--error').addClass('field__input--ok');
|
|
813
|
+
self.first('.field__error').html('');
|
|
814
|
+
},
|
|
815
|
+
setIdle: ({ self }) => {
|
|
816
|
+
self.first('.field__input').removeClass('field__input--error').removeClass('field__input--ok');
|
|
817
|
+
self.first('.field__error').html('');
|
|
818
|
+
},
|
|
819
|
+
getValue: ({ self }) => self.first('.field__input').val(),
|
|
820
|
+
show: ({ self }) => { self.css(null); },
|
|
821
|
+
hide: ({ self }) => { self.css({ display: 'none' }); },
|
|
822
|
+
};
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
---
|
|
826
|
+
|
|
827
|
+
## 3. Nesting & composition
|
|
828
|
+
|
|
829
|
+
Three patterns for building composite components. Choose based on how the parent and children relate.
|
|
830
|
+
|
|
831
|
+
### Pattern A — Slot pattern
|
|
832
|
+
|
|
833
|
+
Best for: containers with named regions (cards, dialogs, panels, toolbars).
|
|
834
|
+
|
|
835
|
+
Parent defines named slot containers in `html`. Children fill them via `appendInside`. Parent tracks children in `self.store`.
|
|
836
|
+
|
|
837
|
+
**When to update:** call child's own state methods directly. The parent DOM is never touched.
|
|
838
|
+
|
|
839
|
+
```js
|
|
840
|
+
const CardStates = {
|
|
841
|
+
name: 'Card',
|
|
842
|
+
render: {
|
|
843
|
+
tag: 'article',
|
|
844
|
+
class: 'card',
|
|
845
|
+
html: `<div class="card__header"></div>
|
|
846
|
+
<div class="card__body"></div>
|
|
847
|
+
<div class="card__footer"></div>`,
|
|
848
|
+
},
|
|
849
|
+
// Each slot setter unmounts the previous occupant and mounts the new one
|
|
850
|
+
setHeader: ({ self }, comp) => {
|
|
851
|
+
self.store.header?.unmount();
|
|
852
|
+
self.store.header = comp;
|
|
853
|
+
const slot = self.first('.card__header').el;
|
|
854
|
+
slot.innerHTML = '';
|
|
855
|
+
comp.appendInside(slot);
|
|
856
|
+
},
|
|
857
|
+
setBody: ({ self }, comp) => {
|
|
858
|
+
self.store.body?.unmount();
|
|
859
|
+
self.store.body = comp;
|
|
860
|
+
const slot = self.first('.card__body').el;
|
|
861
|
+
slot.innerHTML = '';
|
|
862
|
+
comp.appendInside(slot);
|
|
863
|
+
},
|
|
864
|
+
setFooter: ({ self }, comp) => {
|
|
865
|
+
self.store.footer?.unmount();
|
|
866
|
+
self.store.footer = comp;
|
|
867
|
+
const slot = self.first('.card__footer').el;
|
|
868
|
+
slot.innerHTML = '';
|
|
869
|
+
comp.appendInside(slot);
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
// Assembly — each component is independent, card just hosts them
|
|
874
|
+
const card = o.init(CardStates).render().appendInside('#app');
|
|
875
|
+
const title = o.init(ButtonStates).render({ label: 'Product Title', variant: 'ghost' });
|
|
876
|
+
const price = o.init(BadgeStates).render({ count: 49 });
|
|
877
|
+
const buyBtn = o.init(ButtonStates).render({ label: 'Add to cart', variant: 'primary' });
|
|
878
|
+
|
|
879
|
+
card.setHeader(title);
|
|
880
|
+
card.setBody(price);
|
|
881
|
+
card.setFooter(buyBtn);
|
|
882
|
+
|
|
883
|
+
// Update — zero DOM above the component
|
|
884
|
+
title.setLabel('New Product Name'); // only touches card__header's <button>
|
|
885
|
+
price.setCount(39); // only touches card__body's <span>
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### Pattern B — append in render
|
|
889
|
+
|
|
890
|
+
Best for: molecules assembled from known atoms at creation time. Children are immutable after render.
|
|
891
|
+
|
|
892
|
+
Children are created before the parent and passed via `append`. The parent is a structural wrapper only.
|
|
893
|
+
|
|
894
|
+
```js
|
|
895
|
+
function createSearchBar() {
|
|
896
|
+
// Build atoms first
|
|
897
|
+
const input = o.init({
|
|
898
|
+
name: 'SearchInput',
|
|
899
|
+
render: { tag: 'input', class: 'search__input', placeholder: 'Search…', type: 'search' },
|
|
900
|
+
clear: ({ self }) => { self.val(''); },
|
|
901
|
+
}).render();
|
|
902
|
+
|
|
903
|
+
const btn = o.init(ButtonStates).render({ label: '🔍', variant: 'icon' });
|
|
904
|
+
|
|
905
|
+
// Parent wraps them via append — no html needed
|
|
906
|
+
const bar = o.init({
|
|
907
|
+
name: 'SearchBar',
|
|
908
|
+
render: { tag: 'div', class: 'search-bar', append: [input, btn] },
|
|
909
|
+
clear: ({ self }) => { self.store.input.clear(); },
|
|
910
|
+
getValue: ({ self }) => self.store.input.val(),
|
|
911
|
+
focus: ({ self }) => { self.store.input.el.focus(); },
|
|
912
|
+
}).render();
|
|
913
|
+
|
|
914
|
+
// Store references after render
|
|
915
|
+
bar.store.input = input;
|
|
916
|
+
bar.store.btn = btn;
|
|
917
|
+
|
|
918
|
+
// Wire events using stored refs — no DOM queries needed
|
|
919
|
+
btn.on('click', () => {
|
|
920
|
+
bar.el.dispatchEvent(new CustomEvent('search', { detail: bar.getValue(), bubbles: true }));
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
return bar;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const searchBar = createSearchBar().appendInside('.toolbar');
|
|
927
|
+
document.addEventListener('search', (e) => console.log('query:', e.detail));
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
### Pattern C — factory function with lazy child creation
|
|
931
|
+
|
|
932
|
+
Best for: dynamic lists, data-driven components, components where children change at runtime.
|
|
933
|
+
|
|
934
|
+
Children are created inside a state method (not in render). The parent stores all child references and exposes methods to add, update, or remove individual items.
|
|
935
|
+
|
|
936
|
+
```js
|
|
937
|
+
const ListStates = {
|
|
938
|
+
name: 'ProductList',
|
|
939
|
+
render: { tag: 'ul', class: 'product-list' },
|
|
940
|
+
|
|
941
|
+
// Called once when data arrives
|
|
942
|
+
load: ({ self }, products) => {
|
|
943
|
+
self.el.innerHTML = ''; // clear
|
|
944
|
+
self.store.items = {}; // reset map
|
|
945
|
+
|
|
946
|
+
products.forEach((product) => {
|
|
947
|
+
const card = o.init(CardStates).render();
|
|
948
|
+
const titleComp = o.init(ButtonStates).render({ label: product.title, variant: 'ghost' });
|
|
949
|
+
const priceComp = o.init(BadgeStates).render({ count: product.price });
|
|
950
|
+
|
|
951
|
+
card.setHeader(titleComp);
|
|
952
|
+
card.setBody(priceComp);
|
|
953
|
+
card.appendInside(self.el);
|
|
954
|
+
|
|
955
|
+
// Store by product ID for O(1) targeted updates
|
|
956
|
+
self.store.items[product.id] = { card, titleComp, priceComp, product };
|
|
957
|
+
});
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
// Update a single item — only its components are touched
|
|
961
|
+
updatePrice: ({ self }, { id, price }) => {
|
|
962
|
+
self.store.items[id]?.priceComp.setCount(price);
|
|
963
|
+
},
|
|
964
|
+
|
|
965
|
+
// Remove a single item — only its node is removed
|
|
966
|
+
remove: ({ self }, id) => {
|
|
967
|
+
self.store.items[id]?.card.unmount();
|
|
968
|
+
delete self.store.items[id];
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
// Add one item without re-rendering the list
|
|
972
|
+
addItem: ({ self }, product) => {
|
|
973
|
+
const card = o.init(CardStates).render();
|
|
974
|
+
card.setHeader(o.init(ButtonStates).render({ label: product.title, variant: 'ghost' }));
|
|
975
|
+
card.setBody(o.init(BadgeStates).render({ count: product.price }));
|
|
976
|
+
card.appendInside(self.el);
|
|
977
|
+
self.store.items[product.id] = { card, product };
|
|
978
|
+
},
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
const list = o.init(ListStates).render().appendInside('#products');
|
|
982
|
+
const loader = o.newLoader(o.get('/api/products'));
|
|
983
|
+
list.connect(loader, 'load');
|
|
984
|
+
|
|
985
|
+
// Later — only the changed item's badge is touched
|
|
986
|
+
list.updatePrice({ id: 42, price: 39 });
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
---
|
|
990
|
+
|
|
991
|
+
## 4. Design system architecture
|
|
992
|
+
|
|
993
|
+
Objs maps naturally to Atomic Design. Each layer uses the composition pattern appropriate to its role.
|
|
994
|
+
|
|
995
|
+
```
|
|
996
|
+
Atoms → pure state objects, no children, no store
|
|
997
|
+
Molecules → append pattern (atoms assembled at creation)
|
|
998
|
+
Organisms → slot pattern (molecules mounted into named regions)
|
|
999
|
+
Templates → factory functions wiring organisms to data stores
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
### Layer 1 — Atoms (reusable state objects)
|
|
1003
|
+
|
|
1004
|
+
An atom is just a states object. Export it, share it, extend it.
|
|
1005
|
+
|
|
1006
|
+
```js
|
|
1007
|
+
// atoms.js
|
|
1008
|
+
export const ButtonStates = { name: 'Button', render: ..., setLabel: ..., setDisabled: ... };
|
|
1009
|
+
export const BadgeStates = { name: 'Badge', render: ..., setCount: ... };
|
|
1010
|
+
export const FieldStates = { name: 'FormField', render: ..., setError: ..., setSuccess: ... };
|
|
1011
|
+
export const AvatarStates = { name: 'Avatar', render: ({ src, alt }) => ({ tag:'img', class:'avatar', src, alt }) };
|
|
1012
|
+
export const SpinnerStates = { name: 'Spinner', render: { tag:'div', class:'spinner' } };
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
Atoms should have **no `self.store` usage** and **no child components**. Every state method writes directly to the element or its immediate children (via `self.first()`).
|
|
1016
|
+
|
|
1017
|
+
### Layer 2 — Molecules (assembled atoms)
|
|
1018
|
+
|
|
1019
|
+
A molecule is a factory function that builds atoms, wires them together, and returns the parent component.
|
|
1020
|
+
|
|
1021
|
+
```js
|
|
1022
|
+
// molecules.js
|
|
1023
|
+
import { ButtonStates, BadgeStates } from './atoms.js';
|
|
1024
|
+
|
|
1025
|
+
export function createCartButton(initialCount = 0) {
|
|
1026
|
+
const badge = o.init(BadgeStates).render({ count: initialCount });
|
|
1027
|
+
const icon = o.initState({ tag: 'span', class: 'cart-icon', html: '🛒' });
|
|
1028
|
+
|
|
1029
|
+
const btn = o.init({
|
|
1030
|
+
name: 'CartButton',
|
|
1031
|
+
render: { tag: 'button', class: 'cart-btn', append: [icon, badge] },
|
|
1032
|
+
setCount: ({ self }, n) => { self.store.badge.setCount(n); },
|
|
1033
|
+
setLoading: ({ self }, v) => { self.toggleClass('cart-btn--loading', v); },
|
|
1034
|
+
}).render();
|
|
1035
|
+
|
|
1036
|
+
btn.store.badge = badge;
|
|
1037
|
+
btn.store.icon = icon;
|
|
1038
|
+
return btn;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
export function createIconButton(icon, label) {
|
|
1042
|
+
const iconEl = o.initState({ tag: 'span', class: 'btn__icon', html: icon });
|
|
1043
|
+
const labelEl = o.initState({ tag: 'span', class: 'btn__label', html: label });
|
|
1044
|
+
|
|
1045
|
+
const btn = o.init({
|
|
1046
|
+
name: 'IconButton',
|
|
1047
|
+
render: { tag: 'button', class: 'btn btn--icon', append: [iconEl, labelEl] },
|
|
1048
|
+
setIcon: ({ self }, v) => { self.store.icon.html(v); },
|
|
1049
|
+
setLabel: ({ self }, v) => { self.store.label.html(v); },
|
|
1050
|
+
}).render();
|
|
1051
|
+
|
|
1052
|
+
btn.store.icon = iconEl;
|
|
1053
|
+
btn.store.label = labelEl;
|
|
1054
|
+
return btn;
|
|
1055
|
+
}
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### Layer 3 — Organisms (slot-based containers)
|
|
1059
|
+
|
|
1060
|
+
An organism uses the slot pattern. It defines its structure and exposes methods to mount molecules into named regions.
|
|
1061
|
+
|
|
1062
|
+
```js
|
|
1063
|
+
// organisms.js
|
|
1064
|
+
import { createCartButton, createSearchBar } from './molecules.js';
|
|
1065
|
+
|
|
1066
|
+
export const ToolbarStates = {
|
|
1067
|
+
name: 'Toolbar',
|
|
1068
|
+
render: {
|
|
1069
|
+
tag: 'header',
|
|
1070
|
+
class: 'toolbar',
|
|
1071
|
+
html: `<div class="toolbar__start"></div>
|
|
1072
|
+
<div class="toolbar__center"></div>
|
|
1073
|
+
<div class="toolbar__end"></div>`,
|
|
1074
|
+
},
|
|
1075
|
+
mount: ({ self }, { slot, comp }) => {
|
|
1076
|
+
const slotEl = self.first(`.toolbar__${slot}`).el;
|
|
1077
|
+
self.store[slot]?.unmount();
|
|
1078
|
+
self.store[slot] = comp;
|
|
1079
|
+
slotEl.innerHTML = '';
|
|
1080
|
+
comp.appendInside(slotEl);
|
|
1081
|
+
},
|
|
1082
|
+
// Expose slot accessors
|
|
1083
|
+
getSlot: ({ self }, slot) => self.store[slot],
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
// Template — assembles the toolbar, wires it to stores
|
|
1087
|
+
export function createAppToolbar(cartStore) {
|
|
1088
|
+
const logo = o.initState({ tag: 'a', class: 'logo', href: '/', html: 'Brand' });
|
|
1089
|
+
const search = createSearchBar();
|
|
1090
|
+
const cartBtn = createCartButton(0);
|
|
1091
|
+
|
|
1092
|
+
const toolbar = o.init(ToolbarStates).render().appendInside('body');
|
|
1093
|
+
toolbar.mount({ slot: 'start', comp: logo });
|
|
1094
|
+
toolbar.mount({ slot: 'center', comp: search });
|
|
1095
|
+
toolbar.mount({ slot: 'end', comp: cartBtn });
|
|
1096
|
+
|
|
1097
|
+
// Wire cart store — only the badge updates on change
|
|
1098
|
+
cartStore.listeners.push((items) => cartBtn.setCount(items.length));
|
|
1099
|
+
|
|
1100
|
+
return { toolbar, search, cartBtn };
|
|
1101
|
+
}
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
### Extending states (variant inheritance)
|
|
1105
|
+
|
|
1106
|
+
Extend an atom's states object with `Object.assign` to create specialised variants:
|
|
1107
|
+
|
|
1108
|
+
```js
|
|
1109
|
+
// atoms.js
|
|
1110
|
+
export const ButtonStates = { name: 'Button', render: ..., setLabel: ..., setDisabled: ... };
|
|
1111
|
+
|
|
1112
|
+
// Add a variant — extends without modifying the base
|
|
1113
|
+
export const SubmitButtonStates = {
|
|
1114
|
+
...ButtonStates,
|
|
1115
|
+
name: 'SubmitButton',
|
|
1116
|
+
render: ({ label = 'Submit', ...rest }) => ButtonStates.render({ label, variant: 'primary', ...rest }),
|
|
1117
|
+
// Adds loading state on top of atom's states
|
|
1118
|
+
submit: ({ self }) => {
|
|
1119
|
+
self.setDisabled(true);
|
|
1120
|
+
self.setLabel('Saving…');
|
|
1121
|
+
self.toggleClass('btn--loading', true);
|
|
1122
|
+
},
|
|
1123
|
+
reset: ({ self }) => {
|
|
1124
|
+
self.setDisabled(false);
|
|
1125
|
+
self.setLabel('Submit');
|
|
1126
|
+
self.toggleClass('btn--loading', false);
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
### Update efficiency summary
|
|
1132
|
+
|
|
1133
|
+
| Update target | How to do it | DOM writes |
|
|
1134
|
+
|---|---|---|
|
|
1135
|
+
| Atom attribute | `atom.setLabel('x')` | 1 — direct innerHTML or setAttribute |
|
|
1136
|
+
| Atom in molecule | `molecule.store.atom.setLabel('x')` | 1 — same atom write |
|
|
1137
|
+
| Slot in organism | `organism.store.slot.stateMethod()` | 1 or more — only that slot's subtree |
|
|
1138
|
+
| List item | `list.store.items[id].comp.update(data)` | 1+ — only that card |
|
|
1139
|
+
| All list items | `list.reconcile(newOrder)` | replaceWith per changed position only |
|
|
1140
|
+
| Full list reload | `list.load(newProducts)` | clears and re-creates |
|
|
1141
|
+
|
|
1142
|
+
---
|
|
1143
|
+
|
|
1144
|
+
## 5. Real-world examples
|
|
1145
|
+
|
|
1146
|
+
### 5a. Site navigation menu
|
|
1147
|
+
|
|
1148
|
+
Active route highlighting, mobile toggle. Uses multi-instance render for links.
|
|
1149
|
+
|
|
1150
|
+
```js
|
|
1151
|
+
o.autotag = 'qa';
|
|
1152
|
+
|
|
1153
|
+
const navLinkStates = {
|
|
1154
|
+
name: 'NavLink',
|
|
1155
|
+
render: ({ label, path }) => ({ tag: 'a', class: 'nav-link', html: label, href: path }),
|
|
1156
|
+
setActive: ({ self }, activePath) => {
|
|
1157
|
+
self.forEach(({ el }) => {
|
|
1158
|
+
el.classList.toggle('nav-link--active', el.getAttribute('href') === activePath);
|
|
1159
|
+
});
|
|
1160
|
+
},
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
const toggleStates = {
|
|
1164
|
+
name: 'NavToggle',
|
|
1165
|
+
render: { tag: 'button', class: 'nav-toggle', html: '☰' },
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
const menuStates = {
|
|
1169
|
+
name: 'SiteMenu',
|
|
1170
|
+
render: { tag: 'nav', class: 'nav' },
|
|
1171
|
+
init: ({ self }) => {
|
|
1172
|
+
const links = o.init(navLinkStates).render([
|
|
1173
|
+
{ label: 'Home', path: '/' },
|
|
1174
|
+
{ label: 'Products', path: '/products' },
|
|
1175
|
+
{ label: 'About', path: '/about' },
|
|
1176
|
+
]);
|
|
1177
|
+
const toggle = o.init(toggleStates).render();
|
|
1178
|
+
|
|
1179
|
+
links.appendInside(self.el);
|
|
1180
|
+
toggle.appendInside(self.el);
|
|
1181
|
+
|
|
1182
|
+
// Store for later access
|
|
1183
|
+
self.store.links = links;
|
|
1184
|
+
self.store.toggle = toggle;
|
|
1185
|
+
|
|
1186
|
+
links.setActive(window.location.pathname);
|
|
1187
|
+
toggle.on('click', () => self.toggleClass('nav--open'));
|
|
1188
|
+
},
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
const menu = o.init(menuStates).render().appendInside('header');
|
|
1192
|
+
menu.init(); // sets up children
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
### 5b. Product card list + cart badge
|
|
1196
|
+
|
|
1197
|
+
One shared cart store, two components subscribing to different state methods. Neither rerenders when the other updates.
|
|
1198
|
+
|
|
1199
|
+
```js
|
|
1200
|
+
// ── Shared cart store (plain object with listeners) ───────────────────────
|
|
1201
|
+
const cartStore = { items: [], listeners: [] };
|
|
1202
|
+
const cartAdd = (product) => { cartStore.items.push(product); cartStore.listeners.forEach(fn => fn(cartStore.items)); };
|
|
1203
|
+
|
|
1204
|
+
// ── Cart badge in nav ─────────────────────────────────────────────────────
|
|
1205
|
+
const cartBadge = o.init(BadgeStates).render({ count: 0 }).appendInside('.nav');
|
|
1206
|
+
cartStore.listeners.push((items) => cartBadge.setCount(items.length));
|
|
1207
|
+
|
|
1208
|
+
// ── Product card ──────────────────────────────────────────────────────────
|
|
1209
|
+
const productCardStates = {
|
|
1210
|
+
name: 'ProductCard',
|
|
1211
|
+
render: ({ title, price }) => ({
|
|
1212
|
+
tag: 'article',
|
|
1213
|
+
class: 'card',
|
|
1214
|
+
html: `<h3 class="card__title">${title}</h3>
|
|
1215
|
+
<p class="card__price">$${price}</p>
|
|
1216
|
+
<button class="card__btn">Add to cart</button>`,
|
|
1217
|
+
}),
|
|
1218
|
+
// Granular: only the button changes, card root never touched
|
|
1219
|
+
setAdded: ({ self }) => {
|
|
1220
|
+
self.first('.card__btn').html('✓ Added').attr('disabled', '');
|
|
1221
|
+
},
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
// ── Product list (factory pattern — children in self.store) ───────────────
|
|
1225
|
+
const productListStates = {
|
|
1226
|
+
name: 'ProductList',
|
|
1227
|
+
render: { tag: 'div', class: 'card-list' },
|
|
1228
|
+
load: ({ self }, products) => {
|
|
1229
|
+
self.store.cards = {};
|
|
1230
|
+
products.forEach((product) => {
|
|
1231
|
+
const card = o.init(productCardStates).render(product);
|
|
1232
|
+
card.appendInside(self.el);
|
|
1233
|
+
// Wire button — no DOM query needed, card has .first()
|
|
1234
|
+
card.first('.card__btn').on('click', () => {
|
|
1235
|
+
cartAdd(product);
|
|
1236
|
+
card.setAdded(); // only this card's button changes
|
|
1237
|
+
});
|
|
1238
|
+
self.store.cards[product.id] = card;
|
|
1239
|
+
});
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
const productList = o.init(productListStates).render().appendInside('#products');
|
|
1244
|
+
productList.connect(o.newLoader(o.get('/api/products')), 'load');
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
### 5c. Overlay dialog with UTM auto-open
|
|
1248
|
+
|
|
1249
|
+
Promo dialog triggered by `?promo=CODE` URL parameter. sessionStorage prevents repeat shows.
|
|
1250
|
+
|
|
1251
|
+
```js
|
|
1252
|
+
const PROMO_KEY = 'oTest-promo-shown';
|
|
1253
|
+
|
|
1254
|
+
const dialogStates = {
|
|
1255
|
+
name: 'PromoDialog',
|
|
1256
|
+
render: {
|
|
1257
|
+
tag: 'div', class: 'dialog-overlay', style: 'display:none',
|
|
1258
|
+
html: `<div class="dialog">
|
|
1259
|
+
<button class="dialog__close">✕</button>
|
|
1260
|
+
<h2 class="dialog__title"></h2>
|
|
1261
|
+
<p class="dialog__body"></p>
|
|
1262
|
+
<a class="dialog__cta" href="#">Get offer</a>
|
|
1263
|
+
</div>`,
|
|
1264
|
+
},
|
|
1265
|
+
open: ({ self }, { title, body, cta, ctaUrl }) => {
|
|
1266
|
+
self.first('.dialog__title').html(title);
|
|
1267
|
+
self.first('.dialog__body').html(body);
|
|
1268
|
+
self.first('.dialog__cta').html(cta).attr('href', ctaUrl);
|
|
1269
|
+
self.css({ display: 'flex' });
|
|
1270
|
+
sessionStorage.setItem(PROMO_KEY, '1');
|
|
1271
|
+
},
|
|
1272
|
+
close: ({ self }) => { self.css(null); },
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const dialog = o.init(dialogStates).render().appendInside('body');
|
|
1276
|
+
dialog.first('.dialog__close').on('click', () => dialog.close());
|
|
1277
|
+
dialog.on('click', (e) => { if (e.target === dialog.el) dialog.close(); });
|
|
1278
|
+
|
|
1279
|
+
const promoCode = o.getParams('promo');
|
|
1280
|
+
if (promoCode && !sessionStorage.getItem(PROMO_KEY)) {
|
|
1281
|
+
o.get('/api/promos/' + promoCode).then(r => r.json()).then(data => dialog.open(data));
|
|
1282
|
+
}
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
### 5d. Filter drawer with two-way URL sync
|
|
1286
|
+
|
|
1287
|
+
Filters write to the URL and restore from it on page load. No reload needed.
|
|
1288
|
+
|
|
1289
|
+
```js
|
|
1290
|
+
const getFilters = () => {
|
|
1291
|
+
const p = o.getParams();
|
|
1292
|
+
return { category: p.category || '', minPrice: p.minPrice || '', maxPrice: p.maxPrice || '' };
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
const applyFilters = (filters, productsLoader) => {
|
|
1296
|
+
const params = new URLSearchParams(filters);
|
|
1297
|
+
for (const [k, v] of [...params]) { if (!v) params.delete(k); }
|
|
1298
|
+
history.pushState({}, '', params.toString() ? '?' + params : window.location.pathname);
|
|
1299
|
+
productsLoader.reload(o.get('/api/products?' + params.toString()));
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
const drawerStates = {
|
|
1303
|
+
name: 'FilterDrawer',
|
|
1304
|
+
render: {
|
|
1305
|
+
tag: 'aside', class: 'drawer', style: 'transform:translateX(-100%)',
|
|
1306
|
+
html: `<button class="drawer__close">✕</button>
|
|
1307
|
+
<h3>Filters</h3>
|
|
1308
|
+
<select class="drawer__cat"><option value="">All</option><option value="electronics">Electronics</option></select>
|
|
1309
|
+
<input class="drawer__min" type="number" placeholder="Min $">
|
|
1310
|
+
<input class="drawer__max" type="number" placeholder="Max $">
|
|
1311
|
+
<button class="drawer__apply">Apply</button>
|
|
1312
|
+
<button class="drawer__reset">Reset</button>`,
|
|
1313
|
+
},
|
|
1314
|
+
open: ({ self }) => { self.css({ transform: 'translateX(0)' }); },
|
|
1315
|
+
close: ({ self }) => { self.css({ transform: 'translateX(-100%)' }); },
|
|
1316
|
+
restore: ({ self }, { category, minPrice, maxPrice }) => {
|
|
1317
|
+
self.first('.drawer__cat').val(category);
|
|
1318
|
+
self.first('.drawer__min').val(minPrice);
|
|
1319
|
+
self.first('.drawer__max').val(maxPrice);
|
|
1320
|
+
},
|
|
1321
|
+
getValues: ({ self }) => ({
|
|
1322
|
+
category: self.first('.drawer__cat').val(),
|
|
1323
|
+
minPrice: self.first('.drawer__min').val(),
|
|
1324
|
+
maxPrice: self.first('.drawer__max').val(),
|
|
1325
|
+
}),
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
const drawer = o.init(drawerStates).render().appendInside('body');
|
|
1329
|
+
drawer.restore(getFilters());
|
|
1330
|
+
|
|
1331
|
+
drawer.first('.drawer__close').on('click', () => drawer.close());
|
|
1332
|
+
drawer.first('.drawer__apply').on('click', () => {
|
|
1333
|
+
applyFilters(drawer.getValues(), productsLoader);
|
|
1334
|
+
drawer.close();
|
|
1335
|
+
});
|
|
1336
|
+
drawer.first('.drawer__reset').on('click', () => {
|
|
1337
|
+
const empty = { category: '', minPrice: '', maxPrice: '' };
|
|
1338
|
+
applyFilters(empty, productsLoader);
|
|
1339
|
+
drawer.restore(empty);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
o.first('#open-filters').on('click', () => drawer.open());
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
### 5e. Complex form — validation, conditional fields, live preview
|
|
1346
|
+
|
|
1347
|
+
Each field is an independent atom. Validation state and submit gate are pure JS, not in the DOM.
|
|
1348
|
+
|
|
1349
|
+
```js
|
|
1350
|
+
const validators = {
|
|
1351
|
+
email: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Invalid email address',
|
|
1352
|
+
name: v => v.trim().length >= 2 || 'Minimum 2 characters',
|
|
1353
|
+
company: v => v.trim().length >= 1 || 'Required for business accounts',
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
// ── Field atom (reuses FieldStates from section 2) ────────────────────────
|
|
1357
|
+
const emailField = o.init(FieldStates).render({ name: 'email', label: 'Email', placeholder: 'you@example.com' });
|
|
1358
|
+
const nameField = o.init(FieldStates).render({ name: 'name', label: 'Full name', placeholder: 'Jane Smith' });
|
|
1359
|
+
const companyField = o.init(FieldStates).render({ name: 'company', label: 'Company name', placeholder: 'Acme Inc' });
|
|
1360
|
+
|
|
1361
|
+
// ── Checkbox (simple initState — no custom methods needed) ────────────────
|
|
1362
|
+
const bizBox = o.initState({
|
|
1363
|
+
tag: 'label', class: 'field',
|
|
1364
|
+
html: '<input type="checkbox" name="isBusiness" class="biz-check"> Business account',
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
// ── Live preview ──────────────────────────────────────────────────────────
|
|
1368
|
+
const previewStates = {
|
|
1369
|
+
name: 'FormPreview',
|
|
1370
|
+
render: { tag: 'div', class: 'preview', html: 'Preview: <b class="pv-name">—</b> <<span class="pv-email">—</span>>' },
|
|
1371
|
+
update: ({ self }, { name, email }) => {
|
|
1372
|
+
self.first('.pv-name').html(name || '—');
|
|
1373
|
+
self.first('.pv-email').html(email || '—');
|
|
1374
|
+
},
|
|
1375
|
+
};
|
|
1376
|
+
const preview = o.init(previewStates).render();
|
|
1377
|
+
|
|
1378
|
+
// ── Form organism (slot pattern) ──────────────────────────────────────────
|
|
1379
|
+
const formStates = {
|
|
1380
|
+
name: 'RegistrationForm',
|
|
1381
|
+
render: {
|
|
1382
|
+
tag: 'form', class: 'form',
|
|
1383
|
+
html: '<div class="form__fields"></div><div class="form__preview"></div><button type="submit" class="btn btn--primary" disabled>Submit</button>',
|
|
1384
|
+
},
|
|
1385
|
+
init: ({ self }) => {
|
|
1386
|
+
const fieldsRoot = self.first('.form__fields').el;
|
|
1387
|
+
const previewRoot = self.first('.form__preview').el;
|
|
1388
|
+
|
|
1389
|
+
emailField.appendInside(fieldsRoot);
|
|
1390
|
+
nameField.appendInside(fieldsRoot);
|
|
1391
|
+
bizBox.appendInside(fieldsRoot);
|
|
1392
|
+
companyField.appendInside(fieldsRoot);
|
|
1393
|
+
preview.appendInside(previewRoot);
|
|
1394
|
+
|
|
1395
|
+
companyField.hide();
|
|
1396
|
+
|
|
1397
|
+
self.store.fields = { emailField, nameField, companyField };
|
|
1398
|
+
self.store.preview = preview;
|
|
1399
|
+
self.store.valid = { email: false, name: false, company: true, isBiz: false };
|
|
1400
|
+
},
|
|
1401
|
+
checkSubmit: ({ self }) => {
|
|
1402
|
+
const v = self.store.valid;
|
|
1403
|
+
const ok = v.email && v.name && (!v.isBiz || v.company);
|
|
1404
|
+
self.first('button[type="submit"]').el.disabled = !ok;
|
|
1405
|
+
},
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
const form = o.init(formStates).render().appendInside('#form-container');
|
|
1409
|
+
form.init();
|
|
1410
|
+
|
|
1411
|
+
// ── Validation wiring ─────────────────────────────────────────────────────
|
|
1412
|
+
const validate = (fieldComp, rule, value) => {
|
|
1413
|
+
const res = validators[rule](value);
|
|
1414
|
+
res === true ? fieldComp.setSuccess() : fieldComp.setError(res);
|
|
1415
|
+
return res === true;
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
emailField.first('input')
|
|
1419
|
+
.on('blur', (e) => { form.store.valid.email = validate(emailField, 'email', e.target.value); form.checkSubmit(); })
|
|
1420
|
+
.on('input', (e) => { preview.update({ name: nameField.getValue(), email: e.target.value }); });
|
|
1421
|
+
|
|
1422
|
+
nameField.first('input')
|
|
1423
|
+
.on('blur', (e) => { form.store.valid.name = validate(nameField, 'name', e.target.value); form.checkSubmit(); })
|
|
1424
|
+
.on('input', (e) => { preview.update({ name: e.target.value, email: emailField.getValue() }); });
|
|
1425
|
+
|
|
1426
|
+
bizBox.first('.biz-check').on('change', (e) => {
|
|
1427
|
+
form.store.valid.isBiz = e.target.checked;
|
|
1428
|
+
if (e.target.checked) {
|
|
1429
|
+
companyField.show();
|
|
1430
|
+
} else {
|
|
1431
|
+
companyField.hide();
|
|
1432
|
+
companyField.setIdle();
|
|
1433
|
+
form.store.valid.company = true;
|
|
1434
|
+
}
|
|
1435
|
+
form.checkSubmit();
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
companyField.first('input')
|
|
1439
|
+
.on('blur', (e) => { form.store.valid.company = validate(companyField, 'company', e.target.value); form.checkSubmit(); });
|
|
1440
|
+
|
|
1441
|
+
form.on('submit', (e) => {
|
|
1442
|
+
e.preventDefault();
|
|
1443
|
+
const submitBtn = form.first('button[type="submit"]');
|
|
1444
|
+
submitBtn.setLoading?.(true);
|
|
1445
|
+
o.post('/api/register', { data: Object.fromEntries(new FormData(e.target)) })
|
|
1446
|
+
.then(r => r.json())
|
|
1447
|
+
.then(() => submitBtn.setLoading?.(false));
|
|
1448
|
+
});
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
---
|
|
1452
|
+
|
|
1453
|
+
## 6. React integration
|
|
1454
|
+
|
|
1455
|
+
### Mode 1 — Objs inside a React ref (most common)
|
|
1456
|
+
|
|
1457
|
+
Mount an Objs component into a React-managed DOM node via `useRef`. Always unmount in the cleanup function.
|
|
1458
|
+
|
|
1459
|
+
```jsx
|
|
1460
|
+
function ProductSection() {
|
|
1461
|
+
const containerRef = React.useRef(null);
|
|
1462
|
+
|
|
1463
|
+
React.useEffect(() => {
|
|
1464
|
+
if (!containerRef.current) return;
|
|
1465
|
+
|
|
1466
|
+
// Create Objs components inside useEffect — NOT in component body
|
|
1467
|
+
const list = o.init(productListStates).render().appendInside(containerRef.current);
|
|
1468
|
+
list.connect(o.newLoader(o.get('/api/products')), 'load');
|
|
1469
|
+
|
|
1470
|
+
return () => list.unmount(); // required — prevents memory leak on unmount
|
|
1471
|
+
}, []); // empty deps — run once
|
|
1472
|
+
|
|
1473
|
+
return <div ref={containerRef} />;
|
|
1474
|
+
}
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
### Mode 2 — React context bridge (shared state, no React rerender)
|
|
1478
|
+
|
|
1479
|
+
`o.withReactContext` returns a React component that calls an Objs state method when context changes. It renders nothing — it is a pure side-effect bridge.
|
|
1480
|
+
|
|
1481
|
+
```jsx
|
|
1482
|
+
// CartContext.js
|
|
1483
|
+
export const CartContext = React.createContext({ items: [], addItem: () => {} });
|
|
1484
|
+
|
|
1485
|
+
// App.jsx
|
|
1486
|
+
import { CartContext } from './CartContext';
|
|
1487
|
+
|
|
1488
|
+
function App() {
|
|
1489
|
+
const [items, setItems] = React.useState([]);
|
|
1490
|
+
const addItem = React.useCallback((item) => setItems(prev => [...prev, item]), []);
|
|
1491
|
+
|
|
1492
|
+
return (
|
|
1493
|
+
<CartContext.Provider value={{ items, addItem }}>
|
|
1494
|
+
<Header />
|
|
1495
|
+
<ProductSection addItem={addItem} />
|
|
1496
|
+
</CartContext.Provider>
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// ProductSection.jsx — Objs grid with React context bridge
|
|
1501
|
+
function ProductSection({ addItem }) {
|
|
1502
|
+
const containerRef = React.useRef(null);
|
|
1503
|
+
|
|
1504
|
+
React.useEffect(() => {
|
|
1505
|
+
if (!containerRef.current) return;
|
|
1506
|
+
|
|
1507
|
+
// All Objs components created inside useEffect
|
|
1508
|
+
const cartBadge = o.init(BadgeStates).render({ count: 0 });
|
|
1509
|
+
const grid = o.init(productListStates).render().appendInside(containerRef.current);
|
|
1510
|
+
|
|
1511
|
+
// Wire the add-to-cart action to React's state setter
|
|
1512
|
+
grid.store.onAdd = (product) => {
|
|
1513
|
+
addItem(product); // React's useState → React header rerenders with new count
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
grid.connect(o.newLoader(o.get('/api/products')), 'load');
|
|
1517
|
+
|
|
1518
|
+
// Store cartBadge on the ref so the bridge component can access it
|
|
1519
|
+
containerRef.current._cartBadge = cartBadge;
|
|
1520
|
+
|
|
1521
|
+
return () => { grid.unmount(); cartBadge.unmount(); };
|
|
1522
|
+
}, []);
|
|
1523
|
+
|
|
1524
|
+
// Bridge: CartContext.items → cartBadge.setCount (no React subtree rerender)
|
|
1525
|
+
// Created inline — React creates it once, re-runs only when context changes
|
|
1526
|
+
const CartBridge = React.useMemo(
|
|
1527
|
+
() => o.withReactContext(
|
|
1528
|
+
React,
|
|
1529
|
+
CartContext,
|
|
1530
|
+
ctx => ctx.items.length,
|
|
1531
|
+
// cartBadge may not exist yet on first render — the bridge handles undefined gracefully
|
|
1532
|
+
{ setCount: (n) => containerRef.current?._cartBadge?.setCount(n) },
|
|
1533
|
+
'setCount'
|
|
1534
|
+
),
|
|
1535
|
+
[]
|
|
1536
|
+
);
|
|
1537
|
+
|
|
1538
|
+
return (
|
|
1539
|
+
<div>
|
|
1540
|
+
<CartBridge /> {/* renders null — pure side effect */}
|
|
1541
|
+
<div ref={containerRef} />
|
|
1542
|
+
</div>
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
```
|
|
1546
|
+
|
|
1547
|
+
### Mode 3 — Objs element as React element
|
|
1548
|
+
|
|
1549
|
+
Use `prepareFor(React)` to export a rendered Objs element into a React render tree.
|
|
1550
|
+
|
|
1551
|
+
```jsx
|
|
1552
|
+
function AnimatedBadge({ count }) {
|
|
1553
|
+
const badge = React.useMemo(() => o.init(BadgeStates).render({ count }), []);
|
|
1554
|
+
|
|
1555
|
+
React.useEffect(() => {
|
|
1556
|
+
badge.setCount(count);
|
|
1557
|
+
}, [count]);
|
|
1558
|
+
|
|
1559
|
+
// Returns a React element that mounts the Objs DOM node
|
|
1560
|
+
return badge.prepareFor(React);
|
|
1561
|
+
}
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
### Mode 4 — Bolt-on Playwright test generation for existing React apps
|
|
1565
|
+
|
|
1566
|
+
Live demo: [Recording & Test Generation](examples/recording/index.html) — task app with scoped `o.startRecording(observe)`, auto-generated assertions in the export, and the dev-only manual check overlay `o.testConfirm` after replay.
|
|
1567
|
+
|
|
1568
|
+
Add one script tag to `index.html` (dev/staging only, stripped from prod via the `__DEV__` block):
|
|
1569
|
+
|
|
1570
|
+
```html
|
|
1571
|
+
<script src="objs.js"></script>
|
|
1572
|
+
<script> o.autotag = 'qa'; </script>
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
Mark key React elements with stable QA selectors — three options:
|
|
1576
|
+
|
|
1577
|
+
```jsx
|
|
1578
|
+
// A) Manual attribute
|
|
1579
|
+
<button data-qa="checkout-btn" onClick={handleCheckout}>Checkout</button>
|
|
1580
|
+
|
|
1581
|
+
// B) o.reactQA() utility — converts CamelCase to kebab-case automatically
|
|
1582
|
+
<button {...o.reactQA('CheckoutButton')} onClick={handleCheckout}>Checkout</button>
|
|
1583
|
+
// → <button data-qa="checkout-button">
|
|
1584
|
+
|
|
1585
|
+
// C) Inline hook (no import needed)
|
|
1586
|
+
const useQA = n => ({ 'data-qa': n.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '') });
|
|
1587
|
+
<button {...useQA('CheckoutButton')} onClick={handleCheckout}>Checkout</button>
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
From the browser console (or a QA toolbar injected into staging):
|
|
1591
|
+
|
|
1592
|
+
```js
|
|
1593
|
+
o.startRecording();
|
|
1594
|
+
// QA tester uses the app normally — clicks, fills forms, navigates
|
|
1595
|
+
const rec = o.stopRecording();
|
|
1596
|
+
console.log(o.exportPlaywrightTest(rec, { testName: 'Checkout flow' }));
|
|
1597
|
+
```
|
|
1598
|
+
|
|
1599
|
+
Generated output — paste into `tests/checkout.spec.ts`:
|
|
1600
|
+
|
|
1601
|
+
```ts
|
|
1602
|
+
// Auto-generated by o.exportPlaywrightTest() — review and anonymize mocks before committing
|
|
1603
|
+
// Prerequisites: npm install @playwright/test && npx playwright install chromium
|
|
1604
|
+
// Run: npx playwright test checkout.spec.ts
|
|
1605
|
+
import { test, expect } from '@playwright/test';
|
|
1606
|
+
|
|
1607
|
+
test('Checkout flow', async ({ page }) => {
|
|
1608
|
+
// Network mocks — edit/anonymize before committing
|
|
1609
|
+
await page.route('**/api/cart', async route => {
|
|
1610
|
+
await route.fulfill({ status: 200, contentType: 'application/json',
|
|
1611
|
+
body: JSON.stringify({ items: [], total: 0 }) });
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// Set baseURL in playwright.config.ts: { use: { baseURL: 'https://staging.example.com' } }
|
|
1615
|
+
await page.goto('/checkout');
|
|
1616
|
+
|
|
1617
|
+
await page.locator('[data-qa="email-field"]').fill('jane@example.com');
|
|
1618
|
+
await page.locator('[data-qa="checkout-button"]').click();
|
|
1619
|
+
|
|
1620
|
+
// TODO: Add assertions before committing, e.g.:
|
|
1621
|
+
// await expect(page.locator('[data-qa="success-panel"]')).toBeVisible();
|
|
1622
|
+
// await expect(page).toHaveURL(/\/confirmation/);
|
|
1623
|
+
});
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1626
|
+
> The `data-qa` selectors set by `o.autotag` or `o.reactQA()` are stable across deploys — they don't change when class names are renamed or elements are restructured.
|
|
1627
|
+
|
|
1628
|
+
### Key rules for React coexistence
|
|
1629
|
+
|
|
1630
|
+
| Rule | Reason |
|
|
1631
|
+
|---|---|
|
|
1632
|
+
| Create Objs components inside `useEffect`, not component body | Component body re-runs on every React render — Objs components would be recreated |
|
|
1633
|
+
| Always call `component.unmount()` in the useEffect return | Prevents memory leaks and orphaned event listeners |
|
|
1634
|
+
| Do not use `appendInside` with React-managed selectors | React may move or replace the DOM node — use `ref.current` |
|
|
1635
|
+
| Use `o.withReactContext` for context→Objs data flow | Zero React rerender; direct Objs state call |
|
|
1636
|
+
| Use React's `useState`/`useCallback` for Objs→React data flow | `addItem(product)` calls React's setter which triggers React's rerender in its own tree |
|
|
1637
|
+
| Use `o.reactQA(name)` for stable test selectors | `data-qa` attributes survive CSS refactors and component restructuring |
|