mi-element 0.0.1 → 0.1.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/src/element.js ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * @typedef {object} HostController controller
3
+ * @property {() => void} hostConnected is called when host element is added to
4
+ * the DOM, usually with connectedCallback()
5
+ * @property {() => void} hostDisconnected is called when host element is
6
+ * removed from the DOM, usually with disconnectedCallback()
7
+ */
8
+
9
+ /**
10
+ * class extening HTMLElement to enable deferred rendering on attribute changes
11
+ * either via `setAttribute(name, value)` or `this[name] = value`.
12
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
13
+ * @example
14
+ * ```js
15
+ * class Example extends MiElement {
16
+ * // define all observed attributes with its default value.
17
+ * // attributes are accessible via `this[prop]`
18
+ * // avoid using attributes which are HTMLElement properties e.g. className
19
+ * static get attributes () {
20
+ * return { text: 'Hi' }
21
+ * }
22
+ * render() {
23
+ * this.renderRoot.innerHTML = `<div></div>`
24
+ * this.refs = {
25
+ * div: this.renderRoot.querySelector('div')
26
+ * }
27
+ * }
28
+ * // render method called every time an attribute changes
29
+ * update() {
30
+ * this.refs.div.textContent = this.text
31
+ * }
32
+ * }
33
+ * // create a custom element with the `define` function (see below)
34
+ * define('x-example', Example)
35
+ * // create a DOM node and re-render via attribute or property changes
36
+ * const elem = document.createElement('x-example')
37
+ * elem.setAttribute('text', 'set attribute')
38
+ * // or if change is triggered by property
39
+ * elem.text = 'set property'
40
+ * ```
41
+ */
42
+ export class MiElement extends HTMLElement {
43
+ #attr = {}
44
+ #attrLc = new Map()
45
+ #types = new Map()
46
+ #disposers = new Set()
47
+ #controllers = new Set()
48
+ #changedAttr = {}
49
+
50
+ /**
51
+ * Default options used when calling `attachShadow`. Used in
52
+ * `connectedCallback()`.
53
+ * If override is `null`, no shadow-root will be attached.
54
+ */
55
+ static shadowRootOptions = { mode: 'open' }
56
+
57
+ constructor() {
58
+ super()
59
+ // @ts-expect-error
60
+ this.#attr = { ...this.constructor.attributes }
61
+ this.#observedAttributes()
62
+ }
63
+
64
+ /**
65
+ * requests update on component when property changes
66
+ */
67
+ #observedAttributes() {
68
+ for (const [name, val] of Object.entries(this.#attr)) {
69
+ this.#types.set(name, initialType(val))
70
+ this.#attrLc.set(name.toLowerCase(), name)
71
+ Object.defineProperty(this, name, {
72
+ enumerable: true,
73
+ get() {
74
+ return this.#attr[name]
75
+ },
76
+ set(newValue) {
77
+ console.debug('%s.%s =', this.nodeName, name, newValue)
78
+ const oldValue = this.#attr[name]
79
+ if (oldValue === newValue) return
80
+ this.#attr[name] = newValue
81
+ this.#changedAttr[name] = newValue
82
+ this.requestUpdate()
83
+ }
84
+ })
85
+ }
86
+ }
87
+
88
+ /**
89
+ * return camelCased value instead of possible lowercased
90
+ * @param {string} name
91
+ * @returns
92
+ */
93
+ #getName(name) {
94
+ return this.#attrLc.get(name) || name
95
+ }
96
+
97
+ #getType(name) {
98
+ return this.#types.get(name)
99
+ }
100
+
101
+ /**
102
+ * creates the element's renderRoot, sets up styling
103
+ * @category lifecycle
104
+ */
105
+ connectedCallback() {
106
+ this.#controllers.forEach((controller) => controller.hostConnected?.())
107
+ // create render root
108
+ // @ts-expect-error
109
+ const { shadowRootOptions, template } = this.constructor
110
+ this.renderRoot = shadowRootOptions
111
+ ? (this.shadowRoot ?? this.attachShadow(shadowRootOptions))
112
+ : this
113
+ this.addTemplate(template)
114
+ // trigger initial rendering such that children can be added via JS
115
+ this.render()
116
+ // and update
117
+ this.requestUpdate()
118
+ }
119
+
120
+ /**
121
+ * unsubscribe from all events and disconnect controllers
122
+ */
123
+ disconnectedCallback() {
124
+ // unsubscribe from all subscriptions
125
+ this.#disposers.forEach((remover) => remover())
126
+ // disconnect all controllers
127
+ this.#controllers.forEach((controller) => controller.hostDisconnected?.())
128
+ }
129
+
130
+ /**
131
+ * @param {string} name change attribute
132
+ * @param {any} _oldValue
133
+ * @param {any} newValue new value
134
+ */
135
+ attributeChangedCallback(name, _oldValue, newValue) {
136
+ const attr = this.#getName(name)
137
+ const type = this.#getType(attr)
138
+ const _newValue = convertType(newValue, type)
139
+ this.#attr[attr] = this.#changedAttr[attr] = _newValue
140
+ // correct initial setting of `trueish="false"` otherwise there's no chance
141
+ // to overwrite a trueish value. The case `falsish="true"` is covered.
142
+ if (type === 'Boolean' && newValue === 'false') {
143
+ this.removeAttribute(name)
144
+ }
145
+ console.debug(
146
+ '%s.attributeChangedCallback("%s",',
147
+ this.nodeName,
148
+ name,
149
+ _oldValue,
150
+ newValue
151
+ )
152
+ this.requestUpdate()
153
+ }
154
+
155
+ /**
156
+ * Set string and number attributes on element only. Set all other values as
157
+ * properties to avoid type conversion to and from string
158
+ * @param {string} name
159
+ * @param {any} newValue
160
+ */
161
+ setAttribute(name, newValue) {
162
+ const attr = this.#getName(name)
163
+ // only allow to change observedAttributes
164
+ if (!(attr in this.#attr)) {
165
+ return
166
+ }
167
+ const type = this.#getType(attr)
168
+ this.#attr[attr] = this.#changedAttr[attr] = newValue
169
+ console.debug('%s.setAttribute("%s",', this.nodeName, name, newValue)
170
+
171
+ // only set string values in these cases
172
+ if (type === 'Boolean') {
173
+ if (newValue === true || newValue === '') {
174
+ super.setAttribute(name, '')
175
+ } else {
176
+ super.removeAttribute(name)
177
+ }
178
+ } else if (['String', 'Number'].includes(type) || newValue === true) {
179
+ super.setAttribute(name, newValue)
180
+ } else {
181
+ this.requestUpdate()
182
+ }
183
+ }
184
+
185
+ /**
186
+ * request rendering
187
+ */
188
+ requestUpdate() {
189
+ if (!this.isConnected) return
190
+ requestAnimationFrame(() => {
191
+ if (this.shouldUpdate(this.#changedAttr)) {
192
+ this.update(this.#changedAttr)
193
+ }
194
+ // reset changed attributes
195
+ this.#changedAttr = {}
196
+ })
197
+ }
198
+
199
+ /**
200
+ * adds a template to renderRoot
201
+ * @param {HTMLTemplateElement}
202
+ */
203
+ addTemplate(template) {
204
+ if (!(template instanceof HTMLTemplateElement)) {
205
+ console.debug('template is not a HTMLTemplateElement')
206
+ return
207
+ }
208
+ this.renderRoot.appendChild(template.content.cloneNode(true))
209
+ }
210
+
211
+ /**
212
+ * initial rendering
213
+ */
214
+ render() {}
215
+
216
+ /**
217
+ * controls if component shall be updated
218
+ * @param {Record<string,any>} _changedAttributes changed attributes
219
+ * @returns {boolean}
220
+ */
221
+ shouldUpdate(_changedAttributes) {
222
+ return true
223
+ }
224
+
225
+ /**
226
+ * called every time the components needs a render update
227
+ * @param {Record<string,any>} _changedAttributes changed attributes
228
+ */
229
+ update(_changedAttributes) {}
230
+
231
+ /**
232
+ * Adds listener function for eventName. listener is removed before component
233
+ * disconnects
234
+ * @param {string} eventName
235
+ * @param {EventListenerOrEventListenerObject} listener
236
+ * @param {Node|Document|Window} [node=this]
237
+ */
238
+ on(eventName, listener, node = this) {
239
+ node.addEventListener(eventName, listener)
240
+ this.#disposers.add(() => node.removeEventListener(eventName, listener))
241
+ }
242
+
243
+ /**
244
+ * Adds one-time listener function for eventName. The next time eventName is
245
+ * triggered, this listener is removed and then invoked.
246
+ * @param {string} eventName
247
+ * @param {EventListenerOrEventListenerObject} listener
248
+ * @param {Node|Document|Window} node
249
+ */
250
+ once(eventName, listener, node = this) {
251
+ node.addEventListener(eventName, listener, { once: true })
252
+ }
253
+
254
+ /**
255
+ * Unsubscribe a listener function for disposal on disconnectedCallback()
256
+ * @param {function} listener
257
+ */
258
+ dispose(listener) {
259
+ if (typeof listener !== 'function') {
260
+ throw new TypeError('listener must be a function')
261
+ }
262
+ this.#disposers.add(listener)
263
+ }
264
+
265
+ /**
266
+ * adds a connected controller
267
+ * @param {HostController} controller
268
+ */
269
+ addController(controller) {
270
+ this.#controllers.add(controller)
271
+ if (this.isConnected) {
272
+ // if already connected call hostConnected() immediately
273
+ /* istanbul ignore next */
274
+ controller.hostConnected?.()
275
+ }
276
+ }
277
+
278
+ /**
279
+ * removes a connected controller
280
+ * @param {HostController} controller
281
+ */
282
+ /* istanbul ignore next 3 */
283
+ removeController(controller) {
284
+ this.#controllers.delete(controller)
285
+ }
286
+ }
287
+
288
+ /**
289
+ * defines a custom element adding observedAttributes from default static
290
+ * attributes
291
+ * NOTE: camelCased attributes get lowercased!
292
+ * ```html
293
+ * <custom-element myAttr="1">
294
+ * <!-- is equal to -->
295
+ * <custom-element myattr="1">
296
+ * ```
297
+ * @param {string} name custom element tag
298
+ * @param {typeof MiElement} element
299
+ * @param {object} [options]
300
+ */
301
+ export const define = (name, element, options) => {
302
+ // @ts-expect-error
303
+ element.observedAttributes = // @ts-expect-error
304
+ (element.observedAttributes || Object.keys(element.attributes || [])).map(
305
+ (attr) => attr.toLowerCase()
306
+ )
307
+ renderTemplate(element)
308
+ window.customElements.define(name, element, options)
309
+ }
310
+
311
+ // --- utils
312
+
313
+ const renderTemplate = (element) => {
314
+ if (typeof element.template !== 'string') {
315
+ return
316
+ }
317
+ const el = document.createElement('template')
318
+ el.innerHTML = element.template
319
+ element.template = el
320
+ }
321
+
322
+ const initialType = (value) => toString.call(value).slice(8, -1)
323
+
324
+ const toNumber = (any) => {
325
+ const n = Number(any)
326
+ return isNaN(n) ? any : n
327
+ }
328
+
329
+ export const convertType = (any, type) => {
330
+ // setAttribute prevents passing Object or Array type. no further conversion required
331
+ switch (type) {
332
+ case 'Number':
333
+ return toNumber(any)
334
+ case 'Boolean':
335
+ // boolean values are set via setAttribute as empty string
336
+ if (any === 'false') {
337
+ return false
338
+ }
339
+ return any === '' || !!any
340
+ }
341
+ return any
342
+ }
package/src/escape.js ADDED
@@ -0,0 +1,38 @@
1
+ const escMap = {
2
+ '&': '&amp;',
3
+ '<': '&lt;',
4
+ '>': '&gt;',
5
+ "'": '&#39;',
6
+ '"': '&quot;'
7
+ }
8
+
9
+ /**
10
+ * escape HTML and prevent double escaping of '&'
11
+ * @param {string} string - which requires escaping
12
+ * @returns {string} escaped string
13
+ * @example
14
+ * escapeHTML('<h1>"One" & 'Two' &amp; Works</h1>')
15
+ * //> &lt;h1&gt;&quot;One&quot; &amp; &#39;Two&#39; &amp; Works&lt;/h1&gt;
16
+ */
17
+ 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])
30
+
31
+ /**
32
+ * template literal to HTML escape all values preventing XSS
33
+ * @param {string[]} strings
34
+ * @param {...any} vars
35
+ * @returns {string}
36
+ */
37
+ export const esc = (strings, ...vars) =>
38
+ strings.map((string, i) => string + escHtml(vars[i] ?? '')).join('')
package/src/index.js CHANGED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @typedef {import('./context.js').Context} Context
3
+ */
4
+ export {
5
+ ContextConsumer,
6
+ ContextProvider,
7
+ ContextRequestEvent
8
+ } from './context.js'
9
+ /**
10
+ * @typedef {import('./element.js').HostController} HostController
11
+ */
12
+ export { MiElement, convertType, define } from './element.js'
13
+ export { esc, escAttr, escHtml } from './escape.js'
14
+ export { refsById, refsBySelector } from './refs.js'
15
+ /**
16
+ * @typedef {import('./signal.js').Callback} Callback
17
+ */
18
+ export { Signal, createSignal, isSignalLike } from './signal.js'
19
+ /**
20
+ * @typedef {import('./store.js').Action} Action
21
+ */
22
+ export { Store, subscribeToStore } from './store.js'
package/src/refs.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Helper function to find `id` attributes in `container`s node tree.
3
+ * id names are camelCased, e.g. 'list-container' becomes 'listContainer'
4
+ * @param {Element} container root element
5
+ * @returns {Record<string, Node>|{}} record of found references
6
+ * @example
7
+ * el.innerHTML = `<p id>unnamed <span id="named">and named</span> reference</p>`
8
+ * references = refs(el)
9
+ * //> references = { p: <p>, named: <span> }
10
+ */
11
+ export function refsById(container) {
12
+ const nodes = container.querySelectorAll?.('[id]') || []
13
+ const found = {}
14
+ for (const node of nodes) {
15
+ const name = kebabToCamelCase(
16
+ node.getAttribute('id') || node.nodeName.toLowerCase()
17
+ )
18
+ found[name] = node
19
+ }
20
+ return found
21
+ }
22
+
23
+ /**
24
+ * convert kebab-case to lowerCamelCase
25
+ * @param {string} str
26
+ * @returns {string}
27
+ */
28
+ export const kebabToCamelCase = (str = '') =>
29
+ str.toLowerCase().replace(/[-_]\w/g, (m) => m[1].toUpperCase())
30
+
31
+ /**
32
+ * Helper function to gather references by a map of selectors
33
+ * @param {Element} container root element
34
+ * @param {Record<string, string>} selectors
35
+ * @returns {Record<string, Node>|{}}
36
+ * @example
37
+ * el.innerHTML = `<p>some <span>and other</span> reference</p>`
38
+ * references = refs(el, { p: 'p', named: 'p > span' })
39
+ * //> references = { p: <p>, named: <span> }
40
+ */
41
+ export function refsBySelector(container, selectors) {
42
+ const found = {}
43
+ for (const [name, selector] of Object.entries(selectors)) {
44
+ found[name] = container.querySelector?.(selector)
45
+ }
46
+ return found
47
+ }
package/src/signal.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @typedef {(value: any) => void} Callback
3
+ */
4
+
5
+ export class Signal {
6
+ _subscribers = new Set()
7
+
8
+ /**
9
+ * creates a new signal with an initial value
10
+ * @param {any} [initialValue]
11
+ */
12
+ constructor(initialValue) {
13
+ this._value = initialValue
14
+ }
15
+
16
+ /**
17
+ * return current value
18
+ * @returns {any}
19
+ */
20
+ get value() {
21
+ return this._value
22
+ }
23
+
24
+ /**
25
+ * set new value on signal;
26
+ * if value has changed all subscribers are called
27
+ * @param {any} newValue
28
+ */
29
+ set value(newValue) {
30
+ // value or reference must have changed to notify subscribers
31
+ if (this._value === newValue) {
32
+ return
33
+ }
34
+ this._value = newValue
35
+ this.notify()
36
+ }
37
+
38
+ /**
39
+ * notify all subscribers on current value
40
+ */
41
+ notify() {
42
+ for (const callback of this._subscribers) {
43
+ callback(this._value)
44
+ }
45
+ }
46
+
47
+ /**
48
+ * subscribe to signal to receive value updates
49
+ * @param {Callback} callback
50
+ * @returns {() => void} unsubscribe function
51
+ */
52
+ subscribe(callback) {
53
+ this._subscribers.add(callback)
54
+ const unsubscribe = () => {
55
+ this._subscribers.delete(callback)
56
+ }
57
+ return unsubscribe
58
+ }
59
+ }
60
+
61
+ /**
62
+ * creates a signal
63
+ * @param {any} [initialValue]
64
+ * @returns {Signal}
65
+ */
66
+ export const createSignal = (initialValue) => new Signal(initialValue)
67
+
68
+ /**
69
+ * check if implements signal like features
70
+ * @param {any} possibleSignal
71
+ * @returns {boolean}
72
+ */
73
+ export const isSignalLike = (possibleSignal) =>
74
+ typeof possibleSignal?.subscribe === 'function' &&
75
+ typeof possibleSignal?.notify === 'function' &&
76
+ 'value' in possibleSignal
package/src/store.js ADDED
@@ -0,0 +1,99 @@
1
+ import { Signal, isSignalLike } from './signal.js'
2
+
3
+ /**
4
+ * @typedef {import('./element.js').MiElement} MiElement
5
+ */
6
+ /**
7
+ * @typedef {(state: any, data?: any) => any} Action
8
+ */
9
+
10
+ /**
11
+ * Store implementing [Flux](https://www.npmjs.com/package/flux) pattern based
12
+ * on Signals
13
+ */
14
+ export class Store extends Signal {
15
+ /**
16
+ * @param {Record<string, Action>} actions
17
+ * @param {any} [initialValue]
18
+ * @example
19
+ * ```js
20
+ * const actions = { increment: (by = 1) => (current) => current + by }
21
+ * const initialValue = 1
22
+ * const store = new Store(actions, initialValue)
23
+ * // subscribe with a callback function
24
+ * const unsubscribe = store.subscribe((value) => console.log(`count is ${value}`))
25
+ * // change the store
26
+ * store.increment(2) // increment by 2
27
+ * //> count is 3
28
+ * unsubscribe()
29
+ * ```
30
+ *
31
+ * if `initialValue` is an object, the object's reference must be changed
32
+ * using the spread operator, in order to notify on state changes, e.g.
33
+ * ```js
34
+ * const initialValue = { count: 0, other: 'foo' }
35
+ * const actions = {
36
+ * increment: (by = 1) => (state) => ({...state, count: state.count + by})
37
+ * }
38
+ * ```
39
+ */
40
+ constructor(actions, initialValue) {
41
+ super(initialValue)
42
+ for (const [action, dispatcher] of Object.entries(actions)) {
43
+ if (process.env.NODE_ENV !== 'production') {
44
+ if (this[action]) {
45
+ throw new Error(`action "${action}" is already defined`)
46
+ }
47
+ if (
48
+ typeof dispatcher !== 'function' ||
49
+ typeof dispatcher(undefined) !== 'function'
50
+ ) {
51
+ throw new Error(
52
+ `action "${action}" must be a function of type \`() => (state) => state\``
53
+ )
54
+ }
55
+ }
56
+ this[action] = (data) => {
57
+ this.value = dispatcher(data)(this.value)
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * subscribe to a store from a MiElement with unsubscription from store on
65
+ * disconnectedCallback()
66
+ * @param {MiElement} element
67
+ * @param {Store} store
68
+ * @param {string|string[]|Signal} propOrSignal element property to apply store
69
+ * value updates
70
+ */
71
+ export const subscribeToStore = (element, store, propOrSignal) => {
72
+ if (propOrSignal instanceof Signal || isSignalLike(propOrSignal)) {
73
+ element.dispose(
74
+ store.subscribe((value) => {
75
+ // @ts-expect-error
76
+ propOrSignal.value = value
77
+ })
78
+ )
79
+ return
80
+ }
81
+ const keys = Array.isArray(propOrSignal)
82
+ ? propOrSignal
83
+ : propOrSignal.split('.').filter(Boolean)
84
+ const last = keys.pop()
85
+ if (!last) throw TypeError('need prop')
86
+ let tmp = element
87
+ for (const key of keys) {
88
+ if (typeof tmp[key] !== 'object') {
89
+ throw new TypeError(`object expected for property "${key}"`)
90
+ }
91
+ tmp = tmp[key]
92
+ }
93
+ element.dispose(
94
+ store.subscribe((value) => {
95
+ tmp[last] = value
96
+ element.requestUpdate()
97
+ })
98
+ )
99
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @implements {HostController}
3
+ */
4
+ export class ContextProvider implements HostController {
5
+ /**
6
+ * @param {MiElement} host
7
+ * @param {Context} context
8
+ * @param {any} initialValue
9
+ */
10
+ constructor(host: MiElement, context: Context, initialValue: any)
11
+ host: import('./element.js').MiElement
12
+ context: Context
13
+ state: any
14
+ hostConnected(): void
15
+ hostDisconnected(): void
16
+ set value(newValue: any)
17
+ get value(): any
18
+ notify(): void
19
+ onContextRequest: (ev: any) => void
20
+ }
21
+ export class ContextRequestEvent extends Event {
22
+ /**
23
+ * @param {Context} context
24
+ * @param {(value: any, unsubscribe?: () => void) => void} callback
25
+ * @param {boolean} [subscribe=false] subscribe to value changes
26
+ */
27
+ constructor(
28
+ context: Context,
29
+ callback: (value: any, unsubscribe?: () => void) => void,
30
+ subscribe?: boolean | undefined
31
+ )
32
+ context: Context
33
+ callback: (value: any, unsubscribe?: () => void) => void
34
+ subscribe: boolean | undefined
35
+ }
36
+ /**
37
+ * @implements {HostController}
38
+ */
39
+ export class ContextConsumer implements HostController {
40
+ /**
41
+ * @param {MiElement} host
42
+ * @param {Context} context
43
+ * @param {object} [options]
44
+ * @param {boolean} [options.subscribe=false] subscribe to value changes
45
+ * @param {(any) => boolean} [options.validate] validation function
46
+ */
47
+ constructor(
48
+ host: MiElement,
49
+ context: Context,
50
+ options?:
51
+ | {
52
+ subscribe?: boolean | undefined
53
+ validate?: ((any: any) => boolean) | undefined
54
+ }
55
+ | undefined
56
+ )
57
+ host: import('./element.js').MiElement
58
+ context: Context
59
+ subscribe: boolean
60
+ validate: (any: any) => boolean
61
+ value: any
62
+ unsubscribe: any
63
+ hostConnected(): void
64
+ hostDisconnected(): void
65
+ dispatchRequest(): void
66
+ _callback(value: any, unsubscribe: any): void
67
+ }
68
+ export type HostController = import('./element.js').HostController
69
+ export type MiElement = import('./element.js').MiElement
70
+ export type Context = string | Symbol