atoms-element v5.0.0
git clone https://git.pyrossh.dev/atoms-element
A simple web component library for defining your custom elements. It works on both client and server.
582dce0
— Peter John
2026-08-07T17:29:49+05:30
re-design
- examples/e2e.spec.js +1 -1
- examples/elements/app-counter.js +16 -11
- index.d.ts +18 -6
- index.js +90 -30
examples/e2e.spec.js
CHANGED
|
@@ -81,7 +81,7 @@ test('clicking +/- updates the counter and the derived total', async () => {
|
|
|
81
81
|
|
|
82
82
|
test('external attribute writes are adopted directly, with no separate internal state to conflict with', async () => {
|
|
83
83
|
// Continuing from the previous test, the first counter currently shows 7.
|
|
84
|
-
// count now lives only in the attribute (via
|
|
84
|
+
// count now lives only in the attribute (via prop) — there's no reducer
|
|
85
85
|
// value to reconcile it against, so an external write just is the new
|
|
86
86
|
// state, and app-total (via useAttr) picks it up automatically.
|
|
87
87
|
const first = page.locator('app-counter').nth(0);
|
examples/elements/app-counter.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { createElement, css, html
|
|
1
|
+
import { createElement, css, html } from '../../index.js';
|
|
2
2
|
|
|
3
|
-
// State lives only in the count attribute —
|
|
3
|
+
// State lives only in the count attribute — no separate internal copy to
|
|
4
|
+
// keep in sync. Counter.attrTypes below declares count as a number: that
|
|
5
|
+
// folds it into observedAttributes on its own (name is still destructured
|
|
6
|
+
// as a plain param since it's only ever displayed, never written), and
|
|
7
|
+
// createElement hands count to this function already wrapped as a
|
|
8
|
+
// {value} getter/setter box, type-checked against the declared type.
|
|
9
|
+
// count must be set in markup (no default) — reading count.value throws
|
|
4
|
-
//
|
|
10
|
+
// otherwise. count.value += 1 reads the live attribute and writes straight
|
|
5
|
-
// setAttribute, with no separate internal copy to keep in sync. count still
|
|
6
|
-
// has to appear as a destructured param below for it to end up in
|
|
7
|
-
//
|
|
11
|
+
// back via setAttribute.
|
|
8
12
|
const Counter = ({ name, count }) => {
|
|
9
|
-
const [currentCount, setCount] = useProp('count', 0);
|
|
10
|
-
const increment = () =>
|
|
13
|
+
const increment = () => { count.value += 1; };
|
|
11
|
-
const decrement = () =>
|
|
14
|
+
const decrement = () => { count.value -= 1; };
|
|
12
|
-
const warningClass =
|
|
15
|
+
const warningClass = count.value > 10 ? 'warning' : '';
|
|
13
16
|
|
|
14
17
|
return html`
|
|
15
18
|
<div class="heading">
|
|
@@ -17,7 +20,7 @@ const Counter = ({ name, count }) => {
|
|
|
17
20
|
<div class="controls">
|
|
18
21
|
<button @click=${decrement}>-</button>
|
|
19
22
|
<div class="count">
|
|
20
|
-
<h1 class="${warningClass}">${
|
|
23
|
+
<h1 class="${warningClass}">${count.value}</h1>
|
|
21
24
|
</div>
|
|
22
25
|
<button @click=${increment}>+</button>
|
|
23
26
|
</div>
|
|
@@ -25,6 +28,8 @@ const Counter = ({ name, count }) => {
|
|
|
25
28
|
`;
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
Counter.attrTypes = { count: 'number' };
|
|
32
|
+
|
|
28
33
|
Counter.styles = css`
|
|
29
34
|
:scope {
|
|
30
35
|
display: block;
|
index.d.ts
CHANGED
|
@@ -56,6 +56,14 @@ export function useReducer<P, Q extends ReducerActions<P>, R extends EffectActio
|
|
|
56
56
|
selector?: (state: P) => any,
|
|
57
57
|
): P & { actions: ActionsOf<Q>, effects: EffectsOf<R> };
|
|
58
58
|
|
|
59
|
+
export type AttrType = 'number' | 'boolean' | 'string';
|
|
60
|
+
// Alternative to calling prop() manually: Fn.attrTypes = { count: 'number' }
|
|
61
|
+
// folds count into observedAttributes on its own (no need for count to also
|
|
62
|
+
// be a destructured render-function param), and createElement passes it to
|
|
63
|
+
// Fn already wrapped as a { value } getter/setter box. Reading .value throws
|
|
64
|
+
// if the attribute is missing, or set but not parseable as the declared
|
|
65
|
+
// type — validated against that explicit type rather than sniffed.
|
|
66
|
+
export type AttrTypes = { [key: string]: AttrType };
|
|
59
67
|
export function createElement(meta: any, renderFn: any): any
|
|
60
68
|
export function css(strings: TemplateStringsArray, ...values: any[]): string;
|
|
61
69
|
export type Handler = (props: any) => string;
|
|
@@ -76,9 +84,13 @@ export function useAttr(selector: string, attribute: string): string[];
|
|
|
76
84
|
// The element instance itself (e.g. to call host.setAttribute(...)). Only
|
|
77
85
|
// call this during render — capture the result via closure for later use.
|
|
78
86
|
export function useHost(): any;
|
|
79
|
-
// State lives only in the named attribute — no separate internal copy
|
|
87
|
+
// State lives only in the named attribute — no separate internal copy, and no
|
|
88
|
+
// default: the attribute must already be set in markup, or reading `.value`
|
|
80
|
-
// must also appear as a destructured render-function parameter
|
|
89
|
+
// throws. `name` must also appear as a destructured render-function parameter
|
|
81
|
-
// up in observedAttributes.
|
|
90
|
+
// for it to end up in observedAttributes. The attribute string's type is
|
|
91
|
+
// inferred ("true"/"false" -> boolean, numeric strings -> number, otherwise
|
|
82
|
-
//
|
|
92
|
+
// left as a string). `.value` is a live getter/setter, not a plain field —
|
|
83
|
-
//
|
|
93
|
+
// reading/writing it goes straight to the attribute each time, so
|
|
94
|
+
// `p.value += 1` is safe to call repeatedly, but destructuring `.value` out
|
|
95
|
+
// into a local snapshots it once and breaks that.
|
|
84
|
-
export function
|
|
96
|
+
export function prop<T = string | number | boolean>(name: string): { value: T };
|
index.js
CHANGED
|
@@ -97,23 +97,52 @@ const parseTag = (tag) => {
|
|
|
97
97
|
};
|
|
98
98
|
const parseAttrValue = (value) => (value && value.startsWith('{') ? JSON.parse(value.replace(/'/g, '"')) : value);
|
|
99
99
|
|
|
100
|
-
//
|
|
100
|
+
// Used by createElement's attrTypes handling: unlike prop's sniffed
|
|
101
|
-
// works: a number default means parse as a number, a boolean default means
|
|
102
|
-
//
|
|
101
|
+
// coercePropValue below, the type here is declared explicitly, so mismatches
|
|
103
|
-
//
|
|
102
|
+
// (not just missing attributes) can be caught and reported precisely.
|
|
104
|
-
// unchanged.
|
|
105
|
-
const
|
|
103
|
+
const coerceTypedProp = (raw, type, key) => {
|
|
106
|
-
if (raw === undefined
|
|
104
|
+
if (raw === undefined) {
|
|
107
|
-
|
|
105
|
+
throw new Error(`attrTypes.${key}: no "${key}" attribute is set (expected ${type})`);
|
|
108
106
|
}
|
|
107
|
+
if (type === 'number') {
|
|
108
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
109
|
+
if (Number.isNaN(n)) {
|
|
110
|
+
throw new Error(`attrTypes.${key}: "${raw}" is not a valid number`);
|
|
111
|
+
}
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
114
|
+
if (type === 'boolean') {
|
|
115
|
+
if (typeof raw === 'boolean') {
|
|
116
|
+
return raw;
|
|
117
|
+
}
|
|
118
|
+
if (raw === 'true' || raw === 'false') {
|
|
119
|
+
return raw === 'true';
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`attrTypes.${key}: "${raw}" is not a valid boolean (expected "true" or "false")`);
|
|
122
|
+
}
|
|
123
|
+
if (type === 'string') {
|
|
124
|
+
return String(raw);
|
|
125
|
+
}
|
|
126
|
+
return raw;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// There's no default value to infer a type from, so the type is inferred
|
|
130
|
+
// from the attribute string itself instead: "true"/"false" become booleans,
|
|
131
|
+
// anything that parses as a finite number becomes a number, everything else
|
|
132
|
+
// (including anything parseAttrValue already turned into an object/array
|
|
133
|
+
// upstream) passes through as-is.
|
|
134
|
+
const coercePropValue = (raw) => {
|
|
109
135
|
if (typeof raw !== 'string') {
|
|
110
136
|
return raw;
|
|
111
137
|
}
|
|
112
|
-
if (
|
|
138
|
+
if (raw === 'true') {
|
|
113
|
-
return
|
|
139
|
+
return true;
|
|
114
140
|
}
|
|
115
|
-
if (
|
|
141
|
+
if (raw === 'false') {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
116
|
-
|
|
144
|
+
if (raw !== '' && !Number.isNaN(Number(raw))) {
|
|
145
|
+
return Number(raw);
|
|
117
146
|
}
|
|
118
147
|
return raw;
|
|
119
148
|
};
|
|
@@ -426,7 +455,7 @@ export const useAttr = (selector, attribute) => {
|
|
|
426
455
|
comp.update();
|
|
427
456
|
}
|
|
428
457
|
});
|
|
429
|
-
observer.observe(document.body, { attributes: true, attributeFilter: [attribute], subtree: true });
|
|
458
|
+
observer.observe(document.body, { attributes: true, attributeFilter: [attribute], childList: true, subtree: true });
|
|
430
459
|
comp.hooks.cleanups[index] = () => observer.disconnect();
|
|
431
460
|
}
|
|
432
461
|
return comp.hooks.data[index];
|
|
@@ -436,28 +465,39 @@ export const useAttr = (selector, attribute) => {
|
|
|
436
465
|
// value to keep in sync with it (that bidirectional sync, tried in an earlier
|
|
437
466
|
// version of this, was a genuine, unresolvable race: whichever side changed
|
|
438
467
|
// last could always be undone by a stale update from the other side still in
|
|
439
|
-
// flight).
|
|
468
|
+
// flight). No default value: the attribute must already be set in markup, or
|
|
440
|
-
//
|
|
469
|
+
// this throws — reading count.value re-checks live each time, so removing the
|
|
441
|
-
//
|
|
470
|
+
// attribute later also throws on the next read rather than silently reviving
|
|
471
|
+
// some invented fallback.
|
|
442
472
|
//
|
|
443
|
-
//
|
|
473
|
+
// count.value is a getter/setter, not a plain field, so count.value += 1
|
|
444
|
-
//
|
|
474
|
+
// reads the live attribute and writes straight back through setAttribute
|
|
475
|
+
// (which synchronously updates comp.attrs via attributeChangedCallback and
|
|
476
|
+
// schedules a re-render if connected) — always against the current value at
|
|
445
|
-
//
|
|
477
|
+
// call time, not one captured in a render closure, so rapid successive calls
|
|
446
|
-
// clicks, each queued before a re-render lands) don't read stale
|
|
478
|
+
// (e.g. quick clicks, each queued before a re-render lands) don't read stale
|
|
479
|
+
// state and silently lose updates. Only works if callers go through
|
|
480
|
+
// `.value` — destructuring it out into a plain local (`const {value} = prop(...)`)
|
|
481
|
+
// snapshots it once and breaks the live read/write, same as it would for a
|
|
447
|
-
//
|
|
482
|
+
// Proxy.
|
|
448
483
|
//
|
|
449
484
|
// name must still appear as a destructured render-function parameter (even if
|
|
450
485
|
// unused otherwise) for it to end up in observedAttributes — that list is read
|
|
451
486
|
// by the browser once, synchronously, when the class is defined, so it can't
|
|
452
487
|
// depend on anything this hook (called later, during render) discovers.
|
|
453
|
-
export const
|
|
488
|
+
export const prop = (name) => {
|
|
454
489
|
const comp = currentComponent.get();
|
|
490
|
+
return {
|
|
491
|
+
get value() {
|
|
492
|
+
if (comp.attrs[name] === undefined) {
|
|
493
|
+
throw new Error(`prop('${name}'): no "${name}" attribute is set`);
|
|
494
|
+
}
|
|
455
|
-
|
|
495
|
+
return coercePropValue(comp.attrs[name]);
|
|
496
|
+
},
|
|
456
|
-
|
|
497
|
+
set value(next) {
|
|
457
|
-
const resolved = typeof next === 'function' ? next(coerceAttr(comp.attrs[name], defaultValue)) : next;
|
|
458
|
-
|
|
498
|
+
comp.setAttribute(name, serializeAttr(next));
|
|
499
|
+
},
|
|
459
500
|
};
|
|
460
|
-
return [value, setValue];
|
|
461
501
|
};
|
|
462
502
|
|
|
463
503
|
const registry = {};
|
|
@@ -466,9 +506,17 @@ export const getElement = (name) => registry[name];
|
|
|
466
506
|
const BaseElement = isBrowser ? window.HTMLElement : class {};
|
|
467
507
|
export const createElement = (meta, renderFn) => {
|
|
468
508
|
const funcParams = parseFuncParams(renderFn);
|
|
509
|
+
// Fn.attrTypes = { count: 'number' } is an alternative to calling prop()
|
|
510
|
+
// manually: it folds count into observedAttributes on its own (no need for
|
|
511
|
+
// count to also be a destructured render-function param), and render()
|
|
512
|
+
// below hands it to renderFn as an already-constructed {value} box, typed
|
|
513
|
+
// and validated per the declared type rather than sniffed from the string.
|
|
514
|
+
const attrTypes = renderFn.attrTypes || {};
|
|
515
|
+
const attrTypeKeys = Object.keys(attrTypes);
|
|
516
|
+
const observedAttrs = Array.from(new Set([...funcParams, ...attrTypeKeys])).map((k) => k.toLowerCase());
|
|
469
517
|
const RenderElement = class extends BaseElement {
|
|
470
518
|
static get observedAttributes() {
|
|
471
|
-
return
|
|
519
|
+
return observedAttrs;
|
|
472
520
|
}
|
|
473
521
|
|
|
474
522
|
static get styles() {
|
|
@@ -581,11 +629,23 @@ export const createElement = (meta, renderFn) => {
|
|
|
581
629
|
|
|
582
630
|
render() {
|
|
583
631
|
currentComponent.set(this);
|
|
584
|
-
const
|
|
632
|
+
const comp = this;
|
|
633
|
+
const props = {
|
|
585
634
|
...this.attrs,
|
|
586
635
|
config: isBrowser ? window.props.config : global?.props?.config,
|
|
587
636
|
location: isBrowser ? window.location : global?.location,
|
|
588
|
-
}
|
|
637
|
+
};
|
|
638
|
+
for (const key of attrTypeKeys) {
|
|
639
|
+
props[key] = {
|
|
640
|
+
get value() {
|
|
641
|
+
return coerceTypedProp(comp.attrs[key], attrTypes[key], key);
|
|
642
|
+
},
|
|
643
|
+
set value(next) {
|
|
644
|
+
comp.setAttribute(key, serializeAttr(next));
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const template = this.renderFn(props);
|
|
589
649
|
if (isBrowser) {
|
|
590
650
|
const styles = RenderElement.styles;
|
|
591
651
|
if (styles && !injectedStyleTags.has(name)) {
|