@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.
- package/README.md +151 -0
- package/bind.js +125 -0
- package/index.js +135 -0
- 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
|
+
}
|