mi-element 0.6.0 → 0.6.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.
@@ -0,0 +1,82 @@
1
+ # Controllers
2
+
3
+ A controller is meant to hook into the components update lifecycle and provide
4
+ an updated value to the host component.
5
+
6
+ ```js
7
+ /**
8
+ * @file './my-clock.js'
9
+ * Defines custom element my-clock with controller
10
+ */
11
+ import { define, MiElement } from 'mi-element'
12
+
13
+ class ClockController {
14
+ /**
15
+ * @param {MiElement} host
16
+ */
17
+ constructor(host) {
18
+ this.host = host
19
+ // Add controller to component.
20
+ this.host.addController(this)
21
+ // As soon as component is mounted `hostConnected()` is called.
22
+ }
23
+ /**
24
+ * called by the hosts connectedCallback()
25
+ */
26
+ hostConnected() {
27
+ this._interval() // start timer
28
+ }
29
+ /**
30
+ * called by the hosts disconnectedCallback(); do cleanup herein.
31
+ */
32
+ hostDisconnected() {
33
+ clearTimeout(this._timerId)
34
+ }
35
+ /**
36
+ * periodically update controller value
37
+ */
38
+ _interval() {
39
+ this.value = new Date()
40
+ // on every change call requestUpdate()
41
+ this.host.requestUpdate()
42
+
43
+ this._timerId = setTimeout(() => {
44
+ this._interval()
45
+ }, 1000)
46
+ }
47
+ }
48
+
49
+ define(
50
+ 'my-clock',
51
+ class extends MiElement {
52
+ constructor() {
53
+ super()
54
+ // create controller and pass `this` as host
55
+ this.controller = new ClockController(this)
56
+ }
57
+
58
+ update() {
59
+ // get value from controller
60
+ const { value } = this.controller
61
+ // apply some formatting
62
+ const formattedDateTime = new Intl.DateTimeFormat(navigator.language, {
63
+ dateStyle: 'short',
64
+ timeStyle: 'long'
65
+ }).format(value)
66
+ // update component
67
+ this.renderRoot.textContent = formattedDateTime
68
+ }
69
+ }
70
+ )
71
+ ```
72
+
73
+ ```html
74
+ <!-- @file ./index.html -->
75
+ <!doctype html>
76
+ <html>
77
+ <body>
78
+ <my-clock></my-clock>
79
+ <script type="module" src="./my-clock.js"></script>
80
+ </body>
81
+ </html>
82
+ ```
@@ -0,0 +1,394 @@
1
+ **Table of contents**
2
+
3
+ <!-- !toc (minlevel=2) -->
4
+
5
+ * [constructor()](#constructor)
6
+ * [connectedCallback()](#connectedcallback)
7
+ * [disconnectedCallback()](#disconnectedcallback)
8
+ * [attributeChangedCallback(name, oldValue, newValue)](#attributechangedcallbackname-oldvalue-newvalue)
9
+ * [Update Cycle](#update-cycle)
10
+ * [render()](#render)
11
+ * [update(changedAttributes)](#updatechangedattributes)
12
+ * [shouldUpdate(changedAttributes)](#shouldupdatechangedattributes)
13
+ * [on(eventName, listener, \[node\])](#oneventname-listener-node)
14
+ * [once(eventName, listener, \[node\])](#onceeventname-listener-node)
15
+
16
+ <!-- toc! -->
17
+
18
+ # Element Lifecycle
19
+
20
+ MiElement components use the [standard custom element lifecycle callbacks][].
21
+
22
+ [standard custom element lifecycle callbacks]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks
23
+
24
+ ## constructor()
25
+
26
+ Called whenever a new element is created. Default attributes are being applied
27
+ from the `static attributes` object. From there setters and getters for property
28
+ changes using `.[name] = newValue` instead of `setAttribute(name, newValue)` are
29
+ applied.
30
+
31
+ Direct properties can also made observable with `static properties` as long as
32
+ not yet being defined within attributes.
33
+
34
+ ```js
35
+ class extends MiElement {
36
+ /**
37
+ * Declare observable attributes and their default values with this getter.
38
+ * Do not use `static attribute = { text: 'Hi' }` as components attributes
39
+ * will use a shallow copy only. With the getter we always get a real "deep"
40
+ * copy.
41
+ *
42
+ * For yet to defined numbers, boolean or strings use `Number`, `Boolean`,
43
+ * `String`. Attributes are accessible via `.[name]` or `.getAttribute(name)`.
44
+ * Avoid using attributes which are HTMLElement properties e.g. `className`.
45
+ */
46
+ static get attributes () {
47
+ return {
48
+ text: 'Hi',
49
+ // A yet to be defined boolean value
50
+ focus: Boolean
51
+ }
52
+ }
53
+ /**
54
+ * Declare observable properties and their default values.
55
+ * Any changed value of a property, using `.[name] = nextValue` assignment,
56
+ * will cause a rerender.
57
+ * In case of objects or arrays consider changing the reference with the
58
+ * spread operator like `{...obj}` or `[...arr]` creating a new reference.
59
+ * If name is already declared in `attributes` it will be ignored.
60
+ */
61
+ static get properties () {
62
+ return { prop: 0 }
63
+ }
64
+ constructor() {
65
+ super()
66
+ // optionally declare "non observable" and internal properties
67
+ this.foo = 'foo'
68
+ }
69
+ }
70
+ ```
71
+
72
+ ## connectedCallback()
73
+
74
+ Invoked when a component is being added to the document's DOM.
75
+
76
+ Micro components create `this.renderRoot` (typically same as `this.shadowRoot`
77
+ for open components) using `this.attachShadow(shadowRootOptions)`. Shadow root
78
+ options are taken from the components `static shadowRootOptions = { mode: 'open'
79
+ }`. In advanced cases where no shadow root is desired, set `static
80
+ shadowRootOptions = null`
81
+
82
+ The most common use case is adding event listeners to external nodes in
83
+ `connectedCallback()`. Typically, anything done in `connectedCallback()` should
84
+ be undone when the element is disconnected, like removing all event listeners on
85
+ external nodes to prevent memory leaks.
86
+
87
+ Then the first `render()` is issued with a `requestUpdate()`
88
+
89
+ ```js
90
+ class extends MiElement {
91
+ // { mode: 'open' } is the default shadow root option
92
+ // use `null` for no shadow root or { mode: 'closed' } for closed mode
93
+ static shadowRootOptions = { mode: 'open' }
94
+
95
+ connectedCallback() {
96
+ super.connectedCallback() // don't forget to call the super method
97
+ window.addEventListener('keydown', this._handleKeyDown)
98
+ }
99
+
100
+ // create a event listener which is bound to this component
101
+ // note the `_listener () => {}` syntax (instead of `_listener () {}`)
102
+ _handleKeyDown = (ev) => {
103
+ // do sth. with the event
104
+ }
105
+
106
+ disconnectedCallback() {
107
+ super.disconnectedCallback()
108
+ window.removeEventListener('keydown', this._handleKeyDown)
109
+ }
110
+ }
111
+ ```
112
+
113
+ To simplify further use `this.on()` which automatically removes the event listener
114
+ on `window` when the component is unmounted with `disconnectedCallback()`.
115
+
116
+ ```js
117
+ class extends MiElement {
118
+ connectedCallback() {
119
+ super.connectedCallback()
120
+ this.on('keydown', this._handleKeyDown, window)
121
+ }
122
+
123
+ // create a event listener which is bound to this component
124
+ // note the `_listener () => {}` syntax (instead of `_listener () {}`)
125
+ _handleKeyDown = (ev) => {
126
+ // do sth. with the event
127
+ }
128
+ }
129
+ ```
130
+
131
+ ## disconnectedCallback()
132
+
133
+ Invoked when a component is removed from the document's DOM.
134
+
135
+ Typically, anything done in `connectedCallback()` should be undone when the
136
+ element is disconnected, like removing all event listeners on external nodes to
137
+ prevent memory leaks.
138
+
139
+ See previous example.
140
+
141
+ !!! INFO No need to remove internal event listeners
142
+
143
+ You don't need to remove event listeners added on the component's own
144
+ DOM. This includes those added in your template. Unlike external
145
+ event listeners, these will be garbage collected with the component.
146
+
147
+ ## attributeChangedCallback(name, oldValue, newValue)
148
+
149
+ Invoked when one of the element’s observedAttributes changes.
150
+
151
+ Usually no need to do something here. But if, don't forget to call
152
+ `super.attributeChangedCallback(name, oldValue, newValue)` within.
153
+
154
+ ## Update Cycle
155
+
156
+ ```mermaid
157
+ flowchart TD
158
+ constructoR("constructor()")
159
+ connectedCallback("connectedCallback()")
160
+ disconnectedCallback("disconnectedCallback()")
161
+ render("render()")
162
+ requestUpdate("requestUpdate()")
163
+ shouldUpdate("shouldUpdate(changedAttributes)")
164
+ update("update(changedAttributes)")
165
+
166
+ setAttribute("setAttribute(name, newVale)")
167
+ setProperty(".[name] = newValue")
168
+
169
+ START --> constructoR
170
+ constructoR -.->|"mount to DOM"| connectedCallback
171
+ connectedCallback --> render
172
+ render --> requestUpdate
173
+ requestUpdate -.->|async| shouldUpdate
174
+ shouldUpdate -->|true| update
175
+
176
+ setAttribute -->|"attributeChangedCallback"| requestUpdate
177
+ setProperty --> requestUpdate
178
+
179
+ update -.->|change| setAttribute
180
+ update -.->|change| setProperty
181
+
182
+ connectedCallback -.->|"unmount from DOM"| disconnectedCallback
183
+ disconnectedCallback --> END
184
+ ```
185
+
186
+ A micro component usually implements `render()` and `update()`:
187
+
188
+ ```js
189
+ import { define, MiElement, refsBySelector } from 'mi-element'
190
+
191
+ class Counter extends MiElement {
192
+ static get attributes() {
193
+ return { value: 0 }
194
+ }
195
+
196
+ // define the innerHTML template for the component
197
+ static template = `
198
+ <button>Count</button>
199
+ <p>Counter value: <span>0</span></p>
200
+ `
201
+
202
+ render() {
203
+ // If `static template` is provided, it has already been rendered
204
+ // on `this.renderRoot`
205
+
206
+ // obtain references for events and update() with `refsBySelector`
207
+ this.refs = refsBySelector(this.renderRoot, {
208
+ button: 'button',
209
+ count: 'span'
210
+ })
211
+
212
+ // apply event listener on button
213
+ this.refs.button.addEventListener('click', () => {
214
+ // observed attribute will trigger `requestUpdate()` which then async
215
+ // calls `update()`
216
+ this.value++
217
+ })
218
+ }
219
+
220
+ update() {
221
+ this.refs.count.textContent = this.value
222
+ }
223
+ }
224
+ ```
225
+
226
+ ## render()
227
+
228
+ Initial rendering of the component. Try to render the component only once!
229
+ If you need re-rendering by recrating the DOM do this outside of `render()`
230
+
231
+ Within the `render()` method, bear in mind to:
232
+
233
+ - Avoid changing the component's state.
234
+ - Avoid producing any side effects.
235
+ - Use only the component's attributes as input.
236
+
237
+ !!! WARNING XSS - Cross-Site Scripting
238
+
239
+ Using [`innerHTML`][innerHTML] to create the components DOM is susceptible to
240
+ [XSS][XSS] attacks in case that user-supplied data contains valid HTML markup.
241
+
242
+ In all other cases you may consider the <code>esc``</code> template literal or
243
+ `escHtml()` from the "mi-element" import, which escapes user-supplied data.
244
+
245
+ [innerHTML]: https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
246
+ [XSS]: https://en.wikipedia.org/wiki/Cross-site_scripting
247
+
248
+ ```js
249
+ import { define, MiElement } from 'mi-element'
250
+
251
+ // (1) get template directly from html ...
252
+ const template = document.querySelector('template#counter')
253
+
254
+ // (2) or define outside the component ...
255
+ const template = document.createElement('template')
256
+ template.innerHTML = `
257
+ <button>Count</button>
258
+ <p>Counter value: <span>0</span></p>
259
+ `
260
+
261
+ class Counter extends MiElement {
262
+ // (3) or as static string on the component (needs define from 'mi-element')
263
+ static template = `
264
+ <button id>Count</button>
265
+ <p>Counter value: <span id="count">0</span></p>
266
+ `
267
+ // ...
268
+ render() {
269
+ /*
270
+ // NEVER DO THIS, as this may cause XSS ///
271
+ this.renderRoot.innerHTML = `
272
+ <button>Count</button>
273
+ <p>Counter value: <span>${this.count}</span></p>`
274
+ */
275
+ // always render a cloned template, which is safe
276
+ // NOT NEEDED with option (3) `static template = '...'`
277
+ this.addTemplate(template)
278
+ }
279
+ }
280
+
281
+ // always use define with (3)
282
+ define('mi-element-counter', Counter)
283
+ ```
284
+
285
+ To more easily obtain any references of interest use the `refs()` helper by
286
+ adding `id` attributes to the nodes where updates shall happen or event
287
+ listeners must be applied.
288
+
289
+ ```js
290
+ import { MiElement, define, refsById } from 'mi-element'
291
+
292
+ class Counter extends MiElement {
293
+ static template = `
294
+ <button id>Count</button>
295
+ <p>Counter value: <span id="count">0</span></p>
296
+ `
297
+
298
+ render() {
299
+ // template is already rendered on `this.renderRoot`
300
+
301
+ // get refs though `refsById` of `refsBySelector`
302
+ this.refs = refsById(this.renderRoot)
303
+ // this.refs == {button: <button>, count: <span>}
304
+ }
305
+ }
306
+
307
+ define('mi-element-counter', Counter)
308
+ ```
309
+
310
+ ## update(changedAttributes)
311
+
312
+ Here all content or render updates on the component should happen. Avoid
313
+ re-rendering the full component and only apply partial changes on the
314
+ rendered elements as much as possible.
315
+
316
+ ```js
317
+ class Counter extends MiElement {
318
+ // ...
319
+ update() {
320
+ this.refs.count.textContent = this.value
321
+ }
322
+ }
323
+ ```
324
+
325
+ In order to allow judged decisions on the area where an update should take place
326
+ any changed attributes are passed.
327
+
328
+ To mitigate [XSS][] attacks prefer the use of `.textContent` and avoid
329
+ ~~`.innerHTML`~~. For attribute changes use `.setAttribute(name, newValue)`.
330
+ Both `.textContent` and `setAttribute()` provide escaping for you.
331
+
332
+
333
+ For finer control on updates the use of signals is encouraged. With this there
334
+ is no need to add logic to `shouldUpdate()` or `update()`.
335
+
336
+ ```js
337
+ import { MiElement, Signal } from 'mi-element'
338
+
339
+ class Counter extends MiElement {
340
+ static get attributes() {
341
+ return { value: 0 }
342
+ }
343
+
344
+ static template = `
345
+ <button>Count</button>
346
+ <p>Counter value: <span>0</span></p>
347
+ `
348
+
349
+ render() {
350
+ const refs = refsBySelector(this.renderRoot, {
351
+ button: 'button',
352
+ count: 'span'
353
+ })
354
+
355
+ refs.button.addEventListener('click', () => {
356
+ this.value++
357
+ })
358
+
359
+ Signal.effect(() => {
360
+ // an update only happens if `this.value` changes;
361
+ // other attribute changes are ignored.
362
+ refs.count.textContent = this.value
363
+ })
364
+ }
365
+ }
366
+ ```
367
+
368
+ ## shouldUpdate(changedAttributes)
369
+
370
+ Convenience method in order to be able to decide on the changed attributes,
371
+ whether `update()` should be called or not.
372
+
373
+ Return `true` if component should be updated.
374
+
375
+ ## on(eventName, listener, \[node\])
376
+
377
+ Adds listener function for eventName. listener is removed before component
378
+ disconnects.
379
+
380
+ ```js
381
+ class Router extends MiElement {
382
+ render() {
383
+ // add event listener 'hashchange' to `window` which is disposed as soon as
384
+ // the component unmounts
385
+ this.on('hashchange', this.update, window)
386
+ }
387
+ // ...
388
+ }
389
+ ```
390
+
391
+ ## once(eventName, listener, \[node\])
392
+
393
+ Adds one-time listener function for eventName. The next time eventName is
394
+ triggered, this listener is removed and then invoked.
@@ -0,0 +1,41 @@
1
+ # Reactivity with mi-html
2
+
3
+ MiElement can be used with [mi-html][] for reactive updates.
4
+
5
+ To install use
6
+
7
+ pnpm install mi-html
8
+
9
+ MiElement provides signals for all its attributes defined in `static get
10
+ attributes() {}`.
11
+
12
+ ```js
13
+ import { define, MiElement } from 'mi-element'
14
+ import { html, render } from 'mi-html'
15
+
16
+ define(
17
+ 'mi-counter',
18
+ class extends MiElement {
19
+ static get attributes() {
20
+ return {
21
+ count: 1 //<< this.count is already a signal
22
+ }
23
+ }
24
+
25
+ render() {
26
+ render(
27
+ this.renderRoot,
28
+ // use callback function!
29
+ () =>
30
+ html`<button @click=${() => this.count++}>
31
+ Clicked ${this.count} times
32
+ </button> `
33
+ )
34
+ }
35
+ }
36
+ )
37
+ ```
38
+
39
+ See `./example/mi-html/index.html` for a running sample.
40
+
41
+ [mi-html]: https://github.com/commenthol/mi-element/blob/main/packages/mi-html/README.md
package/docs/signal.md ADDED
@@ -0,0 +1,192 @@
1
+ **Table of contents**
2
+
3
+ <!-- !toc (minlevel=2) -->
4
+
5
+ * [State, createSignal](#state-createsignal)
6
+ * [effect](#effect)
7
+ * [DONT'S](#donts)
8
+ * [Computed Signals](#computed-signals)
9
+ * [Signals in MiElement](#signals-in-mielement)
10
+
11
+ <!-- toc! -->
12
+
13
+ # Signal
14
+
15
+ Signal is the core for any reactive behavior of mi-element.
16
+ It loosely follows the [TC39 JavaScript Signals standard proposal][].
17
+
18
+ ## State, createSignal
19
+
20
+ Reactive state and its subscribers is managed in this class. With
21
+ `.set(nextValue)` and `.get()` access to the state is achieved.
22
+
23
+ For convenience there is a `createSignal(initialValue<T>): State<T>` function to
24
+ create a signal.
25
+
26
+ ```js
27
+ import { createSignal, State } from 'mi-element'
28
+
29
+ const signal = createSignal(1)
30
+ // same as
31
+ const signal = new State(1)
32
+
33
+ signal.get()
34
+ //> 1
35
+ signal.set(4)
36
+ signal.get()
37
+ //> 4
38
+ ```
39
+
40
+ For controlling the notifications to subscribers, the signal option `equals` for
41
+ a custom comparison function can be used, e.g. to trigger an effect on every
42
+ `.set(nextValue)`
43
+
44
+ ```js
45
+ // default is:
46
+ const equals = (value, nextValue) => value === nextValue
47
+ // changes to trigger change on every `.set()`
48
+ const equals = (value, nextValue) => true
49
+ const signal = createSignal(initialValue, { equals })
50
+ ```
51
+
52
+ ## effect
53
+
54
+ Reactivity is achieved by subscribing to a signals State using an effect
55
+ callback function. Such callback function is called for registration to the
56
+ signals state as well as to update on any change through
57
+ `signal.set(nextValue)`. Within that callback the `signal.get()` must be called
58
+ _synchronously_!
59
+
60
+ ```js
61
+ import { createSignal, effect } from 'mi-element'
62
+
63
+ const signal = createSignal(1)
64
+
65
+ const callback = () => console.log('value is %s', signal.get())
66
+ // `callback` is executed with assigning to the effect!
67
+ const unsubscribe = effect(callback)
68
+ //> "value is 1"
69
+ signal.set(4)
70
+ //> "value is 4"
71
+
72
+ // Signal.effect returns a function to unsubscribe `callback` from the signal
73
+ unsubscribe()
74
+ signal.set(5)
75
+ // gives no console.log output
76
+ ```
77
+
78
+ For asynchronous usage, request the value from the signal first. Otherwise no
79
+ subscription to the signal will take place.
80
+
81
+ ```js
82
+ const signal = createSignal(1)
83
+
84
+ const callback = async () => {
85
+ // synchronously get the value
86
+ const value = signal.get()
87
+ const p = Promise.withResolvers()
88
+ setTimeout(() => {
89
+ console.log('value is %s', )
90
+ p.resolve()
91
+ }, 100)
92
+ }.catch(() => {})
93
+ // callback is executed with assigning to the effect!
94
+ effect(callback)
95
+ ```
96
+
97
+ ### DONT'S
98
+
99
+ Effects are executed synchronously for a better debugging experience. But be
100
+ warned to never set the signal in the an effects callback!
101
+
102
+ ```js
103
+ const signal = createSignal(0)
104
+
105
+ // DON'T DO THIS
106
+ effect(() => {
107
+ const value = signal.get()
108
+ signal.set(value++) //< meeeeh
109
+ })
110
+ ```
111
+
112
+ The signal value getter triggers the registration of the callback through the
113
+ effect. So don't hide a signals getter inside conditionals!
114
+
115
+ ```js
116
+ const signal = createSignal(0)
117
+ const rand = Math.random()
118
+
119
+ // DON'T DO THIS
120
+ effect(() => {
121
+ if (rand < 0.5) {
122
+ console.log(signal.get()) //< meeeeh
123
+ }
124
+ })
125
+
126
+ // DO THIS
127
+ effect(() => {
128
+ const value = signal.get() //< much better
129
+ if (rand < 0.5) {
130
+ console.log(value)
131
+ }
132
+ })
133
+ ```
134
+
135
+ ## Computed Signals
136
+
137
+ Computed signals from more than one signal can be obtained from `Computed`.
138
+
139
+ ```js
140
+ const firstName = createSignal('Joe')
141
+ const lastName = createSignal('Doe')
142
+ // define computed signal
143
+ const name = new Computed(() => `${firstName.get()} ${lastName.get()}`)
144
+ const events = []
145
+ // apply effect
146
+ effect(() => console.log(name.get()))
147
+ //> 'Joe Doe'
148
+ firstName.set('Alice')
149
+ //> 'Alice Doe'
150
+ lastName.set('Wonderland')
151
+ //> 'Alice Wonderland'
152
+ ```
153
+
154
+ ## Signals in MiElement
155
+
156
+ MiElement attributes are backed by signals. To subscribe to reactive changes a
157
+ `Signal.effect` callback can be used on all observed attributes.
158
+
159
+ ```js
160
+ import { effect, define, MiElement, refByIds } from 'mi-element'
161
+
162
+ define(
163
+ 'mi-counter',
164
+ class extends MiElement {
165
+ static template = `
166
+ <button id> + </button>
167
+ <div id></div>
168
+ `
169
+
170
+ static get attributes() {
171
+ return {
172
+ // define reactive attribute
173
+ count: 0
174
+ }
175
+ }
176
+
177
+ render() {
178
+ this.refs = refsById(this.renderRoot)
179
+ this.refs.button.addEventListener('click', () => {
180
+ // change observed and reactive attribute...
181
+ this.count++
182
+ })
183
+ effect(() => {
184
+ // ...triggers update on every change
185
+ this.refs.div.textContent = `${this.count} clicks counted`
186
+ })
187
+ }
188
+ }
189
+ )
190
+ ```
191
+
192
+ [TC39 JavaScript Signals standard proposal]: https://github.com/tc39/proposal-signals