mi-element 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -4,13 +4,24 @@
4
4
 
5
5
  Only weights 2.3kB minified and gzipped.
6
6
 
7
- mi-element provides further features to build web applications through
7
+ mi-element provides features to build web applications through
8
8
  [Web Components][] like:
9
9
 
10
+ - type coercion with setAttribute
10
11
  - controllers to hook into the components lifecycle
11
12
  - ContextProvider, ContextConsumer for data provisioning from outside of a
12
13
  component
13
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][].
14
25
 
15
26
  # Usage
16
27
 
@@ -23,7 +34,7 @@ npm i mi-element
23
34
  ```js
24
35
  /** @file ./mi-counter.js */
25
36
 
26
- import { MiElement, define, refsById } from 'mi-element'
37
+ import { MiElement, define, refsById, Signal } from 'mi-element'
27
38
 
28
39
  // define your Component
29
40
  class MiCounter extends MiElement {
@@ -31,13 +42,12 @@ class MiCounter extends MiElement {
31
42
  <style>
32
43
  :host { font-size: 1.25rem; }
33
44
  </style>
34
- <button id="decrement" aria-label="Decrement counter"> - </button>
35
- <span id aria-label="Counter value">0</span>
45
+ <div id aria-label="Counter value">0</div>
36
46
  <button id="increment" aria-label="Increment counter"> + </button>
37
47
  `
38
48
 
39
- // declare reactive attributes
40
49
  static get attributes() {
50
+ // declare reactive attribute(s)
41
51
  return { count: 0 }
42
52
  }
43
53
 
@@ -46,13 +56,14 @@ class MiCounter extends MiElement {
46
56
  // gather refs from template (here by id)
47
57
  this.refs = refsById(this.renderRoot)
48
58
  // apply event listeners
49
- this.refs.button.decrement.addEventListener('click', () => this.count--)
50
- this.refs.button.increment.addEventListener('click', () => this.count++)
51
- }
52
-
53
- // called on every change of an observed attributes via `this.requestUpdate()`
54
- update() {
55
- 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
+ })
56
67
  }
57
68
  }
58
69
 
@@ -60,7 +71,7 @@ class MiCounter extends MiElement {
60
71
  define('mi-counter', MiCounter)
61
72
  ```
62
73
 
63
- Now use in your HTML
74
+ Now use your now component in your HTML
64
75
 
65
76
  ```html
66
77
  <body>
@@ -75,20 +86,24 @@ In `./example` you'll find a working sample of a Todo App. Check it out with
75
86
 
76
87
  # Documentation
77
88
 
78
- - [lifecycle][docs-lifecycle] mi-element's lifecycle
89
+ - [element][docs-element] mi-element's lifecycle
79
90
  - [controller][docs-controller] adding controllers to mi-element to hook into the lifecycle
80
- - [context][docs-context] Implementation of the [Context Protocol][].
91
+ - [signal][docs-signal] Signals and effect for reactive behavior
81
92
  - [store][docs-store] Manage shared state in an application
93
+ - [context][docs-context] Implementation of the [Context Protocol][].
82
94
  - [styling][docs-styling] Styling directives for "class" and "style"
83
95
 
84
96
  # License
85
97
 
86
98
  MIT licensed
87
99
 
88
- [Context Protocol]: https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
89
- [Web Components]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks
90
- [docs-lifecycle]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/lifecycle.md
100
+ [docs-element]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/element.md
91
101
  [docs-controller]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/controller.md
92
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
93
104
  [docs-store]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/store.md
94
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/element.js CHANGED
@@ -16,19 +16,22 @@ class MiElement extends HTMLElement {
16
16
  super(), this.#observedAttributes(this.constructor.attributes);
17
17
  }
18
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, {
22
- enumerable: !0,
23
- get() {
24
- return this.#attr[name].get();
25
- },
26
- set(newValue) {
27
- const oldValue = this.#attr[name].get();
28
- oldValue !== newValue && (this.#attr[name].set(newValue), this.#changedAttr[name] = oldValue,
29
- this.requestUpdate());
30
- }
31
- });
19
+ for (const [name, value] of Object.entries(attributes)) {
20
+ const initial = initialValueType(value);
21
+ this.#types.set(name, initial.type), this.#attrLc.set(name.toLowerCase(), name),
22
+ this.#attrLc.set(camelToKebabCase(name), name), this.#attr[name] = createSignal(initial.value),
23
+ Object.defineProperty(this, name, {
24
+ enumerable: !0,
25
+ get() {
26
+ return this.#attr[name].get();
27
+ },
28
+ set(newValue) {
29
+ const oldValue = this.#attr[name].get();
30
+ oldValue !== newValue && (this.#attr[name].set(newValue), this.#changedAttr[name] = oldValue,
31
+ this.requestUpdate());
32
+ }
33
+ });
34
+ }
32
35
  }
33
36
  #getName(name) {
34
37
  return this.#attrLc.get(name) || name;
@@ -54,7 +57,7 @@ class MiElement extends HTMLElement {
54
57
  const attr = this.#getName(name);
55
58
  if (!(attr in this.#attr)) return;
56
59
  const type = this.#getType(attr);
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],
60
+ '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
61
  this[attr] = newValue, this.requestUpdate());
59
62
  }
60
63
  shouldUpdate(_changedAttributes) {
@@ -66,7 +69,7 @@ class MiElement extends HTMLElement {
66
69
  }));
67
70
  }
68
71
  addTemplate(template) {
69
- template instanceof HTMLTemplateElement && this.renderRoot.appendChild(template.content.cloneNode(!0));
72
+ template instanceof HTMLTemplateElement ? this.renderRoot.appendChild(template.content.cloneNode(!0)) : console.warn('template is not a HTMLTemplateElement');
70
73
  }
71
74
  render() {}
72
75
  update(_changedAttributes) {}
@@ -99,7 +102,33 @@ const define = (name, element, options) => {
99
102
  if ('string' != typeof element.template) return;
100
103
  const el = document.createElement('template');
101
104
  el.innerHTML = element.template, element.template = el;
102
- }, initialType = value => toString.call(value).slice(8, -1), convertType = (any, type) => {
105
+ }, initialValueType = value => {
106
+ switch (value) {
107
+ case Boolean:
108
+ return {
109
+ value: void 0,
110
+ type: 'Boolean'
111
+ };
112
+
113
+ case Number:
114
+ return {
115
+ value: void 0,
116
+ type: 'Number'
117
+ };
118
+
119
+ case String:
120
+ return {
121
+ value: void 0,
122
+ type: 'String'
123
+ };
124
+
125
+ default:
126
+ return {
127
+ value: value,
128
+ type: toString.call(value).slice(8, -1)
129
+ };
130
+ }
131
+ }, convertType = (any, type) => {
103
132
  switch (type) {
104
133
  case 'Number':
105
134
  return (any => {
package/dist/escape.js CHANGED
@@ -1,9 +1,13 @@
1
- const escMap = {
1
+ class UnsafeHtml extends String {}
2
+
3
+ const unsafeHtml = str => new UnsafeHtml(str), escMap = {
2
4
  '&': '&amp;',
3
5
  '<': '&lt;',
4
6
  '>': '&gt;',
5
7
  "'": '&#39;',
6
8
  '"': '&quot;'
7
- }, escHtml = string => ('' + string).replace(/&amp;/g, '&').replace(/[&<>'"]/g, (tag => escMap[tag])), escAttr = string => ('' + string).replace(/['"]/g, (tag => escMap[tag])), esc = (strings, ...vars) => strings.map(((string, i) => string + escHtml(vars[i] ?? ''))).join('');
9
+ }, escHtml = string => string instanceof UnsafeHtml ? string : ('' + string).replace(/&amp;/g, '&').replace(/[&<>'"]/g, (tag => escMap[tag])), esc = (strings, ...vars) => String.raw({
10
+ raw: strings
11
+ }, ...vars.map(escHtml));
8
12
 
9
- export { esc, escAttr, escHtml };
13
+ export { esc, escHtml, unsafeHtml };
package/dist/index.js CHANGED
@@ -2,11 +2,11 @@ export { ContextConsumer, ContextProvider, ContextRequestEvent } from './context
2
2
 
3
3
  export { MiElement, convertType, define } from './element.js';
4
4
 
5
- export { esc, escAttr, escHtml } from './escape.js';
5
+ export { esc, escHtml, unsafeHtml } from './escape.js';
6
6
 
7
7
  export { refsById, refsBySelector } from './refs.js';
8
8
 
9
- export { default as Signal } from './signal.js';
9
+ export { Computed, default as Signal, State, createSignal, effect } from './signal.js';
10
10
 
11
11
  export { Store } from './store.js';
12
12
 
package/dist/signal.js CHANGED
@@ -1,62 +1,53 @@
1
1
  const context = [];
2
2
 
3
- class State {
4
- subscribers=new Set;
3
+ class State extends EventTarget {
4
+ #value;
5
+ #equals;
5
6
  constructor(value, options) {
7
+ super();
6
8
  const {equals: equals} = options || {};
7
- this.value = value, this.equals = equals ?? ((value, nextValue) => value === nextValue);
9
+ this.#value = value, this.#equals = equals ?? ((value, nextValue) => value === nextValue);
8
10
  }
9
11
  get() {
10
12
  const running = context[context.length - 1];
11
- return running && (this.subscribers.add(running), running.dependencies.add(this.subscribers)),
12
- this.value;
13
+ return running && running.add(this), this.#value;
13
14
  }
14
15
  set(nextValue) {
15
- if (!this.equals(this.value, nextValue)) {
16
- this.value = nextValue;
17
- for (const running of [ ...this.subscribers ]) running.execute();
18
- }
16
+ this.#equals(this.#value, nextValue) || (this.#value = nextValue, this.dispatchEvent(new CustomEvent('signal')));
19
17
  }
20
18
  }
21
19
 
22
- const createSignal = value => value instanceof State ? value : new State(value);
23
-
24
- function cleanup(running) {
25
- for (const dep of running.dependencies) dep.delete(running);
26
- running.dependencies.clear();
27
- }
20
+ const createSignal = (initialValue, options) => initialValue instanceof State ? initialValue : new State(initialValue, options);
28
21
 
29
22
  function effect(cb) {
30
- const execute = () => {
31
- cleanup(running), context.push(running);
32
- try {
33
- cb();
34
- } finally {
35
- context.pop();
36
- }
37
- }, running = {
38
- execute: execute,
39
- dependencies: new Set
40
- };
41
- return execute(), () => {
42
- cleanup(running);
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);
43
33
  };
44
34
  }
45
35
 
46
36
  class Computed {
37
+ #state;
47
38
  constructor(cb) {
48
- this.state = new State, effect((() => this.state.set(cb)));
39
+ this.#state = new State, effect((() => this.#state.set(cb())));
49
40
  }
50
41
  get() {
51
- return this.state.get();
42
+ return this.#state.get();
52
43
  }
53
44
  }
54
45
 
55
46
  var signal = {
56
47
  State: State,
48
+ Computed: Computed,
57
49
  createSignal: createSignal,
58
- effect: effect,
59
- Computed: Computed
50
+ effect: effect
60
51
  };
61
52
 
62
53
  export { Computed, State, createSignal, signal as default, effect };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mi-element",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -75,32 +75,34 @@
75
75
  "types"
76
76
  ],
77
77
  "devDependencies": {
78
- "@eslint/js": "^9.9.1",
78
+ "@eslint/js": "^9.32.0",
79
79
  "@rollup/plugin-terser": "^0.4.4",
80
- "@testing-library/dom": "^10.4.0",
81
- "@types/node": "^22.5.0",
82
- "@vitest/browser": "^2.0.5",
83
- "@vitest/coverage-istanbul": "^2.0.5",
84
- "eslint": "^9.9.1",
85
- "globals": "^15.9.0",
86
- "npm-run-all2": "^6.2.2",
87
- "playwright": "^1.46.1",
88
- "prettier": "^3.3.3",
80
+ "@testing-library/dom": "^10.4.1",
81
+ "@types/node": "^22.17.0",
82
+ "@vitest/browser": "^2.1.9",
83
+ "@vitest/coverage-istanbul": "^2.1.9",
84
+ "eslint": "^9.32.0",
85
+ "globals": "^15.15.0",
86
+ "npm-run-all2": "^6.2.6",
87
+ "playwright": "^1.54.2",
88
+ "prettier": "^3.6.2",
89
89
  "rimraf": "^6.0.1",
90
- "rollup": "^4.21.1",
91
- "typescript": "^5.5.4",
92
- "vite": "^5.4.2",
93
- "vitest": "^2.0.5"
90
+ "rollup": "^4.46.2",
91
+ "typescript": "^5.9.2",
92
+ "vite": "^5.4.19",
93
+ "vitest": "^2.1.9"
94
94
  },
95
95
  "scripts": {
96
96
  "all": "npm-run-all pretty lint test build types",
97
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",
98
99
  "example": "vite --open /example/",
99
100
  "lint": "eslint",
100
101
  "pretty": "prettier -w **/*.js",
101
102
  "test": "vitest run --coverage",
102
103
  "test:browser": "vitest --coverage",
103
104
  "dev": "npm run test:browser",
104
- "types": "tsc"
105
+ "types": "tsc",
106
+ "setup": "pnpm exec playwright install"
105
107
  }
106
108
  }
package/src/context.js CHANGED
@@ -64,7 +64,6 @@ export class ContextProvider {
64
64
  return
65
65
  }
66
66
  ev.stopPropagation()
67
- console.debug('provider.onContextRequest', this.state)
68
67
  let unsubscribe
69
68
  if (ev.subscribe) {
70
69
  unsubscribe = effect(() => {
@@ -141,7 +140,6 @@ export class ContextConsumer {
141
140
  }
142
141
 
143
142
  _callback(value, unsubscribe) {
144
- console.debug('consumer.callback', { value, unsubscribe })
145
143
  if (unsubscribe) {
146
144
  if (!this.subscribe) {
147
145
  // unsubscribe as we didn't ask for subscription
package/src/element.js CHANGED
@@ -10,17 +10,18 @@ import { createSignal } from './signal.js'
10
10
  */
11
11
 
12
12
  /**
13
- * class extening HTMLElement to enable deferred rendering on attribute changes
13
+ * class extending HTMLElement to enable deferred rendering on attribute changes
14
14
  * either via `setAttribute(name, value)` or `this[name] = value`.
15
15
  * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
16
16
  * @example
17
17
  * ```js
18
18
  * class Example extends MiElement {
19
- * // define all observed attributes with its default value.
19
+ * // define all observed attributes with its default initial value.
20
+ * // for yet to defined numbers, boolean or strings use `Number`, `Boolean`, `String`
20
21
  * // attributes are accessible via `this[prop]`
21
22
  * // avoid using attributes which are HTMLElement properties e.g. className
22
23
  * static get attributes () {
23
- * return { text: 'Hi' }
24
+ * return { text: 'Hi', num: Number }
24
25
  * }
25
26
  * render() {
26
27
  * this.renderRoot.innerHTML = `<div></div>`
@@ -43,8 +44,19 @@ import { createSignal } from './signal.js'
43
44
  * ```
44
45
  */
45
46
  export class MiElement extends HTMLElement {
47
+ /** all attributes are signals! */
46
48
  #attr = {}
49
+ /**
50
+ * lower-cased or kebab-case attribute names;
51
+ * Map<lower-cased and kebab-cased attr name, camelCased attr name as string>
52
+ * @type {Map<string, string>}
53
+ */
47
54
  #attrLc = new Map()
55
+ /**
56
+ * initial types (from `static get attributes() { return {} }`)
57
+ * Map<camelCased attribute name, type as string>
58
+ * @type {Map<string,string>}
59
+ */
48
60
  #types = new Map()
49
61
  #disposers = new Set()
50
62
  #controllers = new Set()
@@ -68,17 +80,17 @@ export class MiElement extends HTMLElement {
68
80
  */
69
81
  #observedAttributes(attributes = {}) {
70
82
  for (const [name, value] of Object.entries(attributes)) {
71
- this.#types.set(name, initialType(value))
83
+ const initial = initialValueType(value)
84
+ this.#types.set(name, initial.type)
72
85
  this.#attrLc.set(name.toLowerCase(), name)
73
86
  this.#attrLc.set(camelToKebabCase(name), name)
74
- this.#attr[name] = createSignal(value)
87
+ this.#attr[name] = createSignal(initial.value)
75
88
  Object.defineProperty(this, name, {
76
89
  enumerable: true,
77
90
  get() {
78
91
  return this.#attr[name].get()
79
92
  },
80
93
  set(newValue) {
81
- console.debug('%s.%s =', this.nodeName, name, newValue)
82
94
  const oldValue = this.#attr[name].get()
83
95
  if (oldValue === newValue) return
84
96
  this.#attr[name].set(newValue)
@@ -146,13 +158,6 @@ export class MiElement extends HTMLElement {
146
158
  if (type === 'Boolean' && newValue === 'false') {
147
159
  this.removeAttribute(name)
148
160
  }
149
- console.debug(
150
- '%s.attributeChangedCallback("%s",',
151
- this.nodeName,
152
- name,
153
- oldValue,
154
- newValue
155
- )
156
161
  this.requestUpdate()
157
162
  }
158
163
 
@@ -169,7 +174,6 @@ export class MiElement extends HTMLElement {
169
174
  return
170
175
  }
171
176
  const type = this.#getType(attr)
172
- console.debug('%s.setAttribute("%s",', this.nodeName, name, newValue)
173
177
 
174
178
  // only set string values in these cases
175
179
  if (type === 'Boolean') {
@@ -178,7 +182,7 @@ export class MiElement extends HTMLElement {
178
182
  } else {
179
183
  super.removeAttribute(name)
180
184
  }
181
- } else if (['String', 'Number'].includes(type) || newValue === true) {
185
+ } else if (['String', 'Number'].includes(type ?? '') || newValue === true) {
182
186
  super.setAttribute(name, newValue)
183
187
  } else {
184
188
  this.#changedAttr[attr] = this[attr]
@@ -216,7 +220,7 @@ export class MiElement extends HTMLElement {
216
220
  */
217
221
  addTemplate(template) {
218
222
  if (!(template instanceof HTMLTemplateElement)) {
219
- console.debug('template is not a HTMLTemplateElement')
223
+ console.warn('template is not a HTMLTemplateElement')
220
224
  return
221
225
  }
222
226
  this.renderRoot.appendChild(template.content.cloneNode(true))
@@ -327,7 +331,18 @@ const renderTemplate = (element) => {
327
331
  element.template = el
328
332
  }
329
333
 
330
- const initialType = (value) => toString.call(value).slice(8, -1)
334
+ const initialValueType = (value) => {
335
+ switch (value) {
336
+ case Boolean:
337
+ return { value: undefined, type: 'Boolean' }
338
+ case Number:
339
+ return { value: undefined, type: 'Number' }
340
+ case String:
341
+ return { value: undefined, type: 'String' }
342
+ default:
343
+ return { value, type: toString.call(value).slice(8, -1) }
344
+ }
345
+ }
331
346
 
332
347
  const toNumber = (any) => {
333
348
  const n = Number(any)
package/src/escape.js CHANGED
@@ -1,3 +1,13 @@
1
+ class UnsafeHtml extends String {}
2
+
3
+ /**
4
+ * tag a string as html for not to be escaped
5
+ * @param {string} str
6
+ * @returns {string}
7
+ */
8
+ // @ts-expect-error
9
+ export const unsafeHtml = (str) => new UnsafeHtml(str)
10
+
1
11
  const escMap = {
2
12
  '&': '&amp;',
3
13
  '<': '&lt;',
@@ -15,18 +25,12 @@ const escMap = {
15
25
  * //> &lt;h1&gt;&quot;One&quot; &amp; &#39;Two&#39; &amp; Works&lt;/h1&gt;
16
26
  */
17
27
  export const escHtml = (string) =>
18
- ('' + string).replace(/&amp;/g, '&').replace(/[&<>'"]/g, (tag) => escMap[tag])
19
-
20
- /**
21
- * escape HTML attribute
22
- * @param {string} string
23
- * @returns {string} escaped string
24
- * @example
25
- * escapeAttr("One's")
26
- * //> &quot;One&#39;s&quot;
27
- */
28
- export const escAttr = (string) =>
29
- ('' + string).replace(/['"]/g, (tag) => escMap[tag])
28
+ // @ts-expect-error
29
+ string instanceof UnsafeHtml
30
+ ? string
31
+ : ('' + string)
32
+ .replace(/&amp;/g, '&')
33
+ .replace(/[&<>'"]/g, (tag) => escMap[tag])
30
34
 
31
35
  /**
32
36
  * template literal to HTML escape all values preventing XSS
@@ -35,4 +39,4 @@ export const escAttr = (string) =>
35
39
  * @returns {string}
36
40
  */
37
41
  export const esc = (strings, ...vars) =>
38
- strings.map((string, i) => string + escHtml(vars[i] ?? '')).join('')
42
+ String.raw({ raw: strings }, ...vars.map(escHtml))
package/src/index.js CHANGED
@@ -10,14 +10,19 @@ export {
10
10
  * @typedef {import('./element.js').HostController} HostController
11
11
  */
12
12
  export { MiElement, convertType, define } from './element.js'
13
- export { esc, escAttr, escHtml } from './escape.js'
13
+ export { unsafeHtml, esc, escHtml } from './escape.js'
14
14
  export { refsById, refsBySelector } from './refs.js'
15
15
  /**
16
16
  * @template T
17
17
  * @typedef {import('./signal.js').SignalOptions<T>} SignalOptions<T>
18
18
  */
19
- import Signal from './signal.js'
20
- export { Signal }
19
+ export {
20
+ default as Signal,
21
+ State,
22
+ createSignal,
23
+ effect,
24
+ Computed
25
+ } from './signal.js'
21
26
  /**
22
27
  * @typedef {import('./store.js').Action} Action
23
28
  */
package/src/signal.js CHANGED
@@ -1,27 +1,42 @@
1
+ /**
2
+ * tries to follow proposal JavaScript Signals standard proposal which is in
3
+ * stage 1
4
+ * @see https://github.com/tc39/proposal-signals
5
+ * @credits https://github.com/jsebrech/tiny-signals
6
+ */
7
+
1
8
  // global context for nested reactivity
2
9
  const context = []
3
10
 
4
11
  /**
5
12
  * @template T
6
- * @typedef {{equals: (value?: T|null, nextValue?: T|null) => boolean}} SignalOptions
13
+ * @typedef {(value?: T|null, nextValue?: T|null) => boolean} EqualsFn
14
+ * Custom comparison function between old and new value
15
+ * default `(value, nextValue) => value === nextValue`
7
16
  */
8
17
 
9
18
  /**
10
- * tries to follow proposal (a bit)
11
- * @see https://github.com/tc39/proposal-signals
12
19
  * @template T
20
+ * @typedef {{equals: EqualsFn<T>}} SignalOptions
13
21
  */
14
- export class State {
15
- subscribers = new Set()
22
+
23
+ /**
24
+ * read- write signal
25
+ * @template T
26
+ */
27
+ export class State extends EventTarget {
28
+ #value
29
+ #equals
16
30
 
17
31
  /**
18
32
  * @param {T|null} [value]
19
33
  * @param {SignalOptions<T>} [options]
20
34
  */
21
35
  constructor(value, options) {
36
+ super()
22
37
  const { equals } = options || {}
23
- this.value = value
24
- this.equals = equals ?? ((value, nextValue) => value === nextValue)
38
+ this.#value = value
39
+ this.#equals = equals ?? ((value, nextValue) => value === nextValue)
25
40
  }
26
41
 
27
42
  /**
@@ -30,73 +45,72 @@ export class State {
30
45
  get() {
31
46
  const running = context[context.length - 1]
32
47
  if (running) {
33
- this.subscribers.add(running)
34
- running.dependencies.add(this.subscribers)
48
+ running.add(this)
35
49
  }
36
- return this.value
50
+ return this.#value
37
51
  }
38
52
 
39
53
  /**
40
54
  * @param {T|null|undefined} nextValue
41
55
  */
42
56
  set(nextValue) {
43
- if (this.equals(this.value, nextValue)) {
57
+ if (this.#equals(this.#value, nextValue)) {
44
58
  return
45
59
  }
46
- this.value = nextValue
47
- for (const running of [...this.subscribers]) {
48
- // run the effect()
49
- running.execute()
50
- }
60
+ this.#value = nextValue
61
+ this.dispatchEvent(new CustomEvent('signal'))
51
62
  }
52
63
  }
53
64
 
54
65
  /**
55
66
  * @template T
56
- * @param {T} value
67
+ * @param {T} initialValue
68
+ * @param {SignalOptions<T>} [options]
57
69
  * @returns {State<T>}
58
70
  */
59
- export const createSignal = (value) =>
60
- value instanceof State ? value : new State(value)
61
-
62
- function cleanup(running) {
63
- // delete all dependent subscribers from state
64
- for (const dep of running.dependencies) {
65
- dep.delete(running)
66
- }
67
- running.dependencies.clear()
68
- }
71
+ export const createSignal = (initialValue, options) =>
72
+ initialValue instanceof State
73
+ ? initialValue
74
+ : new State(initialValue, options)
69
75
 
70
76
  /**
71
- * @param {() => void} cb
77
+ * effect subscribes to state at first run only. Do not hide a signal.get()
78
+ * inside conditionals!
79
+ * @param {() => void|Promise<void>} cb
72
80
  */
73
81
  export function effect(cb) {
74
- const execute = () => {
75
- cleanup(running)
76
- context.push(running)
77
- try {
78
- cb()
79
- } finally {
80
- context.pop()
81
- }
82
- }
82
+ const running = new Set()
83
83
 
84
- const running = { execute, dependencies: new Set() }
85
- execute()
84
+ context.push(running)
85
+ try {
86
+ cb()
87
+ } finally {
88
+ context.pop()
89
+ }
90
+ for (const dep of running) {
91
+ dep.addEventListener('signal', cb)
92
+ }
86
93
 
87
94
  return () => {
88
95
  // unsubscribe from all dependencies
89
- cleanup(running)
96
+ for (const dep of running) {
97
+ dep.removeEventListener('signal', cb)
98
+ }
90
99
  }
91
100
  }
92
101
 
102
+ /**
103
+ * @template T
104
+ */
93
105
  export class Computed {
106
+ #state
107
+
94
108
  /**
95
- * @param {() => void} cb
109
+ * @param {() => T} cb
96
110
  */
97
111
  constructor(cb) {
98
- this.state = new State()
99
- effect(() => this.state.set(cb))
112
+ this.#state = new State()
113
+ effect(() => this.#state.set(cb()))
100
114
  }
101
115
 
102
116
  /**
@@ -104,8 +118,8 @@ export class Computed {
104
118
  * @returns {T}
105
119
  */
106
120
  get() {
107
- return this.state.get()
121
+ return this.#state.get()
108
122
  }
109
123
  }
110
124
 
111
- export default { State, createSignal, effect, Computed }
125
+ export default { State, Computed, createSignal, effect }
package/src/styling.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { camelToKebabCase } from './case.js'
2
2
 
3
3
  /**
4
- * Construct className based on trueish values of map
4
+ * Construct className based on true-ish values of map
5
5
  * @param {{[name: string]: string | boolean | number}} map
6
6
  * @returns {string}
7
7
  */
@@ -31,3 +31,45 @@ export const styleMap = (map, options) => {
31
31
  }
32
32
  return acc.join(';')
33
33
  }
34
+
35
+ // ----
36
+
37
+ let globalSheets = null
38
+ /**
39
+ * obtain and cache global stylesheets
40
+ * @returns {CSSStyleSheet[]}
41
+ */
42
+ function getGlobalStyleSheets() {
43
+ if (globalSheets === null) {
44
+ globalSheets = Array.from(document.styleSheets).map(({ cssRules }) => {
45
+ const sheet = new CSSStyleSheet()
46
+ const css = Array.from(cssRules)
47
+ .map((rule) => rule.cssText)
48
+ .join(' ')
49
+ sheet.replaceSync(css)
50
+ return sheet
51
+ })
52
+ }
53
+ return globalSheets
54
+ }
55
+
56
+ /**
57
+ * apply global style sheets to shadowRoot
58
+ * @param {ShadowRoot} renderRoot
59
+ * @example
60
+ * class MyComponent extends MiElement {
61
+ * render() {
62
+ * addGlobalStyles(this.renderRoot)
63
+ * }
64
+ * }
65
+ */
66
+ export function addGlobalStyles(renderRoot) {
67
+ renderRoot.adoptedStyleSheets.push(...getGlobalStyleSheets())
68
+ }
69
+
70
+ /**
71
+ * Helper literal to show css styles in JS e.g. with
72
+ * https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html
73
+ */
74
+ export const css = (strings, ...values) =>
75
+ String.raw({ raw: strings }, ...values)
@@ -33,7 +33,7 @@ export class ContextRequestEvent extends Event {
33
33
  * @param {(value: any, unsubscribe?: () => void) => void} callback
34
34
  * @param {boolean} [subscribe=false] subscribe to value changes
35
35
  */
36
- constructor(context: Context, callback: (value: any, unsubscribe?: () => void) => void, subscribe?: boolean | undefined);
36
+ constructor(context: Context, callback: (value: any, unsubscribe?: () => void) => void, subscribe?: boolean);
37
37
  context: Context;
38
38
  callback: (value: any, unsubscribe?: () => void) => void;
39
39
  subscribe: boolean | undefined;
@@ -52,7 +52,7 @@ export class ContextConsumer implements HostController {
52
52
  constructor(host: HTMLElement, context: Context, options?: {
53
53
  subscribe?: boolean | undefined;
54
54
  validate?: ((any: any) => boolean) | undefined;
55
- } | undefined);
55
+ });
56
56
  host: HTMLElement;
57
57
  context: Context;
58
58
  subscribe: boolean;
@@ -6,17 +6,18 @@
6
6
  * removed from the DOM, usually with disconnectedCallback()
7
7
  */
8
8
  /**
9
- * class extening HTMLElement to enable deferred rendering on attribute changes
9
+ * class extending HTMLElement to enable deferred rendering on attribute changes
10
10
  * either via `setAttribute(name, value)` or `this[name] = value`.
11
11
  * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
12
12
  * @example
13
13
  * ```js
14
14
  * class Example extends MiElement {
15
- * // define all observed attributes with its default value.
15
+ * // define all observed attributes with its default initial value.
16
+ * // for yet to defined numbers, boolean or strings use `Number`, `Boolean`, `String`
16
17
  * // attributes are accessible via `this[prop]`
17
18
  * // avoid using attributes which are HTMLElement properties e.g. className
18
19
  * static get attributes () {
19
- * return { text: 'Hi' }
20
+ * return { text: 'Hi', num: Number }
20
21
  * }
21
22
  * render() {
22
23
  * this.renderRoot.innerHTML = `<div></div>`
@@ -75,7 +76,7 @@ export class MiElement extends HTMLElement {
75
76
  * @param {Record<string,any>} [_changedAttributes] previous values of changed attributes
76
77
  * @returns {boolean}
77
78
  */
78
- shouldUpdate(_changedAttributes?: Record<string, any> | undefined): boolean;
79
+ shouldUpdate(_changedAttributes?: Record<string, any>): boolean;
79
80
  /**
80
81
  * request rendering
81
82
  */
@@ -94,7 +95,7 @@ export class MiElement extends HTMLElement {
94
95
  * @param {Record<string,any>} [_changedAttributes] previous values of changed
95
96
  * attributes
96
97
  */
97
- update(_changedAttributes?: Record<string, any> | undefined): void;
98
+ update(_changedAttributes?: Record<string, any>): void;
98
99
  /**
99
100
  * Adds listener function for eventName. listener is removed before component
100
101
  * disconnects
@@ -102,7 +103,7 @@ export class MiElement extends HTMLElement {
102
103
  * @param {EventListenerOrEventListenerObject} listener
103
104
  * @param {Node|Document|Window} [node=this]
104
105
  */
105
- on(eventName: string, listener: EventListenerOrEventListenerObject, node?: Node | Document | Window | undefined): void;
106
+ on(eventName: string, listener: EventListenerOrEventListenerObject, node?: Node | Document | Window): void;
106
107
  /**
107
108
  * Adds one-time listener function for eventName. The next time eventName is
108
109
  * triggered, this listener is removed and then invoked.
package/types/escape.d.ts CHANGED
@@ -1,3 +1,3 @@
1
+ export function unsafeHtml(str: string): string;
1
2
  export function escHtml(string: string): string;
2
- export function escAttr(string: string): string;
3
3
  export function esc(strings: string[], ...vars: any[]): string;
package/types/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- export { Signal };
2
1
  export { Store } from "./store.js";
3
2
  export type Context = import("./context.js").Context;
4
3
  export type HostController = import("./element.js").HostController;
@@ -7,9 +6,9 @@ export type HostController = import("./element.js").HostController;
7
6
  */
8
7
  export type SignalOptions<T> = import("./signal.js").SignalOptions<T>;
9
8
  export type Action = import("./store.js").Action;
10
- import Signal from './signal.js';
11
9
  export { ContextConsumer, ContextProvider, ContextRequestEvent } from "./context.js";
12
10
  export { MiElement, convertType, define } from "./element.js";
13
- export { esc, escAttr, escHtml } from "./escape.js";
11
+ export { unsafeHtml, esc, escHtml } from "./escape.js";
14
12
  export { refsById, refsBySelector } from "./refs.js";
13
+ export { default as Signal, State, createSignal, effect, Computed } from "./signal.js";
15
14
  export { classMap, styleMap } from "./styling.js";
package/types/signal.d.ts CHANGED
@@ -1,25 +1,29 @@
1
1
  /**
2
- * @param {() => void} cb
2
+ * effect subscribes to state at first run only. Do not hide a signal.get()
3
+ * inside conditionals!
4
+ * @param {() => void|Promise<void>} cb
3
5
  */
4
- export function effect(cb: () => void): () => void;
6
+ export function effect(cb: () => void | Promise<void>): () => void;
5
7
  /**
6
8
  * @template T
7
- * @typedef {{equals: (value?: T|null, nextValue?: T|null) => boolean}} SignalOptions
9
+ * @typedef {(value?: T|null, nextValue?: T|null) => boolean} EqualsFn
10
+ * Custom comparison function between old and new value
11
+ * default `(value, nextValue) => value === nextValue`
8
12
  */
9
13
  /**
10
- * tries to follow proposal (a bit)
11
- * @see https://github.com/tc39/proposal-signals
12
14
  * @template T
15
+ * @typedef {{equals: EqualsFn<T>}} SignalOptions
13
16
  */
14
- export class State<T> {
17
+ /**
18
+ * read- write signal
19
+ * @template T
20
+ */
21
+ export class State<T> extends EventTarget {
15
22
  /**
16
23
  * @param {T|null} [value]
17
24
  * @param {SignalOptions<T>} [options]
18
25
  */
19
- constructor(value?: T | null | undefined, options?: SignalOptions<T> | undefined);
20
- subscribers: Set<any>;
21
- value: T | null | undefined;
22
- equals: (value?: T | null | undefined, nextValue?: T | null | undefined) => boolean;
26
+ constructor(value?: T | null, options?: SignalOptions<T>);
23
27
  /**
24
28
  * @returns {T|null|undefined}
25
29
  */
@@ -28,27 +32,36 @@ export class State<T> {
28
32
  * @param {T|null|undefined} nextValue
29
33
  */
30
34
  set(nextValue: T | null | undefined): void;
35
+ #private;
31
36
  }
32
- export function createSignal<T>(value: T): State<T>;
33
- export class Computed {
37
+ export function createSignal<T>(initialValue: T, options?: SignalOptions<T>): State<T>;
38
+ /**
39
+ * @template T
40
+ */
41
+ export class Computed<T> {
34
42
  /**
35
- * @param {() => void} cb
43
+ * @param {() => T} cb
36
44
  */
37
- constructor(cb: () => void);
38
- state: State<any>;
45
+ constructor(cb: () => T);
39
46
  /**
40
47
  * @template T
41
48
  * @returns {T}
42
49
  */
43
- get<T>(): T;
50
+ get<T_1>(): T_1;
51
+ #private;
44
52
  }
45
53
  declare namespace _default {
46
54
  export { State };
55
+ export { Computed };
47
56
  export { createSignal };
48
57
  export { effect };
49
- export { Computed };
50
58
  }
51
59
  export default _default;
60
+ /**
61
+ * Custom comparison function between old and new value
62
+ * default `(value, nextValue) => value === nextValue`
63
+ */
64
+ export type EqualsFn<T> = (value?: T | null, nextValue?: T | null) => boolean;
52
65
  export type SignalOptions<T> = {
53
- equals: (value?: T | null, nextValue?: T | null) => boolean;
66
+ equals: EqualsFn<T>;
54
67
  };
package/types/store.d.ts CHANGED
@@ -53,7 +53,7 @@ export class Store<T> extends State<any> {
53
53
  * const store = new Store(actions, initialValue, options)
54
54
  * ```
55
55
  */
56
- constructor(actions: Record<string, Action>, initialValue?: T | null | undefined, options?: SignalOptions<T> | undefined);
56
+ constructor(actions: Record<string, Action>, initialValue?: T | null, options?: SignalOptions<T>);
57
57
  }
58
58
  export type MiElement = import("./element.js").MiElement;
59
59
  export type Action = (state: any, data?: any) => any;
@@ -1,3 +1,14 @@
1
+ /**
2
+ * apply global style sheets to shadowRoot
3
+ * @param {ShadowRoot} renderRoot
4
+ * @example
5
+ * class MyComponent extends MiElement {
6
+ * render() {
7
+ * addGlobalStyles(this.renderRoot)
8
+ * }
9
+ * }
10
+ */
11
+ export function addGlobalStyles(renderRoot: ShadowRoot): void;
1
12
  export function classMap(map: {
2
13
  [name: string]: string | boolean | number;
3
14
  }): string;
@@ -5,4 +16,5 @@ export function styleMap(map: {
5
16
  [name: string]: string | number | undefined | null;
6
17
  }, options?: {
7
18
  unit?: string | undefined;
8
- } | undefined): string;
19
+ }): string;
20
+ export function css(strings: any, ...values: any[]): string;