mi-element 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/element.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { camelToKebabCase } from './case.js'
2
+ import { createSignal } from './signal.js'
3
+
1
4
  /**
2
5
  * @typedef {object} HostController controller
3
6
  * @property {() => void} hostConnected is called when host element is added to
@@ -57,28 +60,29 @@ export class MiElement extends HTMLElement {
57
60
  constructor() {
58
61
  super()
59
62
  // @ts-expect-error
60
- this.#attr = { ...this.constructor.attributes }
61
- this.#observedAttributes()
63
+ this.#observedAttributes(this.constructor.attributes)
62
64
  }
63
65
 
64
66
  /**
65
67
  * requests update on component when property changes
66
68
  */
67
- #observedAttributes() {
68
- for (const [name, val] of Object.entries(this.#attr)) {
69
- this.#types.set(name, initialType(val))
69
+ #observedAttributes(attributes = {}) {
70
+ for (const [name, value] of Object.entries(attributes)) {
71
+ this.#types.set(name, initialType(value))
70
72
  this.#attrLc.set(name.toLowerCase(), name)
73
+ this.#attrLc.set(camelToKebabCase(name), name)
74
+ this.#attr[name] = createSignal(value)
71
75
  Object.defineProperty(this, name, {
72
76
  enumerable: true,
73
77
  get() {
74
- return this.#attr[name]
78
+ return this.#attr[name].get()
75
79
  },
76
80
  set(newValue) {
77
81
  console.debug('%s.%s =', this.nodeName, name, newValue)
78
- const oldValue = this.#attr[name]
82
+ const oldValue = this.#attr[name].get()
79
83
  if (oldValue === newValue) return
80
- this.#attr[name] = newValue
81
- this.#changedAttr[name] = newValue
84
+ this.#attr[name].set(newValue)
85
+ this.#changedAttr[name] = oldValue
82
86
  this.requestUpdate()
83
87
  }
84
88
  })
@@ -129,14 +133,14 @@ export class MiElement extends HTMLElement {
129
133
 
130
134
  /**
131
135
  * @param {string} name change attribute
132
- * @param {any} _oldValue
136
+ * @param {any} oldValue
133
137
  * @param {any} newValue new value
134
138
  */
135
- attributeChangedCallback(name, _oldValue, newValue) {
139
+ attributeChangedCallback(name, oldValue, newValue) {
136
140
  const attr = this.#getName(name)
137
141
  const type = this.#getType(attr)
138
- const _newValue = convertType(newValue, type)
139
- this.#attr[attr] = this.#changedAttr[attr] = _newValue
142
+ this.#changedAttr[attr] = this[attr]
143
+ this[attr] = convertType(newValue, type)
140
144
  // correct initial setting of `trueish="false"` otherwise there's no chance
141
145
  // to overwrite a trueish value. The case `falsish="true"` is covered.
142
146
  if (type === 'Boolean' && newValue === 'false') {
@@ -146,7 +150,7 @@ export class MiElement extends HTMLElement {
146
150
  '%s.attributeChangedCallback("%s",',
147
151
  this.nodeName,
148
152
  name,
149
- _oldValue,
153
+ oldValue,
150
154
  newValue
151
155
  )
152
156
  this.requestUpdate()
@@ -165,7 +169,6 @@ export class MiElement extends HTMLElement {
165
169
  return
166
170
  }
167
171
  const type = this.#getType(attr)
168
- this.#attr[attr] = this.#changedAttr[attr] = newValue
169
172
  console.debug('%s.setAttribute("%s",', this.nodeName, name, newValue)
170
173
 
171
174
  // only set string values in these cases
@@ -178,10 +181,21 @@ export class MiElement extends HTMLElement {
178
181
  } else if (['String', 'Number'].includes(type) || newValue === true) {
179
182
  super.setAttribute(name, newValue)
180
183
  } else {
184
+ this.#changedAttr[attr] = this[attr]
185
+ this[attr] = newValue
181
186
  this.requestUpdate()
182
187
  }
183
188
  }
184
189
 
190
+ /**
191
+ * controls if component shall be updated
192
+ * @param {Record<string,any>} [_changedAttributes] previous values of changed attributes
193
+ * @returns {boolean}
194
+ */
195
+ shouldUpdate(_changedAttributes) {
196
+ return true
197
+ }
198
+
185
199
  /**
186
200
  * request rendering
187
201
  */
@@ -198,7 +212,7 @@ export class MiElement extends HTMLElement {
198
212
 
199
213
  /**
200
214
  * adds a template to renderRoot
201
- * @param {HTMLTemplateElement}
215
+ * @param {HTMLTemplateElement} template
202
216
  */
203
217
  addTemplate(template) {
204
218
  if (!(template instanceof HTMLTemplateElement)) {
@@ -213,18 +227,10 @@ export class MiElement extends HTMLElement {
213
227
  */
214
228
  render() {}
215
229
 
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
230
  /**
226
231
  * called every time the components needs a render update
227
- * @param {Record<string,any>} _changedAttributes changed attributes
232
+ * @param {Record<string,any>} [_changedAttributes] previous values of changed
233
+ * attributes
228
234
  */
229
235
  update(_changedAttributes) {}
230
236
 
@@ -253,13 +259,15 @@ export class MiElement extends HTMLElement {
253
259
 
254
260
  /**
255
261
  * Unsubscribe a listener function for disposal on disconnectedCallback()
256
- * @param {function} listener
262
+ * @param {...function} listeners
257
263
  */
258
- dispose(listener) {
259
- if (typeof listener !== 'function') {
260
- throw new TypeError('listener must be a function')
264
+ dispose(...listeners) {
265
+ for (const listener of listeners) {
266
+ if (typeof listener !== 'function') {
267
+ throw new TypeError('listener must be a function')
268
+ }
269
+ this.#disposers.add(listener)
261
270
  }
262
- this.#disposers.add(listener)
263
271
  }
264
272
 
265
273
  /**
package/src/index.js CHANGED
@@ -13,10 +13,13 @@ export { MiElement, convertType, define } from './element.js'
13
13
  export { esc, escAttr, escHtml } from './escape.js'
14
14
  export { refsById, refsBySelector } from './refs.js'
15
15
  /**
16
- * @typedef {import('./signal.js').Callback} Callback
16
+ * @template T
17
+ * @typedef {import('./signal.js').SignalOptions<T>} SignalOptions<T>
17
18
  */
18
- export { Signal, createSignal, isSignalLike } from './signal.js'
19
+ import Signal from './signal.js'
20
+ export { Signal }
19
21
  /**
20
22
  * @typedef {import('./store.js').Action} Action
21
23
  */
22
- export { Store, subscribeToStore } from './store.js'
24
+ export { Store } from './store.js'
25
+ export { classMap, styleMap } from './styling.js'
package/src/refs.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { kebabToCamelCase } from './case.js'
2
+
1
3
  /**
2
4
  * Helper function to find `id` attributes in `container`s node tree.
3
5
  * id names are camelCased, e.g. 'list-container' becomes 'listContainer'
@@ -20,14 +22,6 @@ export function refsById(container) {
20
22
  return found
21
23
  }
22
24
 
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
25
  /**
32
26
  * Helper function to gather references by a map of selectors
33
27
  * @param {Element} container root element
package/src/signal.js CHANGED
@@ -1,76 +1,125 @@
1
1
  /**
2
- * @typedef {(value: any) => void} Callback
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
3
6
  */
4
7
 
5
- export class Signal {
6
- _subscribers = new Set()
8
+ // global context for nested reactivity
9
+ const context = []
10
+
11
+ /**
12
+ * @template T
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`
16
+ */
17
+
18
+ /**
19
+ * @template T
20
+ * @typedef {{equals: EqualsFn<T>}} SignalOptions
21
+ */
22
+
23
+ /**
24
+ * read- write signal
25
+ * @template T
26
+ */
27
+ export class State extends EventTarget {
28
+ #value
29
+ #equals
7
30
 
8
31
  /**
9
- * creates a new signal with an initial value
10
- * @param {any} [initialValue]
32
+ * @param {T|null} [value]
33
+ * @param {SignalOptions<T>} [options]
11
34
  */
12
- constructor(initialValue) {
13
- this._value = initialValue
35
+ constructor(value, options) {
36
+ super()
37
+ const { equals } = options || {}
38
+ this.#value = value
39
+ this.#equals = equals ?? ((value, nextValue) => value === nextValue)
14
40
  }
15
41
 
16
42
  /**
17
- * return current value
18
- * @returns {any}
43
+ * @returns {T|null|undefined}
19
44
  */
20
- get value() {
21
- return this._value
45
+ get() {
46
+ const running = context[context.length - 1]
47
+ if (running) {
48
+ running.add(this)
49
+ }
50
+ return this.#value
22
51
  }
23
52
 
24
53
  /**
25
- * set new value on signal;
26
- * if value has changed all subscribers are called
27
- * @param {any} newValue
54
+ * @param {T|null|undefined} nextValue
28
55
  */
29
- set value(newValue) {
30
- // value or reference must have changed to notify subscribers
31
- if (this._value === newValue) {
56
+ set(nextValue) {
57
+ if (this.#equals(this.#value, nextValue)) {
32
58
  return
33
59
  }
34
- this._value = newValue
35
- this.notify()
60
+ this.#value = nextValue
61
+ this.dispatchEvent(new CustomEvent('signal'))
36
62
  }
63
+ }
37
64
 
38
- /**
39
- * notify all subscribers on current value
40
- */
41
- notify() {
42
- for (const callback of this._subscribers) {
43
- callback(this._value)
44
- }
65
+ /**
66
+ * @template T
67
+ * @param {T} initialValue
68
+ * @param {SignalOptions<T>} [options]
69
+ * @returns {State<T>}
70
+ */
71
+ export const createSignal = (initialValue, options) =>
72
+ initialValue instanceof State
73
+ ? initialValue
74
+ : new State(initialValue, options)
75
+
76
+ /**
77
+ * effect subscribes to state at first run only. Do not hide a signal.get()
78
+ * inside conditionals!
79
+ * @param {() => void|Promise<void>} cb
80
+ */
81
+ export function effect(cb) {
82
+ const running = new Set()
83
+
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)
45
92
  }
46
93
 
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)
94
+ return () => {
95
+ // unsubscribe from all dependencies
96
+ for (const dep of running) {
97
+ dep.removeEventListener('signal', cb)
56
98
  }
57
- return unsubscribe
58
99
  }
59
100
  }
60
101
 
61
102
  /**
62
- * creates a signal
63
- * @param {any} [initialValue]
64
- * @returns {Signal}
103
+ * @template T
65
104
  */
66
- export const createSignal = (initialValue) => new Signal(initialValue)
105
+ export class Computed {
106
+ #state
67
107
 
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
108
+ /**
109
+ * @param {() => T} cb
110
+ */
111
+ constructor(cb) {
112
+ this.#state = new State()
113
+ effect(() => this.#state.set(cb()))
114
+ }
115
+
116
+ /**
117
+ * @template T
118
+ * @returns {T}
119
+ */
120
+ get() {
121
+ return this.#state.get()
122
+ }
123
+ }
124
+
125
+ export default { State, createSignal, effect, Computed }
package/src/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Signal, isSignalLike } from './signal.js'
1
+ import { State } from './signal.js'
2
2
 
3
3
  /**
4
4
  * @typedef {import('./element.js').MiElement} MiElement
@@ -6,22 +6,29 @@ import { Signal, isSignalLike } from './signal.js'
6
6
  /**
7
7
  * @typedef {(state: any, data?: any) => any} Action
8
8
  */
9
+ /**
10
+ * @template T
11
+ * @typedef {import('./signal.js').SignalOptions<T>} SignalOptions<T>
12
+ */
9
13
 
10
14
  /**
11
15
  * Store implementing [Flux](https://www.npmjs.com/package/flux) pattern based
12
16
  * on Signals
17
+ * @template T
13
18
  */
14
- export class Store extends Signal {
19
+ export class Store extends State {
15
20
  /**
16
21
  * @param {Record<string, Action>} actions
17
- * @param {any} [initialValue]
22
+ * @param {T|null} [initialValue]
23
+ * @param {SignalOptions<T>} [options]
18
24
  * @example
19
25
  * ```js
26
+ * import { Signal, Store } from 'mi-element'
20
27
  * const actions = { increment: (by = 1) => (current) => current + by }
21
28
  * const initialValue = 1
22
29
  * const store = new Store(actions, initialValue)
23
30
  * // subscribe with a callback function
24
- * const unsubscribe = store.subscribe((value) => console.log(`count is ${value}`))
31
+ * const unsubscribe = Signal.effect(() => console.log(`count is ${store.get()}`))
25
32
  * // change the store
26
33
  * store.increment(2) // increment by 2
27
34
  * //> count is 3
@@ -33,12 +40,24 @@ export class Store extends Signal {
33
40
  * ```js
34
41
  * const initialValue = { count: 0, other: 'foo' }
35
42
  * const actions = {
36
- * increment: (by = 1) => (state) => ({...state, count: state.count + by})
43
+ * increment: (by = 1) => (state) => ({...state, count: state.count + by})
44
+ * }
45
+ * ```
46
+ * or you change the signals options equality function
47
+ * ```js
48
+ * const actions = {
49
+ * increment: (by = 1) => (state) => {
50
+ * state.count += by
51
+ * return state
52
+ * }
37
53
  * }
54
+ * const initialValue = { count: 0, other: 'foo' }
55
+ * const options = { equals: (value, nextValue) => true }
56
+ * const store = new Store(actions, initialValue, options)
38
57
  * ```
39
58
  */
40
- constructor(actions, initialValue) {
41
- super(initialValue)
59
+ constructor(actions, initialValue, options) {
60
+ super(initialValue, options)
42
61
  for (const [action, dispatcher] of Object.entries(actions)) {
43
62
  if (process.env.NODE_ENV !== 'production') {
44
63
  if (this[action]) {
@@ -53,47 +72,7 @@ export class Store extends Signal {
53
72
  )
54
73
  }
55
74
  }
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}"`)
75
+ this[action] = (data) => this.set(dispatcher(data)(this.get()))
90
76
  }
91
- tmp = tmp[key]
92
77
  }
93
- element.dispose(
94
- store.subscribe((value) => {
95
- tmp[last] = value
96
- element.requestUpdate()
97
- })
98
- )
99
78
  }
package/src/styling.js ADDED
@@ -0,0 +1,33 @@
1
+ import { camelToKebabCase } from './case.js'
2
+
3
+ /**
4
+ * Construct className based on trueish values of map
5
+ * @param {{[name: string]: string | boolean | number}} map
6
+ * @returns {string}
7
+ */
8
+ export const classMap = (map) => {
9
+ /** @type {string[]} */
10
+ const acc = []
11
+ for (const [name, value] of Object.entries(map ?? {})) {
12
+ if (value) acc.push(name)
13
+ }
14
+ return acc.join(' ')
15
+ }
16
+
17
+ /**
18
+ * Construct style from camelCased map.
19
+ * @param {{[name: string]: string | number | undefined | null}} map
20
+ * @param {object} [options]
21
+ * @param {string} [options.unit] cssUnit for number values; default='px'
22
+ * @returns {string}
23
+ */
24
+ export const styleMap = (map, options) => {
25
+ const { unit = 'px' } = options || {}
26
+ const acc = []
27
+ for (const [name, value] of Object.entries(map ?? {})) {
28
+ if (value === null || value === undefined) continue
29
+ const _unit = Number.isFinite(value) ? unit : ''
30
+ acc.push(`${camelToKebabCase(name)}:${value}${_unit}`)
31
+ }
32
+ return acc.join(';')
33
+ }
@@ -0,0 +1,2 @@
1
+ export function camelToKebabCase(str?: string): string;
2
+ export function kebabToCamelCase(str?: string): string;
@@ -2,69 +2,67 @@
2
2
  * @implements {HostController}
3
3
  */
4
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
5
+ /**
6
+ * @param {HTMLElement} host
7
+ * @param {Context} context
8
+ * @param {any} initialValue
9
+ */
10
+ constructor(host: HTMLElement, context: Context, initialValue: any);
11
+ host: HTMLElement;
12
+ context: Context;
13
+ state: import("./signal.js").State<any>;
14
+ hostConnected(): void;
15
+ hostDisconnected(): void;
16
+ /**
17
+ * @param {any} newValue
18
+ */
19
+ set(newValue: any): void;
20
+ /**
21
+ * @returns {any}
22
+ */
23
+ get(): any;
24
+ /**
25
+ * @private
26
+ * @param {ContextRequestEvent} ev
27
+ */
28
+ private onContextRequest;
20
29
  }
21
30
  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
31
+ /**
32
+ * @param {Context} context
33
+ * @param {(value: any, unsubscribe?: () => void) => void} callback
34
+ * @param {boolean} [subscribe=false] subscribe to value changes
35
+ */
36
+ constructor(context: Context, callback: (value: any, unsubscribe?: () => void) => void, subscribe?: boolean | undefined);
37
+ context: Context;
38
+ callback: (value: any, unsubscribe?: () => void) => void;
39
+ subscribe: boolean | undefined;
35
40
  }
36
41
  /**
37
42
  * @implements {HostController}
38
43
  */
39
44
  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
45
+ /**
46
+ * @param {HTMLElement} host
47
+ * @param {Context} context
48
+ * @param {object} [options]
49
+ * @param {boolean} [options.subscribe=false] subscribe to value changes
50
+ * @param {(any) => boolean} [options.validate] validation function
51
+ */
52
+ constructor(host: HTMLElement, context: Context, options?: {
53
+ subscribe?: boolean | undefined;
54
+ validate?: ((any: any) => boolean) | undefined;
55
+ } | undefined);
56
+ host: HTMLElement;
57
+ context: Context;
58
+ subscribe: boolean;
59
+ validate: (any: any) => boolean;
60
+ value: any;
61
+ unsubscribe: any;
62
+ hostConnected(): void;
63
+ hostDisconnected(): void;
64
+ dispatchRequest(): void;
65
+ _callback(value: any, unsubscribe: any): void;
67
66
  }
68
- export type HostController = import('./element.js').HostController
69
- export type MiElement = import('./element.js').MiElement
70
- export type Context = string | Symbol
67
+ export type HostController = import("./element.js").HostController;
68
+ export type Context = string | Symbol;