@webtides/element-js 1.0.0-alpha.1 → 1.0.0-alpha.2

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.
@@ -5,7 +5,44 @@ import { Store } from './util/Store.js';
5
5
  export { defineElement } from './util/defineElement.js';
6
6
  export { toString } from './util/toString.js';
7
7
 
8
+ /**
9
+ * Options object for the BaseElement
10
+ * @typedef {Object} BaseElementOptions
11
+ * @property {boolean} [autoUpdate] - When set to true the element will call the requestUpdate() method on the instance every time a property or attribute was changed. This will re-evaluate everything on the element and trigger a re-render (if a template is provided) and trigger the watchers for the affected properties/attributes. Default is `true`
12
+ * @property {boolean} [deferUpdate] - When set to true the element will not call the requestUpdate() method upon connecting and therefore will not render (if template was provided) initially. Default is `true`
13
+ * @property {MutationObserverOptions} [mutationObserverOptions]
14
+ * @property {PropertyOptions} [propertyOptions]
15
+ */
16
+
17
+ /**
18
+ * Options object for the BaseElement mutationObserverOptions option
19
+ * @typedef {Object} MutationObserverOptions
20
+ * @property {boolean} [childList] - When childList is set to true the element will also call the requestUpdate method for childList modifications (eg. Adding or removing child elements). Default is `true`
21
+ * @property {boolean} [subtree] - Please note that observing subtree mutations might have performance implications and use it only if necessary. Default is `false`
22
+ */
23
+
24
+ /**
25
+ * Options object for the BaseElement propertyOptions option
26
+ * @typedef {Object} PropertyOptions
27
+ * @property {undefined | boolean | function} [reflect] - When set to true the element will reflect property changes back to attributes if the attribute was not present when connecting the element. By default, all attributes that are present when connecting the element will be reflected anyway. Default is `undefined`
28
+ * @property {undefined | boolean | function} [parse] - When set to false the element will not automatically try to parse the attributes string value to a complex type (number, array, object). Default is `undefined`
29
+ * @property {undefined | boolean | function} [notify] - When set to true the element will dispatch CustomEvents for every property/attribute change. The event name will be the property name all lowercase and camel to dashes with a postfix of -changed. Default is `undefined`
30
+ */
31
+
32
+ /**
33
+ * Options object for updating properties
34
+ * @typedef {Object} PropertyUpdateOptions
35
+ * @property {boolean} [notify] - Whether the update should trigger the lifecycle methods. The default is `true`
36
+ * @property {string} property - The property name
37
+ * @property {any} newValue - The new value for the property
38
+ * @property {string} newValueString - The new value as `string` for the property (for comparison reasons)
39
+ * @property {any} oldValue - The old value for the property
40
+ */
41
+
8
42
  class BaseElement extends HTMLElement {
43
+ /**
44
+ * @param {BaseElementOptions} options
45
+ */
9
46
  constructor(options = {}) {
10
47
  super();
11
48
  this.$refs = {};
@@ -28,6 +65,11 @@ class BaseElement extends HTMLElement {
28
65
  };
29
66
  }
30
67
 
68
+ /**
69
+ * Overrides the native `connectedCallback` of the HTMLElement to set up and initialize our element.
70
+ * This will define the attributes and properties as reactive getters and setter,
71
+ * register observers, refs and events.
72
+ */
31
73
  connectedCallback() {
32
74
  // define all attributes to "this" as properties
33
75
  this.defineAttributesAsProperties();
@@ -54,6 +96,9 @@ class BaseElement extends HTMLElement {
54
96
  }
55
97
  }
56
98
 
99
+ /**
100
+ * Define a MutationObserver on the element to get notified on attribute changes and childList updates according to the options specified via the constructor options.
101
+ */
57
102
  defineObserver() {
58
103
  // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#Example_usage
59
104
  this._mutationObserver = new MutationObserver((mutations) => {
@@ -86,6 +131,9 @@ class BaseElement extends HTMLElement {
86
131
  });
87
132
  }
88
133
 
134
+ /**
135
+ * Native `disconnectedCallback` of the HTMLElement
136
+ */
89
137
  disconnectedCallback() {
90
138
  // remove events for elements in the component
91
139
  this.removeEvents();
@@ -107,6 +155,11 @@ class BaseElement extends HTMLElement {
107
155
  this.triggerHook('disconnected');
108
156
  }
109
157
 
158
+ /**
159
+ * Request and batch an asynchronous update cycle
160
+ * @param {PropertyUpdateOptions} options
161
+ * @returns {Promise<unknown>}
162
+ */
110
163
  requestUpdate(options = { notify: true }) {
111
164
  if (options.notify === true) {
112
165
  this.triggerHook('beforeUpdate');
@@ -136,6 +189,7 @@ class BaseElement extends HTMLElement {
136
189
  * This should be called by the template component AFTER adding the template to
137
190
  * the DOM.
138
191
  * Here we will register the events and the refs for the element.
192
+ * @param {PropertyUpdateOptions} options
139
193
  */
140
194
  update(options = { notify: true }) {
141
195
  this.registerEventsAndRefs();
@@ -202,6 +256,9 @@ class BaseElement extends HTMLElement {
202
256
  * Define a property on the element
203
257
  * Will trigger watch() function when a property was changed
204
258
  * Will trigger update() when a property was changed
259
+ * @param {string} property
260
+ * @param {any} value
261
+ * @param {boolean} reflectAttribute
205
262
  */
206
263
  defineProperty(property, value, reflectAttribute = false) {
207
264
  if (value instanceof Store) {
@@ -265,12 +322,10 @@ class BaseElement extends HTMLElement {
265
322
  }
266
323
 
267
324
  /**
268
- * call the watch callbacks on property changes
269
- *
270
- * @param context is the elements instance
271
- * @param property property that changes
272
- * @param newValue
273
- * @param oldValue
325
+ * Call the watch callbacks on property changes
326
+ * @param {string} property property that changes
327
+ * @param {any} newValue
328
+ * @param {any} oldValue
274
329
  */
275
330
  callPropertyWatcher(property, newValue, oldValue) {
276
331
  // notify watched properties (after update())
@@ -279,12 +334,11 @@ class BaseElement extends HTMLElement {
279
334
  watch[property](newValue, oldValue);
280
335
  }
281
336
  }
337
+
282
338
  /**
283
- * notify property observer via change event
284
- *
285
- * @param context is the elements instance
286
- * @param property property that changes
287
- * @param newValue
339
+ * Notify property observer via change event
340
+ * @param {string} property property that changes
341
+ * @param {any} newValue
288
342
  */
289
343
  notifyPropertyChange(property, newValue) {
290
344
  // dispatch change event
@@ -296,6 +350,10 @@ class BaseElement extends HTMLElement {
296
350
  }
297
351
  }
298
352
 
353
+ /**
354
+ * Reflects a property as attribute on the element
355
+ * @param {PropertyUpdateOptions} options
356
+ */
299
357
  reflectProperty(options) {
300
358
  const { property, newValue } = options;
301
359
  const newValueString = options.newValueString || JSON.stringify(newValue);
@@ -344,9 +402,8 @@ class BaseElement extends HTMLElement {
344
402
 
345
403
  /**
346
404
  * Helper Function to initialize the context requests
347
- *
348
- * @param propertyName
349
- * @param valueOrCallback
405
+ * @param {string} propertyName
406
+ * @param {any | function} valueOrCallback
350
407
  */
351
408
  requestContext(propertyName, valueOrCallback) {
352
409
  this.dispatch('request-context', { [propertyName]: valueOrCallback }, true);
@@ -356,8 +413,7 @@ class BaseElement extends HTMLElement {
356
413
  * Internal Context Request Handler
357
414
  * This will check if the current/receiving instance actually provides a value under the requested name.
358
415
  * If there is something to provide it will either assign the value to the requesting element or call the callback.
359
- *
360
- * @param event
416
+ * @param {CustomEvent} event
361
417
  */
362
418
  onRequestContext(event) {
363
419
  const properties = this.properties();
@@ -379,19 +435,30 @@ class BaseElement extends HTMLElement {
379
435
  });
380
436
  }
381
437
 
382
- // Connected lifecycle hook
438
+ /**
439
+ * Connected lifecycle hook
440
+ */
383
441
  connected() {}
384
442
 
385
- // BeforeUpdate lifecycle hook
443
+ /**
444
+ * BeforeUpdate lifecycle hook
445
+ */
386
446
  beforeUpdate() {}
387
447
 
388
- // AfterUpdate lifecycle hook
448
+ /**
449
+ * AfterUpdate lifecycle hook
450
+ */
389
451
  afterUpdate() {}
390
452
 
391
- // Disconnected lifecycle hook
453
+ /**
454
+ * Disconnected lifecycle hook
455
+ */
392
456
  disconnected() {}
393
457
 
394
- // Triggers a lifecycle hook based on the name
458
+ /**
459
+ * Triggers a lifecycle hook based on the name
460
+ * @param {string} name
461
+ */
395
462
  triggerHook(name) {
396
463
  if (name in this) {
397
464
  this[name]();
@@ -501,7 +568,14 @@ class BaseElement extends HTMLElement {
501
568
  this.$refs = refsMap;
502
569
  }
503
570
 
504
- // Helper function for dispatching custom events
571
+ /**
572
+ * Helper function for dispatching custom events
573
+ * @param {string} name
574
+ * @param {Object} data
575
+ * @param {boolean} bubble
576
+ * @param {boolean} cancelable
577
+ * @param {boolean} composed
578
+ */
505
579
  dispatch(name, data, bubble = false, cancelable = false, composed = false) {
506
580
  const event = new CustomEvent(name, {
507
581
  bubbles: bubble,
@@ -512,7 +586,10 @@ class BaseElement extends HTMLElement {
512
586
  this.dispatchEvent(event);
513
587
  }
514
588
 
515
- // Get the root element
589
+ /**
590
+ * Get the root element
591
+ * @returns {HTMLElement}
592
+ */
516
593
  getRoot() {
517
594
  return this;
518
595
  }
@@ -2,13 +2,22 @@ import { BaseElement } from './BaseElement.js';
2
2
  import { supportsAdoptingStyleSheets, getShadowParentOrBody } from './util/DOMHelper.js';
3
3
  export { i18n } from './util/i18n.js';
4
4
 
5
+ /**
6
+ * Options object for the StyledElement
7
+ * @typedef {Object} StyledElementOptions
8
+ * @extends BaseElementOptions
9
+ * @property {boolean} [shadowRender] - When set to true the element will render the template (if provided) in the Shadow DOM and therefore encapsulate the element and styles from the rest of the document. Default is `false`
10
+ * @property {[]} [styles] - Via the styles option you can add multiple styles/stylesheets to your element. Default is `[]`
11
+ * @property {boolean} [adoptGlobalStyles] - When set to true element-js will look for a style element in the global document with an id of "globalStyles" and apply it before any custom/element styles inside the shadow DOM. Default is `true`
12
+ */
13
+
5
14
  class StyledElement extends BaseElement {
6
15
  static globalStyles = null;
7
16
 
8
17
  static updateGlobalStyles() {
9
18
  // this is a runtime dependency so that every shadow dom can make use of global css
10
19
  // we assume these styles to be inlined into the document
11
- StyledElement.globalStyles = document.getElementById('globalStyles');
20
+ StyledElement.globalStyles = globalThis.document?.getElementById('globalStyles');
12
21
 
13
22
  if (StyledElement.globalStyles && StyledElement['globalStyleSheet']) {
14
23
  //updates already adopted global styles
@@ -16,6 +25,9 @@ class StyledElement extends BaseElement {
16
25
  }
17
26
  }
18
27
 
28
+ /**
29
+ * @param {StyledElementOptions} options
30
+ */
19
31
  constructor(options) {
20
32
  super({
21
33
  deferUpdate: false,
@@ -27,6 +39,9 @@ class StyledElement extends BaseElement {
27
39
  this._styles = [...this._options.styles, ...this.styles()];
28
40
  }
29
41
 
42
+ /**
43
+ * Overrides the `connectedCallback` to adopt optional styles when the element is connected
44
+ */
30
45
  connectedCallback() {
31
46
  super.connectedCallback();
32
47
 
@@ -36,10 +51,19 @@ class StyledElement extends BaseElement {
36
51
  }
37
52
  }
38
53
 
54
+ /**
55
+ * The styles method is another way to return a list of styles to be adopted when the element is connected.
56
+ * You can either provide a list in the constructor options or return it here.
57
+ * @returns {string[]}
58
+ */
39
59
  styles() {
40
60
  return [];
41
61
  }
42
62
 
63
+ /**
64
+ * Overrides the `update` method to adopt optional styles
65
+ * @param {PropertyUpdateOptions} options
66
+ */
43
67
  update(options) {
44
68
  if (!supportsAdoptingStyleSheets() || this._options.shadowRender === false) {
45
69
  // append stylesheets to template if not already adopted
@@ -52,6 +76,9 @@ class StyledElement extends BaseElement {
52
76
  super.update(options);
53
77
  }
54
78
 
79
+ /**
80
+ * Adopt stylesheets
81
+ */
55
82
  adoptStyleSheets() {
56
83
  if (!this.constructor['cssStyleSheets']) {
57
84
  this.constructor['cssStyleSheets'] = this._styles.map((style) => {
@@ -79,7 +106,10 @@ class StyledElement extends BaseElement {
79
106
  ];
80
107
  }
81
108
 
82
- // custom polyfill for constructable stylesheets by appending styles to the end of an element
109
+ /**
110
+ * Custom polyfill for constructable stylesheets by appending styles to the end of an element
111
+ * @param {string[]} styles
112
+ */
83
113
  appendStyleSheets(styles) {
84
114
  const parentDocument = getShadowParentOrBody(this.getRoot());
85
115
  styles.forEach((style, index) => {
@@ -87,7 +117,7 @@ class StyledElement extends BaseElement {
87
117
 
88
118
  // only append stylesheet if not already appended to shadowRoot or document
89
119
  if (!parentDocument.querySelector(`#${identifier}`)) {
90
- const styleElement = document.createElement('style');
120
+ const styleElement = globalThis.document?.createElement('style');
91
121
  styleElement.id = identifier;
92
122
  styleElement.style.display = 'none';
93
123
  styleElement.textContent = style;
@@ -3,7 +3,16 @@ import { html } from './dom-parts/html.js';
3
3
  import { render } from './dom-parts/render.js';
4
4
  export { i18n } from './util/i18n.js';
5
5
 
6
+ /**
7
+ * Options object for the TemplateElement
8
+ * @typedef {Object} TemplateElementOptions
9
+ * @extends StyledElementOptions
10
+ */
11
+
6
12
  class TemplateElement extends StyledElement {
13
+ /**
14
+ * @param {TemplateElementOptions} options
15
+ */
7
16
  constructor(options) {
8
17
  super({
9
18
  deferUpdate: false,
@@ -20,15 +29,26 @@ class TemplateElement extends StyledElement {
20
29
  if (this._options.shadowRender) this.attachShadow({ mode: 'open' });
21
30
  }
22
31
 
32
+ /**
33
+ * The template method should be overridden in extending elements and return the template to be rendered to the root
34
+ * @returns {TemplateResult}
35
+ */
23
36
  template() {
24
37
  return html``;
25
38
  }
26
39
 
40
+ /**
41
+ * Override update method to render the template to the root
42
+ * @param {PropertyUpdateOptions} options
43
+ */
27
44
  update(options) {
28
45
  this.renderTemplate();
29
46
  super.update(options);
30
47
  }
31
48
 
49
+ /**
50
+ * Render the template to the root
51
+ */
32
52
  renderTemplate() {
33
53
  const template = this._template || this.template();
34
54
  if (typeof template === 'string') {
@@ -39,6 +59,10 @@ class TemplateElement extends StyledElement {
39
59
  }
40
60
  }
41
61
 
62
+ /**
63
+ * Get the root element - either the element or the shadow root
64
+ * @returns {ShadowRoot | HTMLElement}
65
+ */
42
66
  getRoot() {
43
67
  return this.shadowRoot !== null ? this.shadowRoot : this;
44
68
  }
@@ -213,7 +213,8 @@ export class TemplateResult {
213
213
  // TODO: we currently cannot distinguish between NodeParts and AttributeParts before
214
214
  if (isObjectLike(value) && value.directiveClass) {
215
215
  // NodePart
216
- // result += encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value);
216
+ const directive = new value.directiveClass();
217
+ result += directive.stringify(...value.values);
217
218
  } else if (value != null) {
218
219
  result += encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value);
219
220
  }
@@ -1,6 +1,7 @@
1
1
  import { camelToDash, decodeAttribute, encodeAttribute } from '../util/AttributeParser.js';
2
- import { TemplateResult } from './TemplateResult';
3
- import { convertStringToHTML } from '../util/DOMHelper';
2
+ import { TemplateResult } from './TemplateResult.js';
3
+ import { convertStringToHTML, convertStringToTemplate } from '../util/DOMHelper.js';
4
+ import { defineDirective, Directive } from '../util/Directive.js';
4
5
 
5
6
  /**
6
7
  * Maps a list of classes to an element from an object.
@@ -55,30 +56,54 @@ const choose = (value, cases, defaultCase) => {
55
56
  /**
56
57
  * Renders a given string as HTML instead of text
57
58
  * @param {string} string
58
- * @returns {function(): Node}
59
+ * @returns {function(): Node | string}
59
60
  */
60
61
  const unsafeHTML = (string) => {
61
- // TODO: write tests for this...
62
- // I mean should the string always have a single root element? Or could it also handle multiple root elements?!
63
- return () => convertStringToHTML(string).firstChild;
62
+ if (!globalThis.DOMParser) return () => string;
63
+ const node = convertStringToHTML(string).firstChild;
64
+ const importedNode = globalThis.document?.importNode(node, true);
65
+ return () => importedNode;
64
66
  };
65
67
 
66
68
  /**
67
69
  * Renders multiple attributes as key="value" pairs from a map of attributes
68
- * @param {Object.<string, any>} attributes
69
- * @returns {function(): string}
70
70
  */
71
- const spreadAttributes = (attributes) => {
72
- return () => {
71
+ export class SpreadAttributesDirective extends Directive {
72
+ /**
73
+ * @param {Object.<string, any>} attributes
74
+ */
75
+ update(attributes) {
76
+ // TODO: what if the attribute was already there..?!
77
+ // TODO: what if the attribute was there from a previous render/update, but not anymore?!
78
+ for (const name of Object.keys(attributes)) {
79
+ const value = attributes[name];
80
+ this.node.setAttribute(
81
+ camelToDash(name),
82
+ encodeAttribute(typeof value !== 'string' ? JSON.stringify(value) : value),
83
+ );
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {Object.<string, any>} attributes
89
+ * @returns {string}
90
+ */
91
+ stringify(attributes) {
73
92
  return Object.keys(attributes)
74
- .map((key) => {
75
- let value = attributes[key];
76
- return `${camelToDash(key)}='${encodeAttribute(
93
+ .map((name) => {
94
+ let value = attributes[name];
95
+ return `${camelToDash(name)}='${encodeAttribute(
77
96
  typeof value !== 'string' ? JSON.stringify(value) : value,
78
97
  )}'`;
79
98
  })
80
99
  .join(' ');
81
- };
82
- };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Renders multiple attributes as key="value" pairs from a map of attributes
105
+ * @param {Object.<string, any>} attributes
106
+ */
107
+ const spreadAttributes = defineDirective(SpreadAttributesDirective);
83
108
 
84
109
  export { classMap, styleMap, when, choose, unsafeHTML, spreadAttributes };
@@ -1,23 +1,48 @@
1
+ /**
2
+ * Tests if a value is of type `object`
3
+ * @param {any} value
4
+ * @returns {boolean}
5
+ */
1
6
  export function isObjectLike(value) {
2
7
  return typeof value == 'object' && value !== null;
3
8
  }
4
9
 
5
- export function isJSON(str) {
10
+ /**
11
+ * Tests if a value can be parsed as JSON
12
+ * @param {any} value
13
+ * @returns {boolean}
14
+ */
15
+ export function isJSON(value) {
6
16
  try {
7
- return JSON.parse(str) && !!str;
17
+ return JSON.parse(value) && !!value;
8
18
  } catch (e) {
9
19
  return false;
10
20
  }
11
21
  }
12
22
 
23
+ /**
24
+ * Tests if a value is `boolean`
25
+ * @param {any} value
26
+ * @returns {boolean}
27
+ */
13
28
  export function isBoolean(value) {
14
29
  return value === 'true' || value === 'false';
15
30
  }
16
31
 
32
+ /**
33
+ * Parses a value to `boolean`
34
+ * @param {any} value
35
+ * @returns {boolean}
36
+ */
17
37
  export function parseBoolean(value) {
18
38
  return value === 'true';
19
39
  }
20
40
 
41
+ /**
42
+ * Tests if a value is of type `string`
43
+ * @param {any} value
44
+ * @returns {boolean}
45
+ */
21
46
  export function isString(value) {
22
47
  return (
23
48
  typeof value === 'string' ||
@@ -25,26 +50,40 @@ export function isString(value) {
25
50
  );
26
51
  }
27
52
 
53
+ /**
54
+ * Tests if a value is of type `number`
55
+ * @param {any} value
56
+ * @returns {boolean}
57
+ */
28
58
  export function isNumber(value) {
29
59
  return new RegExp('^-?(0|0\\.\\d+|[1-9]\\d*(\\.\\d+)?)$').test(value);
30
60
  }
31
61
 
62
+ /**
63
+ * Tests if a value is of type `NaN`
64
+ * @param {any} value
65
+ * @returns {boolean}
66
+ */
32
67
  export function isNaN(value) {
33
68
  return Number.isNaN(value);
34
69
  }
35
70
 
36
71
  /**
37
- * deep compare two values primitive or complex
38
- *
39
- * @param valueA
40
- * @param valueB
72
+ * Compare two values (deep). It compares primitive or complex values with JSON stringify.
73
+ * @param {any} valueA
74
+ * @param {any} valueB
41
75
  * @returns {boolean}
42
76
  */
43
-
44
77
  export function deepEquals(valueA, valueB) {
45
78
  return JSON.stringify(valueA) === JSON.stringify(valueB);
46
79
  }
47
80
 
81
+ /**
82
+ * Parses an attribute value that comes in as a `string` to its corresponding type
83
+ * @param {string} value
84
+ * @param {PropertyOptions} options
85
+ * @returns {string | number | boolean | object | array}
86
+ */
48
87
  export function parseAttribute(value, options = {}) {
49
88
  if (options.parse === false || !isString(value)) {
50
89
  // no-op
@@ -67,7 +106,8 @@ export function parseAttribute(value, options = {}) {
67
106
 
68
107
  /**
69
108
  * Replaces dashed-expression (i.e. some-value) to a camel-cased expression (i.e. someValue)
70
- * @returns string
109
+ * @param {string} string
110
+ * @returns {string}
71
111
  */
72
112
  export function dashToCamel(string) {
73
113
  if (string.indexOf('-') === -1) return string;
@@ -77,17 +117,16 @@ export function dashToCamel(string) {
77
117
 
78
118
  /**
79
119
  * Replaces camel-cased expression (i.e. someValue) to a dashed-expression (i.e. some-value)
80
- * @returns string
120
+ * @param {string} string
121
+ * @returns {string}
81
122
  */
82
123
  export function camelToDash(string) {
83
124
  return string.replace(/([A-Z])/g, '-$1').toLowerCase();
84
125
  }
85
126
 
86
127
  /**
87
- * Decodes an attribute. Can only be encoded with the `encodeAttribute` method which
88
- * is exported from this file.
89
- *
90
- * @param attribute
128
+ * Decodes an attribute.
129
+ * @param {string} attribute
91
130
  * @returns {string}
92
131
  */
93
132
  export function decodeAttribute(attribute) {
@@ -99,14 +138,8 @@ export function decodeAttribute(attribute) {
99
138
  }
100
139
 
101
140
  /**
102
- * Encodes an attribute. Can only be decoded with the `decodeAttribute` method which
103
- * is exported from this file.
104
- *
105
- * This does not use the standard way to encode an attribute, as the dom parser
106
- * that renders the html would understand and automatically decode it. Which results
107
- * in non sanitized html output.
108
- *
109
- * @param attribute
141
+ * Encodes an attribute.
142
+ * @param {string} attribute
110
143
  * @returns {string}
111
144
  */
112
145
  export function encodeAttribute(attribute) {