atoms-element v5.0.0

#js

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.


582dce0Peter John 2026-08-07T17:29:49+05:30
re-design
Files changed (4) hide show
  1. examples/e2e.spec.js +1 -1
  2. examples/elements/app-counter.js +16 -11
  3. index.d.ts +18 -6
  4. 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 useProp) — there's no reducer
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, useProp } from '../../index.js';
1
+ import { createElement, css, html } from '../../index.js';
2
2
 
3
- // State lives only in the count attribute — useProp reads it (type-coerced to
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
- // a number, since the default 0 is a number) and writes it straight back via
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
- // observedAttributes; useProp doesn't add it there itself.
11
+ // back via setAttribute.
8
12
  const Counter = ({ name, count }) => {
9
- const [currentCount, setCount] = useProp('count', 0);
10
- const increment = () => setCount((c) => c + 1);
13
+ const increment = () => { count.value += 1; };
11
- const decrement = () => setCount((c) => c - 1);
14
+ const decrement = () => { count.value -= 1; };
12
- const warningClass = currentCount > 10 ? 'warning' : '';
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}">${currentCount}</h1>
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. `name`
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 for it to end
89
+ // throws. `name` must also appear as a destructured render-function parameter
81
- // up in observedAttributes. defaultValue's type controls how the attribute
90
+ // for it to end up in observedAttributes. The attribute string's type is
91
+ // inferred ("true"/"false" -> boolean, numeric strings -> number, otherwise
82
- // string is parsed (number/boolean/string); the setter accepts a value or an
92
+ // left as a string). `.value` is a live getter/setter, not a plain field
83
- // updater function, always resolved against the live attribute at call time.
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 useProp<T>(name: string, defaultValue: T): [T, (next: T | ((prev: T) => T)) => void];
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
- // Type is inferred from defaultValue, the way useProp's own default argument
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
- // parse as a boolean, otherwise pass the string through as-is. Anything
101
+ // coercePropValue below, the type here is declared explicitly, so mismatches
103
- // already parsed upstream (an object/array, via parseAttrValue) is returned
102
+ // (not just missing attributes) can be caught and reported precisely.
104
- // unchanged.
105
- const coerceAttr = (raw, defaultValue) => {
103
+ const coerceTypedProp = (raw, type, key) => {
106
- if (raw === undefined || raw === null) {
104
+ if (raw === undefined) {
107
- return defaultValue;
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 (typeof defaultValue === 'number') {
138
+ if (raw === 'true') {
113
- return Number(raw);
139
+ return true;
114
140
  }
115
- if (typeof defaultValue === 'boolean') {
141
+ if (raw === 'false') {
142
+ return false;
143
+ }
116
- return raw !== 'false' && raw !== '0';
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). Reading here is just "what does the attribute say right now";
468
+ // flight). No default value: the attribute must already be set in markup, or
440
- // writing is just "set the attribute" (which synchronously updates comp.attrs
469
+ // this throws reading count.value re-checks live each time, so removing the
441
- // via attributeChangedCallback and schedules a re-render if connected).
470
+ // attribute later also throws on the next read rather than silently reviving
471
+ // some invented fallback.
442
472
  //
443
- // The setter accepts a plain value or an updater function (setCount(c => c+1))
473
+ // count.value is a getter/setter, not a plain field, so count.value += 1
444
- // always resolved against the live attribute value at call time, not a
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
- // value captured in a render closure, so rapid successive calls (e.g. quick
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 state and
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
- // silently lose updates.
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 useProp = (name, defaultValue) => {
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
- const value = coerceAttr(comp.attrs[name], defaultValue);
495
+ return coercePropValue(comp.attrs[name]);
496
+ },
456
- const setValue = (next) => {
497
+ set value(next) {
457
- const resolved = typeof next === 'function' ? next(coerceAttr(comp.attrs[name], defaultValue)) : next;
458
- comp.setAttribute(name, serializeAttr(resolved));
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 funcParams.map((k) => k.toLowerCase());
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 template = this.renderFn({
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)) {