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/LICENSE +20 -0
- package/README.md +88 -0
- package/dist/context.js +62 -0
- package/dist/element.js +109 -0
- package/dist/escape.js +9 -0
- package/dist/index.js +11 -0
- package/dist/refs.js +15 -0
- package/dist/signal.js +24 -0
- package/dist/store.js +28 -0
- package/package.json +87 -4
- package/src/context.js +162 -0
- package/src/element.js +342 -0
- package/src/escape.js +38 -0
- package/src/index.js +22 -0
- package/src/refs.js +47 -0
- package/src/signal.js +76 -0
- package/src/store.js +99 -0
- package/types/context.d.ts +70 -0
- package/types/element.d.ts +169 -0
- package/types/escape.d.ts +3 -0
- package/types/index.d.ts +14 -0
- package/types/refs.d.ts +24 -0
- package/types/signal.d.ts +36 -0
- package/types/store.d.ts +46 -0
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
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
"'": ''',
|
|
6
|
+
'"': '"'
|
|
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' & Works</h1>')
|
|
15
|
+
* //> <h1>"One" & 'Two' & Works</h1>
|
|
16
|
+
*/
|
|
17
|
+
export const escHtml = (string) =>
|
|
18
|
+
('' + string).replace(/&/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
|
+
* //> "One's"
|
|
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
|