mi-element 0.6.7 → 0.7.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,6 +1,16 @@
1
- import { camelToKebabCase } from './case.js'
1
+ import { kebabToCamelCase, camelToKebabCase } from './case.js'
2
+ import { addGlobalStyles } from './styling.js'
3
+ import { refsBySelector } from './refs.js'
2
4
  import { createSignal } from 'mi-signal'
3
5
 
6
+ /**
7
+ * Mapping of attribute names to property names
8
+ */
9
+ const nameMap = {
10
+ class: 'className',
11
+ for: 'htmlFor'
12
+ }
13
+
4
14
  /**
5
15
  * @typedef {object} HostController controller
6
16
  * @property {() => void} hostConnected is called when host element is added to
@@ -16,12 +26,14 @@ import { createSignal } from 'mi-signal'
16
26
  * @example
17
27
  * ```js
18
28
  * class Example extends MiElement {
19
- * // define all observed attributes with its default initial value.
20
- * // for yet to defined numbers, boolean or strings use `Number`, `Boolean`, `String`
21
- * // attributes are accessible via `this[prop]`
22
- * // avoid using attributes which are HTMLElement properties e.g. className
29
+ * // define all observed attributes and define its type,
30
+ * // either use String/'', Number/0, Boolean/true, Array/[], Object/{}.
31
+ * // Objects and Arrays are deserialized from JSON.
32
+ * // Attributes are accessible via `this[prop]` as camelCased properties.
33
+ * // camelCased attributes are converted to kebab-case automatically.
34
+ * // Avoid using attributes which are HTMLElement properties e.g. className
23
35
  * static get attributes () {
24
- * return { text: 'Hi', num: Number }
36
+ * return { text: '', num: Number }
25
37
  * }
26
38
  * render() {
27
39
  * this.renderRoot.innerHTML = `<div></div>`
@@ -44,24 +56,14 @@ import { createSignal } from 'mi-signal'
44
56
  * ```
45
57
  */
46
58
  export class MiElement extends HTMLElement {
47
- /** all attributes are signals! */
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
- */
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
- */
60
- #types = new Map()
59
+ /** all properties are signals! */
60
+ _props = {}
61
+ /** changed properties */
62
+ #changedProps = {}
63
+
61
64
  #disposers = new Set()
62
65
  #controllers = new Set()
63
- #changedAttr = {}
64
- #dedupe = false
66
+ #updateRequested = false
65
67
 
66
68
  /**
67
69
  * Default options used when calling `attachShadow`. Used in
@@ -69,7 +71,9 @@ export class MiElement extends HTMLElement {
69
71
  * If override is `null`, no shadow-root will be attached.
70
72
  * @type {{mode: string}|null}
71
73
  */
72
- static shadowRootOptions = { mode: 'open' }
74
+ static get shadowRootInit() {
75
+ return { mode: 'open' }
76
+ }
73
77
 
74
78
  /**
75
79
  * defines template for render().
@@ -78,83 +82,75 @@ export class MiElement extends HTMLElement {
78
82
  static template
79
83
 
80
84
  /**
81
- * observable attributes
82
- * @returns {Record<PropertyKey, unknown>|{}}
83
- */
84
- static get attributes() {
85
- return {}
86
- }
87
- /**
88
- * observable properties
89
- * @returns {Record<PropertyKey, unknown>|{}}
85
+ * used to define observedAttributes and booleanAttributes during registration
86
+ * @returns {Record<string, {attribute?: boolean, type?:String|Number|Boolean|Array|Object, initial?: any}>} attribute name to isBoolean map
90
87
  */
91
88
  static get properties() {
92
- return {}
93
- }
94
-
95
- constructor() {
96
- super()
97
- // @ts-expect-error
98
- this.#observedAttributes(this.constructor.attributes)
89
+ // to be overridden
99
90
  // @ts-expect-error
100
- this.#observedProperties(this.constructor.properties)
91
+ return undefined
101
92
  }
102
-
103
- #observe(name, initialValue) {
104
- this.#attr[name] = createSignal(initialValue)
105
- Object.defineProperty(this, name, {
106
- enumerable: true,
107
- get() {
108
- return this.#attr[name].get()
109
- },
110
- set(newValue) {
111
- const oldValue = this.#attr[name].get()
112
- if (oldValue === newValue) return
113
- this.#attr[name].set(newValue)
114
- this.#changedAttr[name] = oldValue
115
- this.requestUpdate()
116
- }
117
- })
118
- }
119
-
120
93
  /**
121
- * requests update on component when property changes
122
- * @param {Record<string, any>} [attributes]
94
+ * @returns {string[]}
123
95
  */
124
- #observedAttributes(attributes = {}) {
125
- for (const [name, value] of Object.entries(attributes)) {
126
- const initial = initialValueType(value)
127
- this.#types.set(name, initial.type)
128
- this.#attrLc.set(name.toLowerCase(), name)
129
- this.#attrLc.set(camelToKebabCase(name), name)
130
- this.#observe(name, initial.value)
131
- }
132
- }
96
+ static observedAttributes = []
133
97
 
134
98
  /**
135
- * define (direct) properties
136
- * @param {Record<string, any>} [properties]
99
+ * @returns {string} css styles
137
100
  */
138
- #observedProperties(properties = {}) {
139
- for (const [name, value] of Object.entries(properties)) {
140
- if (this.#attrLc.has(name) || name in this.#attr) {
141
- continue
142
- }
143
- this.#observe(name, value)
144
- }
101
+ static styles = ''
102
+ /**
103
+ * Whether to use global styles instead of scoped styles.
104
+ * @returns {boolean}
105
+ */
106
+ static get useGlobalStyles() {
107
+ return false
145
108
  }
146
109
 
147
110
  /**
148
- * return camelCased value instead of possible lowercased
149
- * @param {string} name
150
- * @returns
111
+ * Define createSignal function for properties.
112
+ * Signal values are set with the .value property
113
+ * @returns {import('mi-signal').createSignal|null} createSignal function
151
114
  */
152
- #getName(name) {
153
- return this.#attrLc.get(name) || name
154
- }
115
+ static createSignal = createSignal
155
116
 
156
- #getType(name) {
157
- return this.#types.get(name)
117
+ constructor() {
118
+ super()
119
+ // @ts-expect-error
120
+ const { createSignal, properties } = this.constructor
121
+ for (const [name, { initial }] of Object.entries(properties)) {
122
+ // allow overwrites with setter, getters
123
+ const descriptor = Object.getOwnPropertyDescriptor(
124
+ this.constructor.prototype,
125
+ name
126
+ )
127
+ if (createSignal) {
128
+ this._props[name] = createSignal()
129
+ }
130
+ Object.defineProperty(this, name, {
131
+ get() {
132
+ if (descriptor?.get) {
133
+ return descriptor.get.call(this)
134
+ }
135
+ return createSignal ? this._props[name].value : this._props[name]
136
+ },
137
+ set(value) {
138
+ const oldValue = this[name]
139
+ if (descriptor?.set) {
140
+ descriptor.set.call(this, value)
141
+ } else if (createSignal) {
142
+ this._props[name].value = value
143
+ } else {
144
+ this._props[name] = value
145
+ }
146
+ if (oldValue !== this[name]) {
147
+ this.requestUpdate({ [name]: value })
148
+ }
149
+ }
150
+ })
151
+ // set initial value
152
+ this[name] = initial
153
+ }
158
154
  }
159
155
 
160
156
  /**
@@ -163,17 +159,17 @@ export class MiElement extends HTMLElement {
163
159
  */
164
160
  connectedCallback() {
165
161
  this.#controllers.forEach((controller) => controller.hostConnected?.())
166
- // create render root
167
162
  // @ts-expect-error
168
- const { shadowRootOptions, template } = this.constructor
169
- this.renderRoot = shadowRootOptions
170
- ? (this.shadowRoot ?? this.attachShadow(shadowRootOptions))
163
+ const { shadowRootInit, useGlobalStyles, template } = this.constructor
164
+ this.renderRoot = shadowRootInit
165
+ ? (this.shadowRoot ?? this.attachShadow(shadowRootInit))
171
166
  : this
172
167
  this.addTemplate(template)
173
- // trigger initial rendering such that children can be added via JS
174
- this.render()
175
- // and update
176
- this.requestUpdate()
168
+ if (useGlobalStyles) {
169
+ addGlobalStyles(this.renderRoot)
170
+ }
171
+ this.render() // initial render
172
+ this.requestUpdate() // request initial update
177
173
  }
178
174
 
179
175
  /**
@@ -188,75 +184,39 @@ export class MiElement extends HTMLElement {
188
184
 
189
185
  /**
190
186
  * @param {string} name change attribute
191
- * @param {any} oldValue
187
+ * @param {any} _oldValue
192
188
  * @param {any} newValue new value
193
189
  */
194
- attributeChangedCallback(name, oldValue, newValue) {
195
- const attr = this.#getName(name)
196
- const type = this.#getType(attr)
197
- this.#changedAttr[attr] = this[attr]
198
- this[attr] = convertType(newValue, type)
199
- // correct initial setting of `trueish="false"` otherwise there's no chance
200
- // to overwrite a trueish value. The case `falsish="true"` is covered.
201
- if (type === 'Boolean' && newValue === 'false') {
202
- this.removeAttribute(name)
203
- }
204
- this.requestUpdate()
205
- }
206
-
207
- /**
208
- * Set string and number attributes on element only. Set all other values as
209
- * properties to avoid type conversion to and from string
210
- * @param {string} name
211
- * @param {any} newValue
212
- */
213
- setAttribute(name, newValue) {
214
- const attr = this.#getName(name)
215
- // only allow to change observedAttributes
216
- if (!(attr in this.#attr)) {
217
- return
218
- }
219
- const type = this.#getType(attr)
220
-
221
- // only set string values in these cases
222
- if (type === 'Boolean') {
223
- if (newValue === true || newValue === '') {
224
- super.setAttribute(name, '')
225
- } else {
226
- super.removeAttribute(name)
190
+ attributeChangedCallback(name, _oldValue, newValue) {
191
+ const camelName = nameMap[name] ?? kebabToCamelCase(name)
192
+ // @ts-expect-error
193
+ const properties = this.constructor?.properties
194
+ const { type } = properties?.[camelName] ?? {}
195
+ const coercedValue = convertType(newValue, type)
196
+ // set data-* attributes to dataset
197
+ if (name.startsWith('data-')) {
198
+ const datasetName = kebabToCamelCase(name.substring(5))
199
+ // datasetName may be empty if attribute is just 'data-'
200
+ if (datasetName) {
201
+ this.dataset[datasetName] = coercedValue
227
202
  }
228
- } else if (['String', 'Number'].includes(type ?? '') || newValue === true) {
229
- super.setAttribute(name, newValue)
230
- } else {
231
- this.#changedAttr[attr] = this[attr]
232
- this[attr] = newValue
233
- this.requestUpdate()
234
203
  }
204
+ this[camelName] = coercedValue
235
205
  }
236
206
 
237
207
  /**
238
- * controls if component shall be updated
239
- * @param {Record<string,any>} [_changedAttributes] previous values of changed attributes
240
- * @returns {boolean}
208
+ * @param {Record<string, any>} [changedProps]
241
209
  */
242
- shouldUpdate(_changedAttributes) {
243
- return true
244
- }
245
-
246
- /**
247
- * request rendering
248
- */
249
- requestUpdate() {
250
- if (this.#dedupe || !this.isConnected) return
251
- this.#dedupe = true
252
- requestAnimationFrame(() => {
253
- this.#dedupe = false
254
- // reset changed attributes
255
- const _changedAttributes = this.#changedAttr
256
- this.#changedAttr = {}
257
- if (this.shouldUpdate(_changedAttributes)) {
258
- this.update(_changedAttributes)
259
- }
210
+ requestUpdate(changedProps) {
211
+ this.#changedProps = { ...this.#changedProps, ...changedProps }
212
+ if (this.#updateRequested || !this.renderRoot) return
213
+ this.#updateRequested = true
214
+ window.requestAnimationFrame(() => {
215
+ this.#updateRequested = false
216
+ // reset changed properties
217
+ const changedProps = this.#changedProps
218
+ this.#changedProps = {}
219
+ this.update(changedProps)
260
220
  })
261
221
  }
262
222
 
@@ -275,14 +235,18 @@ export class MiElement extends HTMLElement {
275
235
  /**
276
236
  * initial rendering
277
237
  */
278
- render() {}
238
+ render() {
239
+ // to be overridden
240
+ }
279
241
 
280
242
  /**
281
243
  * called every time the components needs a render update
282
- * @param {Record<string,any>} [_changedAttributes] previous values of changed
283
- * attributes
244
+ * @param {Record<string, any>} [_changedProps] previous values of changed
245
+ * properties (attributes)
284
246
  */
285
- update(_changedAttributes) {}
247
+ update(_changedProps) {
248
+ // to be overridden
249
+ }
286
250
 
287
251
  /**
288
252
  * Adds listener function for eventName. listener is removed before component
@@ -341,29 +305,68 @@ export class MiElement extends HTMLElement {
341
305
  removeController(controller) {
342
306
  this.#controllers.delete(controller)
343
307
  }
308
+
309
+ refsBySelector(selectors) {
310
+ return refsBySelector(this.renderRoot, selectors)
311
+ }
344
312
  }
345
313
 
346
314
  /**
347
315
  * defines a custom element adding observedAttributes from default static
348
316
  * attributes
349
- * NOTE: camelCased attributes get lowercased!
317
+ * NOTE: camelCased attributes on DOM elements get lowercased by the browser!
318
+ * Prefer using static get attributes() where camelCased names are converted to
319
+ * kebab-case automatically.
350
320
  * ```html
351
321
  * <custom-element myAttr="1">
352
322
  * <!-- is equal to -->
353
323
  * <custom-element myattr="1">
354
324
  * ```
355
- * @param {string} name custom element tag
356
- * @param {typeof MiElement} element
357
- * @param {object} [options]
325
+ * @param {string} tagName custom element tag
326
+ * @param {typeof MiElement} elementClass
327
+ * @param {{usedCssPrefix?: string, cssPrefix?: string, styles?: string}} [options]
358
328
  */
359
- export const define = (name, element, options) => {
360
- // @ts-expect-error
361
- element.observedAttributes = // @ts-expect-error
362
- (element.observedAttributes || Object.keys(element.attributes || [])).map(
363
- (attr) => attr.toLowerCase()
364
- )
365
- renderTemplate(element)
366
- window.customElements.define(name, element, options)
329
+ export const define = (tagName, elementClass, options) => {
330
+ if (customElements.get(tagName)) {
331
+ return
332
+ }
333
+ const { usedCssPrefix = '', cssPrefix = '', styles } = options || {}
334
+ if (elementClass.properties) {
335
+ // only lowercase attribute names are observed!
336
+ const observedAttrs = []
337
+ for (const [name, { attribute = true }] of Object.entries(
338
+ elementClass.properties
339
+ )) {
340
+ if (attribute) {
341
+ observedAttrs.push(camelToKebabCase(name))
342
+ }
343
+ }
344
+ Object.defineProperty(elementClass, 'observedAttributes', {
345
+ get() {
346
+ return observedAttrs
347
+ }
348
+ })
349
+ } else if (elementClass.observedAttributes) {
350
+ const properties = elementClass.observedAttributes.reduce((acc, attr) => {
351
+ const camelName = kebabToCamelCase(attr)
352
+ acc[camelName] = {}
353
+ return acc
354
+ }, {})
355
+ Object.defineProperty(elementClass, 'properties', {
356
+ get() {
357
+ return properties
358
+ }
359
+ })
360
+ }
361
+ if (elementClass.styles) {
362
+ elementClass.styles =
363
+ styles ||
364
+ (usedCssPrefix === cssPrefix
365
+ ? elementClass.styles
366
+ : elementClass.styles.replaceAll(`--${usedCssPrefix}-`, cssPrefix))
367
+ }
368
+ renderTemplate(elementClass)
369
+ window.customElements.define(tagName, elementClass)
367
370
  }
368
371
 
369
372
  // --- utils
@@ -381,35 +384,38 @@ const renderTemplate = (element) => {
381
384
  element.template = el
382
385
  }
383
386
 
384
- const initialValueType = (value) => {
385
- switch (value) {
386
- case Boolean:
387
- return { value: undefined, type: 'Boolean' }
388
- case Number:
389
- return { value: undefined, type: 'Number' }
390
- case String:
391
- return { value: undefined, type: 'String' }
392
- default:
393
- return { value, type: toString.call(value).slice(8, -1) }
394
- }
395
- }
396
-
397
387
  const toNumber = (any) => {
398
388
  const n = Number(any)
399
- return isNaN(n) ? any : n
389
+ return isNaN(n) ? 0 : n
400
390
  }
401
391
 
402
- export const convertType = (any, type) => {
403
- // setAttribute prevents passing Object or Array type. no further conversion required
404
- switch (type) {
405
- case 'Number':
406
- return toNumber(any)
407
- case 'Boolean':
408
- // boolean values are set via setAttribute as empty string
409
- if (any === 'false') {
410
- return false
411
- }
412
- return any === '' || !!any
392
+ const toJson = (any) => {
393
+ try {
394
+ return JSON.parse(any)
395
+ } catch {
396
+ return
397
+ }
398
+ }
399
+
400
+ /**
401
+ * convert a attribute string value to typed value
402
+ * @param {string} value
403
+ * @param {typeof Boolean|typeof Number|typeof String|typeof Array|typeof Object} type
404
+ * @returns {any}
405
+ */
406
+ export const convertType = (value, type) => {
407
+ if (type === Boolean) {
408
+ // false: removeAttribute -> null, true: setAttribute -> ''
409
+ return value !== null
410
+ }
411
+ if (type === Number) {
412
+ return toNumber(value)
413
+ }
414
+ if (type === Array) {
415
+ return toJson(value) ?? value.split(',').map((v) => v.trim())
416
+ }
417
+ if (type === Object) {
418
+ return toJson(value)
413
419
  }
414
- return any
420
+ return value
415
421
  }
package/src/escape.js CHANGED
@@ -11,9 +11,20 @@ export const unsafeHtml = (str) => new UnsafeHtml(str)
11
11
  const escMap = {
12
12
  '&': '&amp;',
13
13
  '<': '&lt;',
14
- '>': '&gt;',
15
- "'": '&#39;',
16
- '"': '&quot;'
14
+ '>': '&gt;'
15
+ }
16
+
17
+ let esc = (string) => {
18
+ return string.replace(/&amp;/g, '&').replace(/[&<>]/g, (tag) => escMap[tag])
19
+ }
20
+
21
+ if (typeof document !== 'undefined') {
22
+ // in browser environment, use DOM to escape
23
+ esc = (string) => {
24
+ const div = document.createElement('div')
25
+ div.textContent = string
26
+ return div.innerHTML
27
+ }
17
28
  }
18
29
 
19
30
  /**
@@ -26,13 +37,7 @@ const escMap = {
26
37
  */
27
38
  export const escHtml = (string) =>
28
39
  // @ts-expect-error
29
- string instanceof UnsafeHtml
30
- ? string
31
- : unsafeHtml(
32
- ('' + string)
33
- .replace(/&amp;/g, '&')
34
- .replace(/[&<>'"]/g, (tag) => escMap[tag])
35
- )
40
+ string instanceof UnsafeHtml ? string : unsafeHtml(esc('' + string))
36
41
 
37
42
  /**
38
43
  * template literal to HTML escape all values preventing XSS;
@@ -41,11 +46,10 @@ export const escHtml = (string) =>
41
46
  * @param {...any} values
42
47
  * @returns {string}
43
48
  * @example
44
- * const data = ['<foo', 'bar>']
45
- * const list = esc`<ul>${data.map(item => esc`<li>${item}</li>`)}</ul>`
49
+ * const list = html`<ul>${['<foo', 'bar>'].map(item => html`<li>${item}</li>`)}</ul>`
46
50
  * // '<ul><li>&lt;foo</li><li>bar&gt;</li></ul>'
47
51
  */
48
- export const esc = (strings, ...values) =>
52
+ export const html = (strings, ...values) =>
49
53
  unsafeHtml(
50
54
  String.raw(
51
55
  { raw: strings },
package/src/index.js CHANGED
@@ -10,21 +10,11 @@ export {
10
10
  * @typedef {import('./element.js').HostController} HostController
11
11
  */
12
12
  export { MiElement, convertType, define } from './element.js'
13
- export { unsafeHtml, esc, escHtml } from './escape.js'
14
- export { refsById, refsBySelector } from './refs.js'
15
- /**
16
- * @template T
17
- * @typedef {import('mi-signal').SignalOptions<T>} SignalOptions<T>
18
- */
19
- export {
20
- default as Signal,
21
- State,
22
- createSignal,
23
- effect,
24
- Computed
25
- } from 'mi-signal'
13
+ export { unsafeHtml, html, escHtml } from './escape.js'
14
+ export { refsBySelector } from './refs.js'
26
15
  /**
27
16
  * @typedef {import('./store.js').Action} Action
28
17
  */
29
18
  export { Store } from './store.js'
30
- export { classMap, styleMap, addGlobalStyles } from './styling.js'
19
+ export { classNames, styleMap, addGlobalStyles, css } from './styling.js'
20
+ export { default as Signal } from 'mi-signal'
package/src/refs.js CHANGED
@@ -1,27 +1,3 @@
1
- import { kebabToCamelCase } from './case.js'
2
-
3
- /**
4
- * Helper function to find `id` attributes in `container`s node tree.
5
- * id names are camelCased, e.g. 'list-container' becomes 'listContainer'
6
- * @param {Element} container root element
7
- * @returns {Record<string, Node>|{}} record of found references
8
- * @example
9
- * el.innerHTML = `<p id>unnamed <span id="named">and named</span> reference</p>`
10
- * references = refs(el)
11
- * //> references = { p: <p>, named: <span> }
12
- */
13
- export function refsById(container) {
14
- const nodes = container.querySelectorAll?.('[id]') || []
15
- const found = {}
16
- for (const node of nodes) {
17
- const name = kebabToCamelCase(
18
- node.getAttribute('id') || node.nodeName.toLowerCase()
19
- )
20
- found[name] = node
21
- }
22
- return found
23
- }
24
-
25
1
  /**
26
2
  * Helper function to gather references by a map of selectors
27
3
  * @param {Element} container root element
package/src/styling.js CHANGED
@@ -1,17 +1,27 @@
1
1
  import { camelToKebabCase } from './case.js'
2
2
 
3
3
  /**
4
- * Construct className based on true-ish values of map
5
- * @param {{[name: string]: string | boolean | number}} map
4
+ * conditionally joining classNames
5
+ * @param {...any} args
6
6
  * @returns {string}
7
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(' ')
8
+ export const classNames = (...args) => {
9
+ const classList = []
10
+ args.forEach((arg) => {
11
+ if (!arg) return
12
+ if (typeof arg === 'string') {
13
+ classList.push(arg)
14
+ } else if (Array.isArray(arg)) {
15
+ classList.push(classNames(...arg))
16
+ } else if (typeof arg === 'object') {
17
+ Object.entries(arg).forEach(([key, value]) => {
18
+ if (value) {
19
+ classList.push(key)
20
+ }
21
+ })
22
+ }
23
+ })
24
+ return classList.join(' ')
15
25
  }
16
26
 
17
27
  /**
@@ -22,6 +22,8 @@ export class ContextProvider<T> implements HostController {
22
22
  * @returns {T|null|undefined}
23
23
  */
24
24
  get(): T | null | undefined;
25
+ set value(newValue: T | null | undefined);
26
+ get value(): T | null | undefined;
25
27
  /**
26
28
  * @private
27
29
  * @param {ContextRequestEvent} ev