@tmbr/component 1.0.0 → 1.1.1

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.
Files changed (4) hide show
  1. package/README.md +31 -13
  2. package/bind.js +4 -4
  3. package/index.js +51 -29
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -62,6 +62,24 @@ class Counter extends Component {
62
62
  new Counter('#counter');
63
63
  ```
64
64
 
65
+ ## State
66
+
67
+ State is deeply reactive so mutations to nested objects and arrays trigger a render.
68
+
69
+ ```js
70
+ class Form extends Component {
71
+ static state = {
72
+ fields: {name: '', email: ''},
73
+ errors: {}
74
+ };
75
+ }
76
+ ```
77
+
78
+ ```html
79
+ <input type="text" :model="fields.name" />
80
+ <span :text="errors.name"></span>
81
+ ```
82
+
65
83
  ## Directives
66
84
 
67
85
  Directives are expressions evaluated with the current state.
@@ -136,16 +154,16 @@ this.dom.items // [li, li]
136
154
 
137
155
  ## Instance API
138
156
 
139
- | Property or Method | Description |
140
- | ---------------------------------- | --------------------------------------------------- |
141
- | `.el` | root element |
142
- | `.dom` | child elements collected from `ref` attributes |
143
- | `.props` | parsed from `data-props` attribute |
144
- | `.state` | reactive proxy where changes trigger a rerender |
145
- | `.findOne(selector)` | scoped `querySelector` |
146
- | `.findAll(selector)` | scoped `querySelectorAll` |
147
- | `.init()` | called after constructor — override in subclass |
148
- | `.update(state)` | called after each render — override in subclass |
149
- | `.on(event, target, fn)` | delegate event listener, cleaned up on `.destroy()` |
150
- | `.dispatch(type, detail, options)` | dispatches a `CustomEvent` from `.el` |
151
- | `.destroy()` | removes all listeners and directives |
157
+ | Property or Method | Description |
158
+ | --------------------------------- | --------------------------------------------------- |
159
+ | `el` | root element |
160
+ | `dom` | child elements collected from `ref` attributes |
161
+ | `props` | parsed from `data-props` attribute |
162
+ | `state` | reactive proxy where changes trigger a rerender |
163
+ | `findOne(selector)` | scoped `querySelector` |
164
+ | `findAll(selector)` | scoped `querySelectorAll` |
165
+ | `init()` | called after constructor — override in subclass |
166
+ | `update(state)` | called after each render — override in subclass |
167
+ | `on(event, target, fn)` | delegate event listener, cleaned up on `.destroy()` |
168
+ | `dispatch(type, detail, options)` | dispatches a `CustomEvent` from `.el` |
169
+ | `destroy()` | removes all listeners and directives |
package/bind.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cx, isFunction } from '@tmbr/utils';
1
+ import { cx, dot, isFunction } from '@tmbr/utils';
2
2
 
3
3
  const BOOLEANS = new Set([
4
4
  'allowfullscreen',
@@ -60,7 +60,7 @@ export function bindDirective(component, node, attr, expression) {
60
60
 
61
61
  node.addEventListener(type, event => {
62
62
  const value = isCheckbox ? event.target.checked : event.target.value;
63
- component.state[expression] = isNumber ? Number(value) : value;
63
+ dot(component.state, expression, isNumber ? Number(value) : value);
64
64
  }, {signal: component.controller.signal});
65
65
 
66
66
  } else if (attr === 'text') {
@@ -70,8 +70,8 @@ export function bindDirective(component, node, attr, expression) {
70
70
  } else if (attr === 'value') {
71
71
  apply = value => node.value = value;
72
72
  } else if (attr === 'show') {
73
- const display = node.style.display || '';
74
- apply = value => node.style.display = value ? display : 'none';
73
+ const initial = node.style.display || '';
74
+ apply = value => node.style.display = value ? initial : 'none';
75
75
  } else if (attr === 'class') {
76
76
  const initial = node.className;
77
77
  apply = value => { node.className = initial; cx(node, value); };
package/index.js CHANGED
@@ -1,13 +1,13 @@
1
- import { on, findOne, findAll, isDefined, isString, toJSON, traverse } from '@tmbr/utils';
1
+ import { on, findOne, findAll, isArray, isDefined, isObject, isString, toJSON, traverse } from '@tmbr/utils';
2
2
  import { bindDirective, bindEvent } from './bind.js';
3
3
 
4
+ let cache;
4
5
  let queue;
5
6
  let scheduled = false;
6
7
 
7
8
  function enqueue(component) {
8
9
  queue ??= new Set();
9
10
  queue.add(component);
10
-
11
11
  if (scheduled) return;
12
12
  queueMicrotask(flush);
13
13
  scheduled = true;
@@ -20,18 +20,25 @@ function flush() {
20
20
  }
21
21
 
22
22
  function render(component) {
23
- const state = component.state;
24
- const proto = Object.getPrototypeOf(component);
25
-
26
- const context = proto === Component.prototype ? state : new Proxy(state, {
27
- has(target, key) {
28
- return Object.getOwnPropertyDescriptor(proto, key)?.get ? true : key in target;
29
- },
30
- get(target, key, receiver) {
31
- const d = Object.getOwnPropertyDescriptor(proto, key);
32
- return d?.get ? d.get.call(component) : Reflect.get(target, key, receiver);
33
- }
34
- });
23
+ cache ??= new WeakMap();
24
+ let context = cache.get(component);
25
+
26
+ if (!context) {
27
+ const proto = Object.getPrototypeOf(component);
28
+
29
+ context = proto !== Component.prototype ? new Proxy(component.state, {
30
+ has(target, key) {
31
+ const own = Object.getOwnPropertyDescriptor(proto, key);
32
+ return own?.get ? true : key in target;
33
+ },
34
+ get(target, key, receiver) {
35
+ const own = Object.getOwnPropertyDescriptor(proto, key);
36
+ return own?.get ? own.get.call(component) : Reflect.get(target, key, receiver);
37
+ }
38
+ }) : component.state;
39
+
40
+ cache.set(component, context);
41
+ }
35
42
 
36
43
  for (const apply of component.directives) apply(context);
37
44
  component.update?.(context);
@@ -80,6 +87,30 @@ export default class Component {
80
87
 
81
88
  #state() {
82
89
 
90
+ const state = this.el.dataset.state
91
+ ? toJSON(this.el.dataset.state)
92
+ : structuredClone(this.constructor.state);
93
+
94
+ const instance = this;
95
+ const cache = new WeakMap();
96
+
97
+ const reactive = (obj) => {
98
+ if (cache.has(obj)) return cache.get(obj);
99
+ const proxy = new Proxy(obj, {
100
+ get(target, key, receiver) {
101
+ const value = Reflect.get(target, key, receiver);
102
+ return isArray(value) || isObject(value) ? reactive(value) : value;
103
+ },
104
+ set(target, key, value) {
105
+ target[key] = value;
106
+ enqueue(instance);
107
+ return true;
108
+ }
109
+ });
110
+ cache.set(obj, proxy);
111
+ return proxy;
112
+ };
113
+
83
114
  traverse(this.el, child => {
84
115
  for (const {name, value} of [...child.attributes]) {
85
116
  if (name.startsWith(':')) {
@@ -92,18 +123,8 @@ export default class Component {
92
123
  }
93
124
  });
94
125
 
95
- const state = this.el.dataset.state
96
- ? toJSON(this.el.dataset.state)
97
- : structuredClone(this.constructor.state);
98
-
99
- const set = (target, key, value) => {
100
- target[key] = value;
101
- enqueue(this);
102
- return true;
103
- };
104
-
105
126
  enqueue(this);
106
- return new Proxy(state, {set});
127
+ return reactive(state);
107
128
  }
108
129
 
109
130
  findOne(s) {
@@ -116,8 +137,8 @@ export default class Component {
116
137
 
117
138
  on(event, target, fn) {
118
139
  const off = on(event, target, fn, this.el);
119
- this.on.destroy ??= [];
120
- this.on.destroy.push(off);
140
+ this.offs ??= [];
141
+ this.offs.push(off);
121
142
  return off;
122
143
  }
123
144
 
@@ -127,9 +148,10 @@ export default class Component {
127
148
  }
128
149
 
129
150
  destroy() {
130
- this.on.destroy?.forEach(off => off());
131
151
  this.controller?.abort();
132
152
  this.directives.length = 0;
133
- queue.delete(this);
153
+ this.offs?.forEach(off => off());
154
+ cache?.delete(this);
155
+ queue?.delete(this);
134
156
  }
135
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmbr/component",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "repository": "github:nikrowell/tmbr-toolkit",
5
5
  "type": "module",
6
6
  "main": "index.js",