mi-element 0.1.0 → 0.3.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 CHANGED
@@ -2,13 +2,26 @@
2
2
 
3
3
  > a lightweight alternative to write web components
4
4
 
5
- mi-element provides further features to build web applications through
5
+ Only weights 2.3kB minified and gzipped.
6
+
7
+ mi-element provides features to build web applications through
6
8
  [Web Components][] like:
7
9
 
10
+ - type coercion with setAttribute
8
11
  - controllers to hook into the components lifecycle
9
12
  - ContextProvider, ContextConsumer for data provisioning from outside of a
10
13
  component
11
14
  - Store for managing shared state across components
15
+ - Signal for reactive behavior
16
+
17
+ The motivation to build this module comes from the confusions around attributes
18
+ and properties. "mi-element" solves this by providing the same results when
19
+ setting objects or functions either through `el.setAttribute(name, value)` or
20
+ properties `el[name] = value`.
21
+
22
+ Furthermore all observed attributes have a reactive behavior through the use of
23
+ signals and effects. It implements signals (loosely) following the
24
+ [TC39 JavaScript Signals standard proposal][].
12
25
 
13
26
  # Usage
14
27
 
@@ -21,7 +34,7 @@ npm i mi-element
21
34
  ```js
22
35
  /** @file ./mi-counter.js */
23
36
 
24
- import { MiElement, define, refsById } from 'mi-element'
37
+ import { MiElement, define, refsById, Signal } from 'mi-element'
25
38
 
26
39
  // define your Component
27
40
  class MiCounter extends MiElement {
@@ -29,13 +42,12 @@ class MiCounter extends MiElement {
29
42
  <style>
30
43
  :host { font-size: 1.25rem; }
31
44
  </style>
32
- <button id="decrement" aria-label="Decrement counter"> - </button>
33
- <span id aria-label="Counter value">0</span>
45
+ <div id aria-label="Counter value">0</div>
34
46
  <button id="increment" aria-label="Increment counter"> + </button>
35
47
  `
36
48
 
37
- // declare reactive attributes
38
49
  static get attributes() {
50
+ // declare reactive attribute(s)
39
51
  return { count: 0 }
40
52
  }
41
53
 
@@ -44,13 +56,14 @@ class MiCounter extends MiElement {
44
56
  // gather refs from template (here by id)
45
57
  this.refs = refsById(this.renderRoot)
46
58
  // apply event listeners
47
- this.refs.button.decrement.addEventListener('click', () => this.count--)
48
- this.refs.button.increment.addEventListener('click', () => this.count++)
49
- }
50
-
51
- // called on every change of an observed attributes via `this.requestUpdate()`
52
- update() {
53
- this.refs.span.textContent = this.count
59
+ this.refs.increment.addEventListener('click', () => {
60
+ // change observed and reactive attribute...
61
+ this.count++
62
+ })
63
+ Signal.effect(() => {
64
+ // ...triggers update on every change of `this.count`
65
+ this.refs.div.textContent = this.count
66
+ })
54
67
  }
55
68
  }
56
69
 
@@ -58,7 +71,7 @@ class MiCounter extends MiElement {
58
71
  define('mi-counter', MiCounter)
59
72
  ```
60
73
 
61
- Now use in your HTML
74
+ Now use your now component in your HTML
62
75
 
63
76
  ```html
64
77
  <body>
@@ -75,16 +88,22 @@ In `./example` you'll find a working sample of a Todo App. Check it out with
75
88
 
76
89
  - [lifecycle][docs-lifecycle] mi-element's lifecycle
77
90
  - [controller][docs-controller] adding controllers to mi-element to hook into the lifecycle
78
- - [context][docs-context] Implementation of the [Context Protocol][].
91
+ - [signal][docs-signal] Signals and effect for reactive behavior
79
92
  - [store][docs-store] Manage shared state in an application
93
+ - [context][docs-context] Implementation of the [Context Protocol][].
94
+ - [styling][docs-styling] Styling directives for "class" and "style"
80
95
 
81
96
  # License
82
97
 
83
98
  MIT licensed
84
99
 
85
- [Context Protocol]: https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
86
- [Web Components]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks
87
100
  [docs-lifecycle]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/lifecycle.md
88
101
  [docs-controller]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/controller.md
89
102
  [docs-context]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/context.md
103
+ [docs-signal]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/signal.md
90
104
  [docs-store]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/store.md
105
+ [docs-styling]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/styling.md
106
+ [Context Protocol]: https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
107
+ [Web Components]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks
108
+ [TC39 JavaScript Signals standard proposal]: https://github.com/tc39/proposal-signals
109
+ [krausest/js-framework-benchmark]: https://github.com/krausest/js-framework-benchmark
package/dist/case.js ADDED
@@ -0,0 +1,3 @@
1
+ const camelToKebabCase = (str = "") => str.replace(/([A-Z])/g, ((_, m) => `-${m.toLowerCase()}`)), kebabToCamelCase = (str = "") => str.toLowerCase().replace(/[-_]\w/g, (m => m[1].toUpperCase()));
2
+
3
+ export { camelToKebabCase, kebabToCamelCase };
package/dist/context.js CHANGED
@@ -1,8 +1,8 @@
1
- import { isSignalLike, createSignal } from './signal.js';
1
+ import { createSignal, effect } from './signal.js';
2
2
 
3
3
  class ContextProvider {
4
4
  constructor(host, context, initialValue) {
5
- this.host = host, this.context = context, this.state = isSignalLike(initialValue) ? initialValue : createSignal(initialValue),
5
+ this.host = host, this.context = context, this.state = createSignal(initialValue),
6
6
  this.host.addController?.(this);
7
7
  }
8
8
  hostConnected() {
@@ -11,20 +11,19 @@ class ContextProvider {
11
11
  hostDisconnected() {
12
12
  this.host.removeEventListener("context-request", this.onContextRequest);
13
13
  }
14
- set value(newValue) {
15
- this.state.value = newValue;
14
+ set(newValue) {
15
+ this.state.set(newValue);
16
16
  }
17
- get value() {
18
- return this.state.value;
19
- }
20
- notify() {
21
- this.state.notify();
17
+ get() {
18
+ return this.state.get();
22
19
  }
23
20
  onContextRequest=ev => {
24
21
  if (ev.context !== this.context) return;
25
- ev.stopPropagation();
26
- const unsubscribe = ev.subscribe ? this.state.subscribe(ev.callback) : void 0;
27
- ev.callback(this.value, unsubscribe);
22
+ let unsubscribe;
23
+ ev.stopPropagation(), ev.subscribe && (unsubscribe = effect((() => {
24
+ const value = this.get();
25
+ unsubscribe && ev.callback(value, unsubscribe);
26
+ }))), ev.callback(this.get(), unsubscribe);
28
27
  };
29
28
  }
30
29
 
package/dist/element.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { camelToKebabCase } from './case.js';
2
+
3
+ import { createSignal } from './signal.js';
4
+
1
5
  class MiElement extends HTMLElement {
2
6
  #attr={};
3
7
  #attrLc=new Map;
@@ -9,19 +13,19 @@ class MiElement extends HTMLElement {
9
13
  mode: 'open'
10
14
  };
11
15
  constructor() {
12
- super(), this.#attr = {
13
- ...this.constructor.attributes
14
- }, this.#observedAttributes();
16
+ super(), this.#observedAttributes(this.constructor.attributes);
15
17
  }
16
- #observedAttributes() {
17
- for (const [name, val] of Object.entries(this.#attr)) this.#types.set(name, initialType(val)),
18
- this.#attrLc.set(name.toLowerCase(), name), Object.defineProperty(this, name, {
18
+ #observedAttributes(attributes = {}) {
19
+ for (const [name, value] of Object.entries(attributes)) this.#types.set(name, initialType(value)),
20
+ this.#attrLc.set(name.toLowerCase(), name), this.#attrLc.set(camelToKebabCase(name), name),
21
+ this.#attr[name] = createSignal(value), Object.defineProperty(this, name, {
19
22
  enumerable: !0,
20
23
  get() {
21
- return this.#attr[name];
24
+ return this.#attr[name].get();
22
25
  },
23
26
  set(newValue) {
24
- this.#attr[name] !== newValue && (this.#attr[name] = newValue, this.#changedAttr[name] = newValue,
27
+ const oldValue = this.#attr[name].get();
28
+ oldValue !== newValue && (this.#attr[name].set(newValue), this.#changedAttr[name] = oldValue,
25
29
  this.requestUpdate());
26
30
  }
27
31
  });
@@ -41,16 +45,20 @@ class MiElement extends HTMLElement {
41
45
  disconnectedCallback() {
42
46
  this.#disposers.forEach((remover => remover())), this.#controllers.forEach((controller => controller.hostDisconnected?.()));
43
47
  }
44
- attributeChangedCallback(name, _oldValue, newValue) {
45
- const attr = this.#getName(name), type = this.#getType(attr), _newValue = convertType(newValue, type);
46
- this.#attr[attr] = this.#changedAttr[attr] = _newValue, 'Boolean' === type && 'false' === newValue && this.removeAttribute(name),
47
- this.requestUpdate();
48
+ attributeChangedCallback(name, oldValue, newValue) {
49
+ const attr = this.#getName(name), type = this.#getType(attr);
50
+ this.#changedAttr[attr] = this[attr], this[attr] = convertType(newValue, type),
51
+ 'Boolean' === type && 'false' === newValue && this.removeAttribute(name), this.requestUpdate();
48
52
  }
49
53
  setAttribute(name, newValue) {
50
54
  const attr = this.#getName(name);
51
55
  if (!(attr in this.#attr)) return;
52
56
  const type = this.#getType(attr);
53
- this.#attr[attr] = this.#changedAttr[attr] = newValue, 'Boolean' === type ? !0 === newValue || '' === newValue ? super.setAttribute(name, '') : super.removeAttribute(name) : [ 'String', 'Number' ].includes(type) || !0 === newValue ? super.setAttribute(name, newValue) : this.requestUpdate();
57
+ 'Boolean' === type ? !0 === newValue || '' === newValue ? super.setAttribute(name, '') : super.removeAttribute(name) : [ 'String', 'Number' ].includes(type) || !0 === newValue ? super.setAttribute(name, newValue) : (this.#changedAttr[attr] = this[attr],
58
+ this[attr] = newValue, this.requestUpdate());
59
+ }
60
+ shouldUpdate(_changedAttributes) {
61
+ return !0;
54
62
  }
55
63
  requestUpdate() {
56
64
  this.isConnected && requestAnimationFrame((() => {
@@ -61,9 +69,6 @@ class MiElement extends HTMLElement {
61
69
  template instanceof HTMLTemplateElement && this.renderRoot.appendChild(template.content.cloneNode(!0));
62
70
  }
63
71
  render() {}
64
- shouldUpdate(_changedAttributes) {
65
- return !0;
66
- }
67
72
  update(_changedAttributes) {}
68
73
  on(eventName, listener, node = this) {
69
74
  node.addEventListener(eventName, listener), this.#disposers.add((() => node.removeEventListener(eventName, listener)));
@@ -73,9 +78,11 @@ class MiElement extends HTMLElement {
73
78
  once: !0
74
79
  });
75
80
  }
76
- dispose(listener) {
77
- if ('function' != typeof listener) throw new TypeError('listener must be a function');
78
- this.#disposers.add(listener);
81
+ dispose(...listeners) {
82
+ for (const listener of listeners) {
83
+ if ('function' != typeof listener) throw new TypeError('listener must be a function');
84
+ this.#disposers.add(listener);
85
+ }
79
86
  }
80
87
  addController(controller) {
81
88
  this.#controllers.add(controller), this.isConnected && controller.hostConnected?.();
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ export { esc, escAttr, escHtml } from './escape.js';
6
6
 
7
7
  export { refsById, refsBySelector } from './refs.js';
8
8
 
9
- export { Signal, createSignal, isSignalLike } from './signal.js';
9
+ export { default as Signal } from './signal.js';
10
10
 
11
- export { Store, subscribeToStore } from './store.js';
11
+ export { Store } from './store.js';
12
+
13
+ export { classMap, styleMap } from './styling.js';
package/dist/refs.js CHANGED
@@ -1,15 +1,15 @@
1
+ import { kebabToCamelCase } from './case.js';
2
+
1
3
  function refsById(container) {
2
4
  const nodes = container.querySelectorAll?.('[id]') || [], found = {};
3
5
  for (const node of nodes) found[kebabToCamelCase(node.getAttribute('id') || node.nodeName.toLowerCase())] = node;
4
6
  return found;
5
7
  }
6
8
 
7
- const kebabToCamelCase = (str = "") => str.toLowerCase().replace(/[-_]\w/g, (m => m[1].toUpperCase()));
8
-
9
9
  function refsBySelector(container, selectors) {
10
10
  const found = {};
11
11
  for (const [name, selector] of Object.entries(selectors)) found[name] = container.querySelector?.(selector);
12
12
  return found;
13
13
  }
14
14
 
15
- export { kebabToCamelCase, refsById, refsBySelector };
15
+ export { refsById, refsBySelector };
package/dist/signal.js CHANGED
@@ -1,24 +1,53 @@
1
- class Signal {
2
- _subscribers=new Set;
3
- constructor(initialValue) {
4
- this._value = initialValue;
1
+ const context = [];
2
+
3
+ class State extends EventTarget {
4
+ #value;
5
+ #equals;
6
+ constructor(value, options) {
7
+ super();
8
+ const {equals: equals} = options || {};
9
+ this.#value = value, this.#equals = equals ?? ((value, nextValue) => value === nextValue);
5
10
  }
6
- get value() {
7
- return this._value;
11
+ get() {
12
+ const running = context[context.length - 1];
13
+ return running && running.add(this), this.#value;
8
14
  }
9
- set value(newValue) {
10
- this._value !== newValue && (this._value = newValue, this.notify());
15
+ set(nextValue) {
16
+ this.#equals(this.#value, nextValue) || (this.#value = nextValue, this.dispatchEvent(new CustomEvent('signal')));
11
17
  }
12
- notify() {
13
- for (const callback of this._subscribers) callback(this._value);
18
+ }
19
+
20
+ const createSignal = (initialValue, options) => initialValue instanceof State ? initialValue : new State(initialValue, options);
21
+
22
+ function effect(cb) {
23
+ const running = new Set;
24
+ context.push(running);
25
+ try {
26
+ cb();
27
+ } finally {
28
+ context.pop();
29
+ }
30
+ for (const dep of running) dep.addEventListener('signal', cb);
31
+ return () => {
32
+ for (const dep of running) dep.removeEventListener('signal', cb);
33
+ };
34
+ }
35
+
36
+ class Computed {
37
+ #state;
38
+ constructor(cb) {
39
+ this.#state = new State, effect((() => this.#state.set(cb())));
14
40
  }
15
- subscribe(callback) {
16
- return this._subscribers.add(callback), () => {
17
- this._subscribers.delete(callback);
18
- };
41
+ get() {
42
+ return this.#state.get();
19
43
  }
20
44
  }
21
45
 
22
- const createSignal = initialValue => new Signal(initialValue), isSignalLike = possibleSignal => 'function' == typeof possibleSignal?.subscribe && 'function' == typeof possibleSignal?.notify && 'value' in possibleSignal;
46
+ var signal = {
47
+ State: State,
48
+ createSignal: createSignal,
49
+ effect: effect,
50
+ Computed: Computed
51
+ };
23
52
 
24
- export { Signal, createSignal, isSignalLike };
53
+ export { Computed, State, createSignal, signal as default, effect };
package/dist/store.js CHANGED
@@ -1,28 +1,10 @@
1
- import { Signal, isSignalLike } from './signal.js';
1
+ import { State } from './signal.js';
2
2
 
3
- class Store extends Signal {
4
- constructor(actions, initialValue) {
5
- super(initialValue);
6
- for (const [action, dispatcher] of Object.entries(actions)) this[action] = data => {
7
- this.value = dispatcher(data)(this.value);
8
- };
3
+ class Store extends State {
4
+ constructor(actions, initialValue, options) {
5
+ super(initialValue, options);
6
+ for (const [action, dispatcher] of Object.entries(actions)) this[action] = data => this.set(dispatcher(data)(this.get()));
9
7
  }
10
8
  }
11
9
 
12
- const subscribeToStore = (element, store, propOrSignal) => {
13
- if (propOrSignal instanceof Signal || isSignalLike(propOrSignal)) return void element.dispose(store.subscribe((value => {
14
- propOrSignal.value = value;
15
- })));
16
- const keys = Array.isArray(propOrSignal) ? propOrSignal : propOrSignal.split('.').filter(Boolean), last = keys.pop();
17
- if (!last) throw TypeError('need prop');
18
- let tmp = element;
19
- for (const key of keys) {
20
- if ('object' != typeof tmp[key]) throw new TypeError(`object expected for property "${key}"`);
21
- tmp = tmp[key];
22
- }
23
- element.dispose(store.subscribe((value => {
24
- tmp[last] = value, element.requestUpdate();
25
- })));
26
- };
27
-
28
- export { Store, subscribeToStore };
10
+ export { Store };
@@ -0,0 +1,17 @@
1
+ import { camelToKebabCase } from './case.js';
2
+
3
+ const classMap = map => {
4
+ const acc = [];
5
+ for (const [name, value] of Object.entries(map ?? {})) value && acc.push(name);
6
+ return acc.join(' ');
7
+ }, styleMap = (map, options) => {
8
+ const {unit: unit = "px"} = options || {}, acc = [];
9
+ for (const [name, value] of Object.entries(map ?? {})) {
10
+ if (null == value) continue;
11
+ const _unit = Number.isFinite(value) ? unit : '';
12
+ acc.push(`${camelToKebabCase(name)}:${value}${_unit}`);
13
+ }
14
+ return acc.join(';');
15
+ };
16
+
17
+ export { classMap, styleMap };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mi-element",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Build lightweight reactive micro web-components",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/commenthol/mi-element/tree/main/packages/mi-element#readme",
@@ -29,6 +29,11 @@
29
29
  "development": "./src/element.js",
30
30
  "types": "./types/element.d.ts"
31
31
  },
32
+ "./case": {
33
+ "default": "./dist/case.js",
34
+ "development": "./src/case.js",
35
+ "types": "./types/case.d.ts"
36
+ },
32
37
  "./context": {
33
38
  "default": "./dist/context.js",
34
39
  "development": "./src/context.js",
@@ -54,6 +59,11 @@
54
59
  "development": "./src/store.js",
55
60
  "types": "./types/store.d.ts"
56
61
  },
62
+ "./styling": {
63
+ "default": "./dist/styling.js",
64
+ "development": "./src/styling.js",
65
+ "types": "./types/styling.d.ts"
66
+ },
57
67
  "./package.json": {
58
68
  "default": "./package.json"
59
69
  }
@@ -83,8 +93,9 @@
83
93
  "vitest": "^2.0.5"
84
94
  },
85
95
  "scripts": {
86
- "all": "npm-run-all lint test build",
87
- "build": "rimraf dist && rollup -c && gzip dist/index.min.js && ls -al dist && rimraf dist/index.min.*",
96
+ "all": "npm-run-all pretty lint test build types",
97
+ "build": "rimraf dist && rollup -c && gzip -k dist/index.min.js && ls -al dist && rimraf dist/index.min.*",
98
+ "docs": "cd docs; for f in *.md; do markedpp -s -i $f; done",
88
99
  "example": "vite --open /example/",
89
100
  "lint": "eslint",
90
101
  "pretty": "prettier -w **/*.js",
package/src/case.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * convert lowerCamelCase to kebab-case
3
+ * @param {string} str
4
+ * @returns {string}
5
+ */
6
+ export const camelToKebabCase = (str = '') =>
7
+ str.replace(/([A-Z])/g, (_, m) => `-${m.toLowerCase()}`)
8
+
9
+ /**
10
+ * convert kebab-case to lowerCamelCase
11
+ * @param {string} str
12
+ * @returns {string}
13
+ */
14
+ export const kebabToCamelCase = (str = '') =>
15
+ str.toLowerCase().replace(/[-_]\w/g, (m) => m[1].toUpperCase())
package/src/context.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @see https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
3
3
  */
4
4
 
5
- import { createSignal, isSignalLike } from './signal.js'
5
+ import { createSignal, effect } from './signal.js'
6
6
 
7
7
  /**
8
8
  * @typedef {import('./element.js').HostController} HostController
@@ -25,9 +25,7 @@ export class ContextProvider {
25
25
  constructor(host, context, initialValue) {
26
26
  this.host = host
27
27
  this.context = context
28
- this.state = isSignalLike(initialValue)
29
- ? initialValue
30
- : createSignal(initialValue)
28
+ this.state = createSignal(initialValue)
31
29
  // @ts-expect-error
32
30
  this.host.addController?.(this)
33
31
  }
@@ -45,24 +43,20 @@ export class ContextProvider {
45
43
  /**
46
44
  * @param {any} newValue
47
45
  */
48
- set value(newValue) {
49
- this.state.value = newValue
46
+ set(newValue) {
47
+ this.state.set(newValue)
50
48
  }
51
49
 
52
50
  /**
53
51
  * @returns {any}
54
52
  */
55
- get value() {
56
- return this.state.value
57
- }
58
-
59
- notify() {
60
- this.state.notify()
53
+ get() {
54
+ return this.state.get()
61
55
  }
62
56
 
63
57
  /**
64
58
  * @private
65
- * @param {ContextRequestEvent} ev
59
+ * @param {ContextRequestEvent} ev
66
60
  */
67
61
  onContextRequest = (ev) => {
68
62
  if (ev.context !== this.context) {
@@ -70,10 +64,17 @@ export class ContextProvider {
70
64
  return
71
65
  }
72
66
  ev.stopPropagation()
73
- const unsubscribe = ev.subscribe
74
- ? this.state.subscribe(ev.callback)
75
- : undefined
76
- ev.callback(this.value, unsubscribe)
67
+ console.debug('provider.onContextRequest', this.state)
68
+ let unsubscribe
69
+ if (ev.subscribe) {
70
+ unsubscribe = effect(() => {
71
+ // needed to subscribe to signal
72
+ const value = this.get()
73
+ // don't call callback the first time where unsubscribe is not defined
74
+ if (unsubscribe) ev.callback(value, unsubscribe)
75
+ })
76
+ }
77
+ ev.callback(this.get(), unsubscribe)
77
78
  }
78
79
  }
79
80
 
@@ -140,6 +141,7 @@ export class ContextConsumer {
140
141
  }
141
142
 
142
143
  _callback(value, unsubscribe) {
144
+ console.debug('consumer.callback', { value, unsubscribe })
143
145
  if (unsubscribe) {
144
146
  if (!this.subscribe) {
145
147
  // unsubscribe as we didn't ask for subscription