@tmbr/component 1.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.
Files changed (4) hide show
  1. package/README.md +151 -0
  2. package/bind.js +125 -0
  3. package/index.js +135 -0
  4. package/package.json +17 -0
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # Component
2
+
3
+ A base class for enhancing DOM elements with reactive state, similar to [Alpine.js](https://alpinejs.dev/) and [petite-vue](https://github.com/vuejs/petite-vue), but without loops and conditionals.
4
+
5
+ ```bash
6
+ npm install @tmbr/component
7
+ ```
8
+
9
+ ## Simple
10
+
11
+ Define state inline with a `data-state` attribute.
12
+
13
+ ```html
14
+ <div data-state="{count: 0}">
15
+ <button type="button" @click="count--">Remove</button>
16
+ <span :text="count"></span>
17
+ <button type="button" @click="count++">Add</button>
18
+ </div>
19
+ ```
20
+ ```js
21
+ document.querySelectorAll('[data-state]').forEach(el => {
22
+ new Component(el);
23
+ });
24
+ ```
25
+
26
+ ## Extended
27
+
28
+ Subclass `Component` to define reusable components with DOM refs, methods, and lifecycle hooks.
29
+
30
+ ```html
31
+ <div id="counter">
32
+ <button type="button" @click="dec" :disabled="count <= 0">Remove</button>
33
+ <span :text="count"></span>
34
+ <button type="button" @click="inc">Add</button>
35
+ <span :show="count >= 10">Count is dangerously high</span>
36
+ </div>
37
+ ```
38
+ ```js
39
+ class Counter extends Component {
40
+
41
+ static state = {
42
+ count: 0
43
+ };
44
+
45
+ init() {
46
+ console.log('mounted', this.el);
47
+ }
48
+
49
+ inc() {
50
+ this.state.count++;
51
+ }
52
+
53
+ dec() {
54
+ this.state.count--;
55
+ }
56
+
57
+ update(state) {
58
+ console.log('updated', state.count);
59
+ }
60
+ }
61
+
62
+ new Counter('#counter');
63
+ ```
64
+
65
+ ## Directives
66
+
67
+ Directives are expressions evaluated with the current state.
68
+
69
+ | Directive | Effect |
70
+ | -----------------------------| --------------------------------------------------------------|
71
+ | `:text` | sets `textContent` |
72
+ | `:html` | sets `innerHTML` |
73
+ | `:show` | toggles `display: none` |
74
+ | `:value` | sets `.value` on inputs (one-way) |
75
+ | `:model` | two-way binding for form elements |
76
+ | `:class` | merges classes from a string, array, or `{name: bool}` object |
77
+ | `:disabled`, `:hidden`, etc. | boolean attributes — set when truthy, removed when falsy |
78
+ | `:attribute` | `setAttribute` fallback for any other attribute |
79
+
80
+ ## Events
81
+
82
+ Events are attached with `@event.modifier="handler"` where handler is an expression or component method.
83
+
84
+ | Modifier | Effect |
85
+ | ----------- | ----------------------------------------------------- |
86
+ | `.prevent` | calls `preventDefault()` |
87
+ | `.stop` | calls `stopPropagation()` |
88
+ | `.self` | only fires when `event.target` is the current element |
89
+ | `.once` | auto-removes after first invocation |
90
+ | `.passive` | passive event listener |
91
+ | `.capture` | capture phase listener |
92
+ | `.outside` | fires on events outside the element |
93
+ | `.window` | listens to `window` |
94
+ | `.document` | listens to `document` |
95
+
96
+ ## Refs
97
+
98
+ Elements with a `ref` attribute are collected into `this.dom`. Multiple elements with the same name are grouped into an array. Use bracket syntax `ref="[items]"` to force an array even with a single element.
99
+
100
+ ```html
101
+ <div>
102
+ <input ref="input" />
103
+ <ul>
104
+ <li ref="[items]">one</li>
105
+ <li ref="[items]">two</li>
106
+ </ul>
107
+ </div>
108
+ ```
109
+
110
+ ## Computed
111
+
112
+ Getters on the subclass expose derived values that are accessible in template expressions and the `update()` hook.
113
+
114
+ ```js
115
+ class Example extends Component {
116
+
117
+ static state = {
118
+ name: 'Jane Doe'
119
+ };
120
+
121
+ get firstName() {
122
+ return this.state.name.split(' ')[0];
123
+ }
124
+ }
125
+ ```
126
+
127
+ ```html
128
+ <input type="text" :model="name" />
129
+ First name is <span :text="firstName"></span>
130
+ ```
131
+
132
+ ```js
133
+ this.dom.input // input
134
+ this.dom.items // [li, li]
135
+ ```
136
+
137
+ ## Instance API
138
+
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 |
package/bind.js ADDED
@@ -0,0 +1,125 @@
1
+ import { cx, isFunction } from '@tmbr/utils';
2
+
3
+ const BOOLEANS = new Set([
4
+ 'allowfullscreen',
5
+ 'async',
6
+ 'autofocus',
7
+ 'autoplay',
8
+ 'checked',
9
+ 'controls',
10
+ 'default',
11
+ 'defer',
12
+ 'disabled',
13
+ 'formnovalidate',
14
+ 'hidden',
15
+ 'ismap',
16
+ 'itemscope',
17
+ 'loop',
18
+ 'multiple',
19
+ 'muted',
20
+ 'nomodule',
21
+ 'novalidate',
22
+ 'open',
23
+ 'playsinline',
24
+ 'readonly',
25
+ 'required',
26
+ 'reversed',
27
+ 'selected'
28
+ ]);
29
+
30
+ export function bindDirective(component, node, attr, expression) {
31
+
32
+ const fn = new Function('state', `
33
+ try {
34
+ var result;
35
+ with (state) { result = ${expression} };
36
+ return result;
37
+ } catch(e) {
38
+ console.warn('[Component]', e);
39
+ }`);
40
+
41
+ let apply;
42
+
43
+ if (attr === 'model') {
44
+
45
+ const isCheckbox = node.type === 'checkbox';
46
+ const isRadio = node.type === 'radio';
47
+ const isNumber = node.type === 'number' || node.type === 'range';
48
+ const isSelect = node.nodeName === 'SELECT';
49
+
50
+ if (isCheckbox) {
51
+ apply = value => node.checked = !!value;
52
+ } else if (isRadio) {
53
+ apply = value => node.checked = node.value === value;
54
+ } else {
55
+ apply = value => node.value = value ?? '';
56
+ }
57
+
58
+ component.controller ??= new AbortController();
59
+ const type = (isCheckbox || isRadio || isSelect) ? 'change' : 'input';
60
+
61
+ node.addEventListener(type, event => {
62
+ const value = isCheckbox ? event.target.checked : event.target.value;
63
+ component.state[expression] = isNumber ? Number(value) : value;
64
+ }, {signal: component.controller.signal});
65
+
66
+ } else if (attr === 'text') {
67
+ apply = value => node.textContent = value;
68
+ } else if (attr === 'html') {
69
+ apply = value => node.innerHTML = value;
70
+ } else if (attr === 'value') {
71
+ apply = value => node.value = value;
72
+ } else if (attr === 'show') {
73
+ const display = node.style.display || '';
74
+ apply = value => node.style.display = value ? display : 'none';
75
+ } else if (attr === 'class') {
76
+ const initial = node.className;
77
+ apply = value => { node.className = initial; cx(node, value); };
78
+ } else if (BOOLEANS.has(attr)) {
79
+ apply = value => value ? node.setAttribute(attr, '') : node.removeAttribute(attr);
80
+ } else {
81
+ apply = value => node.setAttribute(attr, value);
82
+ }
83
+
84
+ component.directives.push(state => apply(fn(state)));
85
+ }
86
+
87
+ export function bindEvent(component, node, attr, value) {
88
+
89
+ const [name, ...modifiers] = attr.split('.');
90
+
91
+ let target = node;
92
+
93
+ if (modifiers.includes('window')) {
94
+ target = window;
95
+ } else if (modifiers.includes('document') || modifiers.includes('outside')) {
96
+ target = document;
97
+ }
98
+
99
+ const callback = isFunction(component[value])
100
+ ? component[value]
101
+ : new Function('event', 'state', `
102
+ try {
103
+ with (state) { ${value} };
104
+ } catch (e) {
105
+ console.warn('[Component]', e);
106
+ }`);
107
+
108
+ component.controller ??= new AbortController();
109
+ const signal = component.controller.signal;
110
+
111
+ const options = {signal};
112
+ if (modifiers.includes('once')) options.once = true;
113
+ if (modifiers.includes('capture')) options.capture = true;
114
+ if (modifiers.includes('passive')) options.passive = true;
115
+
116
+ function listener(event) {
117
+ if (modifiers.includes('outside') && node.contains(event.target)) return;
118
+ if (modifiers.includes('self') && node !== event.target) return;
119
+ if (modifiers.includes('stop')) event.stopPropagation();
120
+ if (modifiers.includes('prevent')) event.preventDefault();
121
+ callback.call(component, event, component.state);
122
+ }
123
+
124
+ target.addEventListener(name, listener, options);
125
+ }
package/index.js ADDED
@@ -0,0 +1,135 @@
1
+ import { on, findOne, findAll, isDefined, isString, toJSON, traverse } from '@tmbr/utils';
2
+ import { bindDirective, bindEvent } from './bind.js';
3
+
4
+ let queue;
5
+ let scheduled = false;
6
+
7
+ function enqueue(component) {
8
+ queue ??= new Set();
9
+ queue.add(component);
10
+
11
+ if (scheduled) return;
12
+ queueMicrotask(flush);
13
+ scheduled = true;
14
+ }
15
+
16
+ function flush() {
17
+ for (const component of queue) render(component);
18
+ queue.clear();
19
+ scheduled = false;
20
+ }
21
+
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
+ });
35
+
36
+ for (const apply of component.directives) apply(context);
37
+ component.update?.(context);
38
+ }
39
+
40
+ export default class Component {
41
+
42
+ static state = {};
43
+
44
+ constructor(el) {
45
+ this.el = isString(el) ? findOne(el) : el;
46
+
47
+ if (!this.el) {
48
+ console.warn(`[Component] ${el} element not found`);
49
+ return;
50
+ }
51
+
52
+ this.dom = this.findAll('[ref]').reduce((result, child) => {
53
+
54
+ let key = child.getAttribute('ref');
55
+ child.removeAttribute('ref');
56
+
57
+ const hasBrackets = key.includes('[');
58
+ hasBrackets && (key = key.replace(/[\[\]]/g, ''));
59
+
60
+ const asArray = hasBrackets || isDefined(result[key]);
61
+
62
+ result[key] = asArray ? [].concat(result[key] ?? [], child) : child;
63
+ return result;
64
+
65
+ }, {});
66
+
67
+ this.controller = null;
68
+ this.directives = [];
69
+
70
+ const hasProps = this.el.hasAttribute('data-props');
71
+ this.props = hasProps ? toJSON(this.el.dataset.props) : {};
72
+
73
+ const hasState = this.el.hasAttribute('data-state') || this.constructor.state;
74
+ this.state = hasState ? this.#state() : {};
75
+
76
+ this.el.removeAttribute('data-props');
77
+ this.el.removeAttribute('data-state');
78
+ this.init?.();
79
+ }
80
+
81
+ #state() {
82
+
83
+ traverse(this.el, child => {
84
+ for (const {name, value} of [...child.attributes]) {
85
+ if (name.startsWith(':')) {
86
+ bindDirective(this, child, name.slice(1), value);
87
+ child.removeAttribute(name);
88
+ } else if (name.startsWith('@')) {
89
+ bindEvent(this, child, name.slice(1), value);
90
+ child.removeAttribute(name);
91
+ }
92
+ }
93
+ });
94
+
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
+ enqueue(this);
106
+ return new Proxy(state, {set});
107
+ }
108
+
109
+ findOne(s) {
110
+ return findOne(s, this.el);
111
+ }
112
+
113
+ findAll(s) {
114
+ return findAll(s, this.el);
115
+ }
116
+
117
+ on(event, target, fn) {
118
+ const off = on(event, target, fn, this.el);
119
+ this.on.destroy ??= [];
120
+ this.on.destroy.push(off);
121
+ return off;
122
+ }
123
+
124
+ dispatch(type, detail, options = {}) {
125
+ const e = new CustomEvent(type, {detail, ...options});
126
+ return this.el.dispatchEvent(e);
127
+ }
128
+
129
+ destroy() {
130
+ this.on.destroy?.forEach(off => off());
131
+ this.controller?.abort();
132
+ this.directives.length = 0;
133
+ queue.delete(this);
134
+ }
135
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@tmbr/component",
3
+ "version": "1.0.0",
4
+ "repository": "github:nikrowell/tmbr-toolkit",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "bind.js",
9
+ "index.js"
10
+ ],
11
+ "scripts": {
12
+ "start": "npx esbuild index.js --outdir=. --servedir=. --format=esm --bundle --watch"
13
+ },
14
+ "dependencies": {
15
+ "@tmbr/utils": "^2.13.1"
16
+ }
17
+ }