@webtides/element-js 1.0.0-alpha.9 → 1.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +369 -0
  2. package/README.md +3 -3
  3. package/docs/README.md +70 -89
  4. package/docs/concepts/classes.md +37 -66
  5. package/docs/concepts/hierarchy.md +29 -31
  6. package/docs/concepts/lifecycle.md +9 -23
  7. package/docs/concepts/reactivity.md +1 -3
  8. package/docs/features/computed-properties.md +13 -16
  9. package/docs/features/dependency-injection-context-protocol.md +75 -87
  10. package/docs/features/global-styles.md +1 -1
  11. package/docs/features/methods.md +14 -15
  12. package/docs/features/properties.md +21 -33
  13. package/docs/features/refs.md +23 -31
  14. package/docs/features/renderless.md +31 -37
  15. package/docs/features/store.md +63 -78
  16. package/docs/features/templates.md +4 -1
  17. package/docs/features/watch.md +28 -35
  18. package/docs/getting-started/upgrade-to-v1.md +16 -0
  19. package/docs/guide/guide.md +10 -22
  20. package/index.js +29 -2
  21. package/package.json +62 -62
  22. package/src/BaseElement.js +637 -632
  23. package/src/StyledElement.js +104 -104
  24. package/src/TemplateElement.js +53 -58
  25. package/src/dom-parts/AttributePart.js +116 -116
  26. package/src/dom-parts/ChildNodePart.js +288 -288
  27. package/src/dom-parts/NodePart.js +27 -27
  28. package/src/dom-parts/Part.js +6 -6
  29. package/src/dom-parts/TemplatePart.js +119 -119
  30. package/src/dom-parts/TemplateResult.js +274 -267
  31. package/src/dom-parts/directives.js +105 -48
  32. package/src/dom-parts/html.js +2 -2
  33. package/src/dom-parts/render.js +6 -1
  34. package/src/util/AttributeParser.js +47 -47
  35. package/src/util/DOMHelper.js +190 -191
  36. package/src/util/Directive.js +31 -24
  37. package/src/util/GlobalStylesStore.js +56 -49
  38. package/src/util/SerializeStateHelper.js +56 -56
  39. package/src/util/Store.js +174 -174
  40. package/src/util/defineElement.js +5 -5
  41. package/src/util/toString.js +5 -5
@@ -24,7 +24,12 @@ export { toString } from './util/toString.js';
24
24
 
25
25
  /**
26
26
  * Options object for the BaseElement propertyOptions option
27
- * @typedef {Object} PropertyOptions
27
+ * @typedef {Object<string, PropertyOption>} PropertyOptions
28
+ */
29
+
30
+ /**
31
+ * Option object for a property of the BaseElement propertyOptions option
32
+ * @typedef {Object} PropertyOption
28
33
  * @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`
29
34
  * @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`
30
35
  * @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`
@@ -44,637 +49,637 @@ export { toString } from './util/toString.js';
44
49
  * @implements {Serializable}
45
50
  */
46
51
  class BaseElement extends HTMLElement {
47
- _serializationKey;
48
-
49
- /**
50
- * @param {BaseElementOptions} options
51
- */
52
- constructor(options = {}) {
53
- super();
54
- this.$refs = {};
55
- this._state = {};
56
- this._mutationObserver = null;
57
- this._registeredEvents = [];
58
- this._batchUpdate = null;
59
- this._requestedUpdates = [];
60
- this._options = {
61
- autoUpdate: true,
62
- deferUpdate: true,
63
- deferConnected: false,
64
- mutationObserverOptions: {
65
- attributes: true,
66
- childList: true,
67
- subtree: false,
68
- ...options.mutationObserverOptions,
69
- },
70
- propertyOptions: {},
71
- ...options,
72
- };
73
- }
74
-
75
- /**
76
- * Overrides the native `connectedCallback` of the HTMLElement to set up and initialize our element.
77
- * This will define the attributes and properties as reactive getters and setter,
78
- * register observers, refs and events.
79
- */
80
- connectedCallback() {
81
- if (this.hasAttribute('defer-update')) {
82
- this._options.deferUpdate = true;
83
- }
84
-
85
- if (this.hasAttribute('defer-connected') || this._options.deferConnected) {
86
- this._options.deferConnected = false;
87
- this.removeAttribute('defer-connected');
88
- return;
89
- }
90
-
91
- if (this.hasAttribute('ejs:key')) {
92
- this._serializationKey = this.getAttribute('ejs:key');
93
- deserializeState(this);
94
- } else {
95
- this._serializationKey = globalThis.crypto.randomUUID();
96
- if (globalThis.elementJsConfig?.serializeState) {
97
- this.setAttribute('ejs:key', this._serializationKey);
98
- }
99
- }
100
-
101
- // define all attributes to "this" as properties
102
- this.defineAttributesAsProperties();
103
-
104
- // define all properties to "this"
105
- this.defineProperties();
106
-
107
- // define context
108
- this.definePropertyInjection();
109
-
110
- // define everything that should be observed
111
- this.defineObserver();
112
-
113
- if (this._options.deferUpdate) {
114
- // don't update/render, but register refs and events
115
- this.registerEventsAndRefs();
116
-
117
- this.triggerHook('connected');
118
- } else {
119
- this.requestUpdate({ notify: false }).then(() => {
120
- this.triggerHook('connected');
121
- this.triggerHook('afterUpdate');
122
- });
123
- }
124
- }
125
-
126
- /**
127
- * Define a MutationObserver on the element to get notified on attribute changes and childList updates according to the options specified via the constructor options.
128
- */
129
- defineObserver() {
130
- // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#Example_usage
131
- this._mutationObserver = new MutationObserver((mutations) => {
132
- mutations.forEach((mutation) => {
133
- if (
134
- mutation.type === 'attributes' &&
135
- mutation.target === this &&
136
- this._state.hasOwnProperty(dashToCamel(mutation.attributeName))
137
- ) {
138
- // update property by invoking the setter
139
- this[dashToCamel(mutation.attributeName)] = parseAttribute(
140
- this.getAttribute(mutation.attributeName),
141
- this._options.propertyOptions[dashToCamel(mutation.attributeName)],
142
- );
143
- }
144
- if (mutation.type === 'attributes' && mutation.target !== this) {
145
- this.requestUpdate();
146
- }
147
- if (mutation.type === 'childList') {
148
- this.requestUpdate();
149
- }
150
- });
151
- });
152
-
153
- this._mutationObserver.observe(this, {
154
- attributes: true,
155
- childList: true,
156
- subtree: false,
157
- ...this._options.mutationObserverOptions,
158
- });
159
- }
160
-
161
- /**
162
- * Native `disconnectedCallback` of the HTMLElement
163
- */
164
- disconnectedCallback() {
165
- // remove events for elements in the component
166
- this.removeEvents();
167
-
168
- if (Object.keys(this.provideProperties()).length > 0) {
169
- this.removeEventListener('request-context', this.onRequestContext);
170
- }
171
-
172
- // remove observers
173
- if (this._mutationObserver) this._mutationObserver.disconnect();
174
-
175
- // unsubscribe from stores
176
- Object.values(this.properties()).forEach((property) => {
177
- if (property instanceof Store) {
178
- property.unsubscribe(this);
179
- }
180
- });
181
-
182
- this.triggerHook('disconnected');
183
- }
184
-
185
- /**
186
- * Request and batch an asynchronous update cycle
187
- * @param {PropertyUpdateOptions} options
188
- * @returns {Promise<unknown>}
189
- */
190
- requestUpdate(options = { notify: true }) {
191
- if (options.notify === true) {
192
- this.triggerHook('beforeUpdate');
193
- }
194
-
195
- if (this._batchUpdate) {
196
- cancelAnimationFrame(this._batchUpdate);
197
- this._batchUpdate = false;
198
- }
199
-
200
- return new Promise((resolve, reject) => {
201
- this._requestedUpdates.push({ resolve, reject });
202
- this._batchUpdate = requestAnimationFrame(() => {
203
- try {
204
- this.update(options);
205
- this._requestedUpdates.forEach(({ resolve, reject }) => resolve());
206
- } catch (e) {
207
- // console.error('Update error', e)
208
- this._requestedUpdates.forEach(({ resolve, reject }) => reject(e));
209
- }
210
- this._requestedUpdates = [];
211
- });
212
- });
213
- }
214
-
215
- /**
216
- * This should be called by the template component AFTER adding the template to
217
- * the DOM.
218
- * Here we will register the events and the refs for the element.
219
- * @param {PropertyUpdateOptions} options
220
- */
221
- update(options = { notify: true }) {
222
- this.registerEventsAndRefs();
223
- if (options.notify === true) {
224
- this.triggerHook('afterUpdate');
225
- }
226
- }
227
-
228
- /**
229
- * Register events and refs for the component
230
- */
231
- registerEventsAndRefs() {
232
- // register events for elements in the component
233
- this.registerEvents();
234
-
235
- // register $refs
236
- this.registerRefs();
237
- }
238
-
239
- /**
240
- * Defines all attributes assigned in the HTML for the element as properties on the element
241
- * If the value for an attribute can be parsed to an object or array, it will do so
242
- */
243
- defineAttributesAsProperties() {
244
- const ignoreAttributes = ['class', 'style'];
245
- Array.from(this.attributes)
246
- .filter((attribute) => {
247
- return !ignoreAttributes.includes(attribute.name);
248
- })
249
- .forEach((attribute) => {
250
- const name = dashToCamel(attribute.name);
251
- const parseOptions = this._options.propertyOptions[name];
252
- this.defineProperty(name, parseAttribute(attribute.value, parseOptions), true);
253
- });
254
- }
255
-
256
- /**
257
- * Properties to be defined and assigned on the element. Should be overwritten in child components
258
- * Properties defined here will also trigger a update() when changed.
259
- * eg. { stringProperty: 'value', numberProperty: 13, booleanProperty: true } }
260
- * @return {{}}
261
- */
262
- properties() {
263
- return {};
264
- }
265
-
266
- /**
267
- * Defines properties on the element based on keys from this.properties()
268
- * Will trigger update() when a property was changed
269
- */
270
- defineProperties() {
271
- Object.keys({ ...this.injectProperties(), ...this.properties() }).forEach((prop) => {
272
- // when mixing shadow dom elements with light dom elements and nesting custom elements
273
- // it might occur that properties where set on an element before it was
274
- // registered or connected. To avoid such timing issues we check
275
- // if a value was set for that specific property on the
276
- // prototype before assigning a default value
277
- const value = this[prop] || this.properties()[prop];
278
- this.defineProperty(prop, value);
279
- });
280
- }
281
-
282
- /**
283
- * Define a property on the element
284
- * Will trigger watch() function when a property was changed
285
- * Will trigger update() when a property was changed
286
- * @param {string} property
287
- * @param {any} value
288
- * @param {boolean} reflectAttribute
289
- */
290
- defineProperty(property, value, reflectAttribute = false) {
291
- if (this._state.hasOwnProperty(property)) {
292
- // property has already been defined as an attribute nothing to do here
293
- return;
294
- }
295
-
296
- // if property did not come from an attribute but has the option to reflect // enabled or custom fn
297
- if (!reflectAttribute && this._options.propertyOptions[property]?.reflect) {
298
- this.reflectProperty({ property: property, newValue: value });
299
- }
300
-
301
- // remove attribute if reflect is set to false explicitly in options
302
- if (this._options.propertyOptions[property]?.reflect === false) {
303
- this.removeAttribute(camelToDash(property));
304
- }
305
-
306
- if (hasSerializedState(this._serializationKey) && Object.keys(this.serializeState())?.includes(property)) {
307
- value = this[property];
308
- }
309
-
310
- this._state[property] = value;
311
-
312
- if (this._state[property] instanceof Store) {
313
- this._state[property].subscribe(this);
314
- }
315
-
316
- Object.defineProperty(this, property, {
317
- get: () => {
318
- return this._state[property];
319
- },
320
- set: (newValue) => {
321
- const oldValue = this._state[property];
322
- const newValueString = JSON.stringify(newValue);
323
- if (JSON.stringify(oldValue) !== newValueString) {
324
- this._state[property] = newValue;
325
-
326
- serializeState(this);
327
-
328
- if (newValue instanceof Store) {
329
- newValue.subscribe(this);
330
- }
331
-
332
- if (reflectAttribute || this._options.propertyOptions[property]?.reflect) {
333
- this.reflectProperty({ property, newValue, newValueString });
334
- }
335
-
336
- if (this._options.autoUpdate) {
337
- this.requestUpdate({
338
- notify: true,
339
- property: property,
340
- newValue: newValue,
341
- newValueString: newValueString,
342
- oldValue: oldValue,
343
- }).finally(() => {
344
- this.callPropertyWatcher(property, newValue, oldValue);
345
- this.notifyPropertyChange(property, newValue);
346
- });
347
- } else {
348
- this.callPropertyWatcher(property, newValue, oldValue);
349
- this.notifyPropertyChange(property, newValue);
350
- }
351
- }
352
- return this;
353
- },
354
- });
355
- }
356
-
357
- /**
358
- * Call the watch callbacks on property changes
359
- * @param {string} property property that changes
360
- * @param {any} newValue
361
- * @param {any} oldValue
362
- */
363
- callPropertyWatcher(property, newValue, oldValue) {
364
- // notify watched properties (after update())
365
- const watch = this.watch();
366
- if (property in watch) {
367
- watch[property](newValue, oldValue);
368
- }
369
- }
370
-
371
- /**
372
- * Notify property observer via change event
373
- * @param {string} property property that changes
374
- * @param {any} newValue
375
- */
376
- notifyPropertyChange(property, newValue) {
377
- // dispatch change event
378
- if (
379
- property in this._options['propertyOptions'] &&
380
- this._options['propertyOptions'][property]['notify'] === true
381
- ) {
382
- this.dispatch(`${camelToDash(property)}-changed`, newValue);
383
- }
384
- }
385
-
386
- /**
387
- * Reflects a property as attribute on the element
388
- * @param {PropertyUpdateOptions} options
389
- */
390
- reflectProperty(options) {
391
- const { property, newValue } = options;
392
- const newValueString = options.newValueString || JSON.stringify(newValue);
393
-
394
- if (newValue === undefined || newValue === null || isNaN(newValue)) {
395
- // these would be reflected as strings: "undefined" || "null" || "NaN"
396
- // which is not the desired behaviour. Therefore we reflect them as empty strings
397
- this.setAttribute(camelToDash(property), '');
398
- } else {
399
- let attributeValue = isObjectLike(newValue) ? newValueString : newValue;
400
- if (typeof this._options.propertyOptions[property]?.reflect === 'function') {
401
- attributeValue = this._options.propertyOptions[property].reflect(newValue);
402
- }
403
- this.setAttribute(camelToDash(property), attributeValue);
404
- }
405
- }
406
-
407
- /**
408
- * Context Properties to issue Context Requests and to pull contextual properties into the elements scope
409
- * @return {{}}
410
- */
411
- injectProperties() {
412
- return {};
413
- }
414
-
415
- /**
416
- * Define which properties that can be provided to children
417
- * @return {{}}
418
- */
419
- provideProperties() {
420
- return {};
421
- }
422
-
423
- /**
424
- * Defines context on the element based on keys from this.injectProperties()
425
- */
426
- definePropertyInjection() {
427
- Object.entries(this.injectProperties()).forEach(([key, value]) => {
428
- this.requestContext(key, value);
429
- });
430
- // define context provider
431
- const providedKeys = Object.keys(this.provideProperties());
432
- if (providedKeys.length > 0) {
433
- this.addEventListener('request-context', this.onRequestContext);
434
- // check if there are already connected elements in child dom and restart requests
435
- getAllElementChildren(this.getRoot(), BaseElement).forEach((customChild) => {
436
- // if injectProperties?.() is defined it means that the child got connected BEFORE the parent (Runtime Issue)
437
- const requestedProperties = customChild.injectProperties?.() ?? {};
438
- Object.entries(requestedProperties).forEach(([key, value]) => {
439
- // iterate over requested properties to find missed context requests
440
- if (providedKeys.includes(key)) {
441
- // owns the provided key -> replay request
442
- customChild.requestContext(key, value);
443
- }
444
- });
445
- });
446
- }
447
- }
448
-
449
- /**
450
- * Helper Function to initialize the context requests
451
- * @param {string} propertyName
452
- * @param {any | function} valueOrCallback
453
- */
454
- requestContext(propertyName, valueOrCallback) {
455
- this.dispatch('request-context', { [propertyName]: valueOrCallback });
456
- }
457
-
458
- /**
459
- * Internal Context Request Handler
460
- * This will check if the current/receiving instance actually provides a value under the requested name.
461
- * If there is something to provide it will either assign the value to the requesting element or call the callback.
462
- * @param {CustomEvent} event
463
- */
464
- onRequestContext(event) {
465
- const provideProperties = this.provideProperties();
466
-
467
- Object.entries(event.detail ?? {}).forEach(([key, valueOrCallback]) => {
468
- if (key in provideProperties) {
469
- // found it, provide it
470
- const providedValue = provideProperties[key];
471
- event.stopPropagation();
472
- if (typeof valueOrCallback === 'function') {
473
- // call function with context value
474
- valueOrCallback(providedValue);
475
- } else {
476
- // assign to prop
477
- const eventPath = event.composedPath();
478
- const target = eventPath[0] || event.target;
479
- target[key] = providedValue;
480
- }
481
- }
482
- });
483
- }
484
-
485
- /**
486
- * Connected lifecycle hook
487
- */
488
- connected() {}
489
-
490
- /**
491
- * BeforeUpdate lifecycle hook
492
- */
493
- beforeUpdate() {}
494
-
495
- /**
496
- * AfterUpdate lifecycle hook
497
- */
498
- afterUpdate() {}
499
-
500
- /**
501
- * Disconnected lifecycle hook
502
- */
503
- disconnected() {}
504
-
505
- /**
506
- * Triggers a lifecycle hook based on the name
507
- * @param {string} name
508
- */
509
- triggerHook(name) {
510
- if (name in this) {
511
- this[name]();
512
- }
513
- }
514
-
515
- /**
516
- * Watched attributes & properties to be notified to the element when changed. Should be overwritten in child components
517
- * When an attribute or property as the key was changed on the element, the callback function defined as value
518
- * will be called with `newValue` and `oldValue`
519
- * eg. { property: (oldValue, newValue) => { console.log('property changed!, oldValue, newValue); } }
520
- * @return {{}}
521
- */
522
- watch() {
523
- return {};
524
- }
525
-
526
- /**
527
- * Return a map of events that should be registered on the element.
528
- * eg. { '.link': {
529
- * 'click': (e) => {console.log('MyElement.link - clicked', e)},
530
- * 'mouseover': (e) => {console.log('MyElement.link - mouseover()', e)},
531
- * }
532
- * }
533
- */
534
- events() {
535
- return {};
536
- }
537
-
538
- /**
539
- * Registers events on the element based on events().
540
- * Removes all previously registered events before adding them.
541
- */
542
- registerEvents() {
543
- // remove all previously registered events to prevent duplicate triggers
544
- this.removeEvents();
545
-
546
- // register events
547
- const eventDefinitions = this.events();
548
- Object.keys(eventDefinitions).forEach((elementSelector) => {
549
- const events = eventDefinitions[elementSelector];
550
- const selectorWhiteList = { window, document, this: this.getRoot() };
551
- const whiteListElement = selectorWhiteList[elementSelector];
552
- const eventTargets = whiteListElement
553
- ? [whiteListElement]
554
- : this.getRoot().querySelectorAll(elementSelector);
555
-
556
- eventTargets.forEach((eventTarget) => {
557
- Object.keys(events).forEach((eventName) => {
558
- const notation = events[eventName];
559
- if (typeof notation === 'function') {
560
- const callback = notation.bind(this);
561
- eventTarget.addEventListener(eventName, callback);
562
- this._registeredEvents.push({ eventTarget, eventName, callback });
563
- } else if (typeof notation === 'object' && typeof notation.listener === 'function') {
564
- const callback = notation.listener.bind(this);
565
- eventTarget.addEventListener(eventName, callback, notation.options ?? {});
566
- this._registeredEvents.push({ eventTarget, eventName, callback });
567
- }
568
- });
569
- });
570
- });
571
- }
572
-
573
- /**
574
- * Removes all events from the element that where previously registered from the events() map.
575
- */
576
- removeEvents() {
577
- this._registeredEvents.forEach(({ eventTarget, eventName, callback }) => {
578
- if (eventTarget === window || eventTarget === document || this.getRoot().contains(eventTarget)) {
579
- eventTarget.removeEventListener(eventName, callback);
580
- }
581
- });
582
- this._registeredEvents = [];
583
- }
584
-
585
- /**
586
- * Stores an object that references child elements & DOM nodes that have a ref attribute defined.
587
- * References will be accessible on the element under this.$refs
588
- */
589
- registerRefs() {
590
- const refsNodeList = this.getRoot().querySelectorAll('[ref]');
591
- const refsArray = Array.from(refsNodeList);
592
- const refsMap = {};
593
-
594
- refsArray.forEach((refNode) => {
595
- const refKey = refNode.getAttribute('ref');
596
- const isListKey = refKey.endsWith('[]');
597
- const cleanKey = isListKey ? refKey.slice(0, -2) : refKey;
598
- const hasListRef = Array.isArray(refsMap[cleanKey]);
599
-
600
- if (isListKey) {
601
- if (!refsMap[cleanKey] || !hasListRef) {
602
- // create or overwrite previously registered single ref
603
- refsMap[cleanKey] = [];
604
- }
605
- refsMap[cleanKey].push(refNode);
606
- } else if (!hasListRef) {
607
- refsMap[refKey] = refNode;
608
- } else {
609
- console.warn(
610
- `Did not register singular ref ${refKey}, ${refNode} as a ref list is already registered under the same name.`,
611
- );
612
- }
613
- });
614
-
615
- this.$refs = refsMap;
616
- }
617
-
618
- /**
619
- * Helper function for dispatching custom events
620
- * @param {string} name
621
- * @param {Object} [data]
622
- * @param {CustomEventInit} [options]
623
- * @param {boolean} [cancelable] - DEPRECATED
624
- * @param {boolean} [composed] - DEPRECATED
625
- */
626
- dispatch(name, data, options, cancelable = false, composed = false) {
627
- let eventInit = {};
628
-
629
- if (options !== undefined && typeof options === 'boolean') {
630
- eventInit['bubbles'] = options;
631
- eventInit['cancelable'] = cancelable;
632
- eventInit['composed'] = composed;
633
- eventInit['detail'] = data;
634
- console.warn(
635
- `[${this.localName}] Using the old parameter options for the event is deprecated and will be removed in v2.0! Please use the new options object that is in line with the "CustomEventInit" options`,
636
- );
637
- } else {
638
- eventInit = {
639
- bubbles: true,
640
- cancelable: true,
641
- composed: true,
642
- detail: data,
643
- ...options,
644
- };
645
- }
646
-
647
- const event = new CustomEvent(name, eventInit);
648
- this.dispatchEvent(event);
649
- }
650
-
651
- /**
652
- * Get the root element
653
- * @returns {HTMLElement}
654
- */
655
- getRoot() {
656
- return this;
657
- }
658
-
659
- /**
660
- * This method will be used to serialize this elements state.
661
- * By default, it will generate an object with all properties from this.properties().
662
- * You can override this and return a different object with more/less/different properties.
663
- * @return {{[string: any]: *}}
664
- */
665
- serializeState() {
666
- const keys = Object.keys(this.properties());
667
- return Object.fromEntries(keys.map((key) => [key, this[key]]));
668
- }
669
-
670
- /**
671
- * This method will be used to restore properties on the element from a previously serialized state.
672
- * You can override this and handle the deserialization differently if the default is not enough.
673
- * @param {{[string: any]: *}} serializedState
674
- */
675
- restoreState(serializedState) {
676
- Object.assign(this, serializedState);
677
- }
52
+ _serializationKey;
53
+
54
+ /**
55
+ * @param {BaseElementOptions} options
56
+ */
57
+ constructor(options = {}) {
58
+ super();
59
+ this.$refs = {};
60
+ this._state = {};
61
+ this._mutationObserver = null;
62
+ this._registeredEvents = [];
63
+ this._batchUpdate = null;
64
+ this._requestedUpdates = [];
65
+ this._options = {
66
+ autoUpdate: true,
67
+ deferUpdate: true,
68
+ deferConnected: false,
69
+ mutationObserverOptions: {
70
+ attributes: true,
71
+ childList: true,
72
+ subtree: false,
73
+ ...options.mutationObserverOptions,
74
+ },
75
+ propertyOptions: {},
76
+ ...options,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Overrides the native `connectedCallback` of the HTMLElement to set up and initialize our element.
82
+ * This will define the attributes and properties as reactive getters and setter,
83
+ * register observers, refs and events.
84
+ */
85
+ connectedCallback() {
86
+ if (this.hasAttribute('defer-update')) {
87
+ this._options.deferUpdate = true;
88
+ }
89
+
90
+ if (this.hasAttribute('defer-connected') || this._options.deferConnected) {
91
+ this._options.deferConnected = false;
92
+ this.removeAttribute('defer-connected');
93
+ return;
94
+ }
95
+
96
+ if (this.hasAttribute('ejs:key')) {
97
+ this._serializationKey = this.getAttribute('ejs:key');
98
+ deserializeState(this);
99
+ } else {
100
+ this._serializationKey = globalThis.crypto.randomUUID();
101
+ if (globalThis.elementJsConfig?.serializeState) {
102
+ this.setAttribute('ejs:key', this._serializationKey);
103
+ }
104
+ }
105
+
106
+ // define all attributes to "this" as properties
107
+ this.defineAttributesAsProperties();
108
+
109
+ // define all properties to "this"
110
+ this.defineProperties();
111
+
112
+ // define context
113
+ this.definePropertyInjection();
114
+
115
+ // define everything that should be observed
116
+ this.defineObserver();
117
+
118
+ if (this._options.deferUpdate) {
119
+ // don't update/render, but register refs and events
120
+ this.registerEventsAndRefs();
121
+
122
+ this.triggerHook('connected');
123
+ } else {
124
+ this.requestUpdate({ notify: false }).then(() => {
125
+ this.triggerHook('connected');
126
+ this.triggerHook('afterUpdate');
127
+ });
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Define a MutationObserver on the element to get notified on attribute changes and childList updates according to the options specified via the constructor options.
133
+ */
134
+ defineObserver() {
135
+ // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#Example_usage
136
+ this._mutationObserver = new MutationObserver((mutations) => {
137
+ mutations.forEach((mutation) => {
138
+ if (
139
+ mutation.type === 'attributes' &&
140
+ mutation.target === this &&
141
+ this._state.hasOwnProperty(dashToCamel(mutation.attributeName))
142
+ ) {
143
+ // update property by invoking the setter
144
+ this[dashToCamel(mutation.attributeName)] = parseAttribute(
145
+ this.getAttribute(mutation.attributeName),
146
+ this._options.propertyOptions[dashToCamel(mutation.attributeName)],
147
+ );
148
+ }
149
+ if (mutation.type === 'attributes' && mutation.target !== this) {
150
+ this.requestUpdate();
151
+ }
152
+ if (mutation.type === 'childList') {
153
+ this.requestUpdate();
154
+ }
155
+ });
156
+ });
157
+
158
+ this._mutationObserver.observe(this, {
159
+ attributes: true,
160
+ childList: true,
161
+ subtree: false,
162
+ ...this._options.mutationObserverOptions,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Native `disconnectedCallback` of the HTMLElement
168
+ */
169
+ disconnectedCallback() {
170
+ // remove events for elements in the component
171
+ this.removeEvents();
172
+
173
+ if (Object.keys(this.provideProperties()).length > 0) {
174
+ this.removeEventListener('request-context', this.onRequestContext);
175
+ }
176
+
177
+ // remove observers
178
+ if (this._mutationObserver) this._mutationObserver.disconnect();
179
+
180
+ // unsubscribe from stores
181
+ Object.values(this.properties()).forEach((property) => {
182
+ if (property instanceof Store) {
183
+ property.unsubscribe(this);
184
+ }
185
+ });
186
+
187
+ this.triggerHook('disconnected');
188
+ }
189
+
190
+ /**
191
+ * Request and batch an asynchronous update cycle
192
+ * @param {PropertyUpdateOptions} options
193
+ * @returns {Promise<unknown>}
194
+ */
195
+ requestUpdate(options = { notify: true }) {
196
+ if (options.notify === true) {
197
+ this.triggerHook('beforeUpdate');
198
+ }
199
+
200
+ if (this._batchUpdate) {
201
+ cancelAnimationFrame(this._batchUpdate);
202
+ this._batchUpdate = false;
203
+ }
204
+
205
+ return new Promise((resolve, reject) => {
206
+ this._requestedUpdates.push({ resolve, reject });
207
+ this._batchUpdate = requestAnimationFrame(() => {
208
+ try {
209
+ this.update(options);
210
+ this._requestedUpdates.forEach(({ resolve, reject }) => resolve());
211
+ } catch (e) {
212
+ // console.error('Update error', e)
213
+ this._requestedUpdates.forEach(({ resolve, reject }) => reject(e));
214
+ }
215
+ this._requestedUpdates = [];
216
+ });
217
+ });
218
+ }
219
+
220
+ /**
221
+ * This should be called by the template component AFTER adding the template to
222
+ * the DOM.
223
+ * Here we will register the events and the refs for the element.
224
+ * @param {PropertyUpdateOptions} options
225
+ */
226
+ update(options = { notify: true }) {
227
+ this.registerEventsAndRefs();
228
+ if (options.notify === true) {
229
+ this.triggerHook('afterUpdate');
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Register events and refs for the component
235
+ */
236
+ registerEventsAndRefs() {
237
+ // register events for elements in the component
238
+ this.registerEvents();
239
+
240
+ // register $refs
241
+ this.registerRefs();
242
+ }
243
+
244
+ /**
245
+ * Defines all attributes assigned in the HTML for the element as properties on the element
246
+ * If the value for an attribute can be parsed to an object or array, it will do so
247
+ */
248
+ defineAttributesAsProperties() {
249
+ const ignoreAttributes = ['class', 'style'];
250
+ Array.from(this.attributes)
251
+ .filter((attribute) => {
252
+ return !ignoreAttributes.includes(attribute.name);
253
+ })
254
+ .forEach((attribute) => {
255
+ const name = dashToCamel(attribute.name);
256
+ const parseOptions = this._options.propertyOptions[name];
257
+ this.defineProperty(name, parseAttribute(attribute.value, parseOptions), true);
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Properties to be defined and assigned on the element. Should be overwritten in child components
263
+ * Properties defined here will also trigger a update() when changed.
264
+ * eg. { stringProperty: 'value', numberProperty: 13, booleanProperty: true } }
265
+ * @return {{}}
266
+ */
267
+ properties() {
268
+ return {};
269
+ }
270
+
271
+ /**
272
+ * Defines properties on the element based on keys from this.properties()
273
+ * Will trigger update() when a property was changed
274
+ */
275
+ defineProperties() {
276
+ Object.keys({ ...this.injectProperties(), ...this.properties() }).forEach((prop) => {
277
+ // when mixing shadow dom elements with light dom elements and nesting custom elements
278
+ // it might occur that properties where set on an element before it was
279
+ // registered or connected. To avoid such timing issues we check
280
+ // if a value was set for that specific property on the
281
+ // prototype before assigning a default value
282
+ const value = this[prop] || this.properties()[prop];
283
+ this.defineProperty(prop, value);
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Define a property on the element
289
+ * Will trigger watch() function when a property was changed
290
+ * Will trigger update() when a property was changed
291
+ * @param {string} property
292
+ * @param {any} value
293
+ * @param {boolean} reflectAttribute
294
+ */
295
+ defineProperty(property, value, reflectAttribute = false) {
296
+ if (this._state.hasOwnProperty(property)) {
297
+ // property has already been defined as an attribute nothing to do here
298
+ return;
299
+ }
300
+
301
+ // if property did not come from an attribute but has the option to reflect // enabled or custom fn
302
+ if (!reflectAttribute && this._options.propertyOptions[property]?.reflect) {
303
+ this.reflectProperty({ property: property, newValue: value });
304
+ }
305
+
306
+ // remove attribute if reflect is set to false explicitly in options
307
+ if (this._options.propertyOptions[property]?.reflect === false) {
308
+ this.removeAttribute(camelToDash(property));
309
+ }
310
+
311
+ if (hasSerializedState(this._serializationKey) && Object.keys(this.serializeState())?.includes(property)) {
312
+ value = this[property];
313
+ }
314
+
315
+ this._state[property] = value;
316
+
317
+ if (this._state[property] instanceof Store) {
318
+ this._state[property].subscribe(this);
319
+ }
320
+
321
+ Object.defineProperty(this, property, {
322
+ get: () => {
323
+ return this._state[property];
324
+ },
325
+ set: (newValue) => {
326
+ const oldValue = this._state[property];
327
+ const newValueString = JSON.stringify(newValue);
328
+ if (JSON.stringify(oldValue) !== newValueString) {
329
+ this._state[property] = newValue;
330
+
331
+ serializeState(this);
332
+
333
+ if (newValue instanceof Store) {
334
+ newValue.subscribe(this);
335
+ }
336
+
337
+ if (reflectAttribute || this._options.propertyOptions[property]?.reflect) {
338
+ this.reflectProperty({ property, newValue, newValueString });
339
+ }
340
+
341
+ if (this._options.autoUpdate) {
342
+ this.requestUpdate({
343
+ notify: true,
344
+ property: property,
345
+ newValue: newValue,
346
+ newValueString: newValueString,
347
+ oldValue: oldValue,
348
+ }).finally(() => {
349
+ this.callPropertyWatcher(property, newValue, oldValue);
350
+ this.notifyPropertyChange(property, newValue);
351
+ });
352
+ } else {
353
+ this.callPropertyWatcher(property, newValue, oldValue);
354
+ this.notifyPropertyChange(property, newValue);
355
+ }
356
+ }
357
+ return this;
358
+ },
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Call the watch callbacks on property changes
364
+ * @param {string} property property that changes
365
+ * @param {any} newValue
366
+ * @param {any} oldValue
367
+ */
368
+ callPropertyWatcher(property, newValue, oldValue) {
369
+ // notify watched properties (after update())
370
+ const watch = this.watch();
371
+ if (property in watch) {
372
+ watch[property](newValue, oldValue);
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Notify property observer via change event
378
+ * @param {string} property property that changes
379
+ * @param {any} newValue
380
+ */
381
+ notifyPropertyChange(property, newValue) {
382
+ // dispatch change event
383
+ if (
384
+ property in this._options['propertyOptions'] &&
385
+ this._options['propertyOptions'][property]['notify'] === true
386
+ ) {
387
+ this.dispatch(`${camelToDash(property)}-changed`, newValue);
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Reflects a property as attribute on the element
393
+ * @param {PropertyUpdateOptions} options
394
+ */
395
+ reflectProperty(options) {
396
+ const { property, newValue } = options;
397
+ const newValueString = options.newValueString || JSON.stringify(newValue);
398
+
399
+ if (newValue === undefined || newValue === null || isNaN(newValue)) {
400
+ // these would be reflected as strings: "undefined" || "null" || "NaN"
401
+ // which is not the desired behaviour. Therefore we reflect them as empty strings
402
+ this.setAttribute(camelToDash(property), '');
403
+ } else {
404
+ let attributeValue = isObjectLike(newValue) ? newValueString : newValue;
405
+ if (typeof this._options.propertyOptions[property]?.reflect === 'function') {
406
+ attributeValue = this._options.propertyOptions[property].reflect(newValue);
407
+ }
408
+ this.setAttribute(camelToDash(property), attributeValue);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Context Properties to issue Context Requests and to pull contextual properties into the elements scope
414
+ * @return {{}}
415
+ */
416
+ injectProperties() {
417
+ return {};
418
+ }
419
+
420
+ /**
421
+ * Define which properties that can be provided to children
422
+ * @return {{}}
423
+ */
424
+ provideProperties() {
425
+ return {};
426
+ }
427
+
428
+ /**
429
+ * Defines context on the element based on keys from this.injectProperties()
430
+ */
431
+ definePropertyInjection() {
432
+ Object.entries(this.injectProperties()).forEach(([key, value]) => {
433
+ this.requestContext(key, value);
434
+ });
435
+ // define context provider
436
+ const providedKeys = Object.keys(this.provideProperties());
437
+ if (providedKeys.length > 0) {
438
+ this.addEventListener('request-context', this.onRequestContext);
439
+ // check if there are already connected elements in child dom and restart requests
440
+ getAllElementChildren(this.getRoot(), BaseElement).forEach((customChild) => {
441
+ // if injectProperties?.() is defined it means that the child got connected BEFORE the parent (Runtime Issue)
442
+ const requestedProperties = customChild.injectProperties?.() ?? {};
443
+ Object.entries(requestedProperties).forEach(([key, value]) => {
444
+ // iterate over requested properties to find missed context requests
445
+ if (providedKeys.includes(key)) {
446
+ // owns the provided key -> replay request
447
+ customChild.requestContext(key, value);
448
+ }
449
+ });
450
+ });
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Helper Function to initialize the context requests
456
+ * @param {string} propertyName
457
+ * @param {any | function} valueOrCallback
458
+ */
459
+ requestContext(propertyName, valueOrCallback) {
460
+ this.dispatch('request-context', { [propertyName]: valueOrCallback });
461
+ }
462
+
463
+ /**
464
+ * Internal Context Request Handler
465
+ * This will check if the current/receiving instance actually provides a value under the requested name.
466
+ * If there is something to provide it will either assign the value to the requesting element or call the callback.
467
+ * @param {CustomEvent} event
468
+ */
469
+ onRequestContext(event) {
470
+ const provideProperties = this.provideProperties();
471
+
472
+ Object.entries(event.detail ?? {}).forEach(([key, valueOrCallback]) => {
473
+ if (key in provideProperties) {
474
+ // found it, provide it
475
+ const providedValue = provideProperties[key];
476
+ event.stopPropagation();
477
+ if (typeof valueOrCallback === 'function') {
478
+ // call function with context value
479
+ valueOrCallback(providedValue);
480
+ } else {
481
+ // assign to prop
482
+ const eventPath = event.composedPath();
483
+ const target = eventPath[0] || event.target;
484
+ target[key] = providedValue;
485
+ }
486
+ }
487
+ });
488
+ }
489
+
490
+ /**
491
+ * Connected lifecycle hook
492
+ */
493
+ connected() {}
494
+
495
+ /**
496
+ * BeforeUpdate lifecycle hook
497
+ */
498
+ beforeUpdate() {}
499
+
500
+ /**
501
+ * AfterUpdate lifecycle hook
502
+ */
503
+ afterUpdate() {}
504
+
505
+ /**
506
+ * Disconnected lifecycle hook
507
+ */
508
+ disconnected() {}
509
+
510
+ /**
511
+ * Triggers a lifecycle hook based on the name
512
+ * @param {string} name
513
+ */
514
+ triggerHook(name) {
515
+ if (name in this) {
516
+ this[name]();
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Watched attributes & properties to be notified to the element when changed. Should be overwritten in child components
522
+ * When an attribute or property as the key was changed on the element, the callback function defined as value
523
+ * will be called with `newValue` and `oldValue`
524
+ * eg. { property: (oldValue, newValue) => { console.log('property changed!, oldValue, newValue); } }
525
+ * @return {{}}
526
+ */
527
+ watch() {
528
+ return {};
529
+ }
530
+
531
+ /**
532
+ * Return a map of events that should be registered on the element.
533
+ * eg. { '.link': {
534
+ * 'click': (e) => {console.log('MyElement.link - clicked', e)},
535
+ * 'mouseover': (e) => {console.log('MyElement.link - mouseover()', e)},
536
+ * }
537
+ * }
538
+ */
539
+ events() {
540
+ return {};
541
+ }
542
+
543
+ /**
544
+ * Registers events on the element based on events().
545
+ * Removes all previously registered events before adding them.
546
+ */
547
+ registerEvents() {
548
+ // remove all previously registered events to prevent duplicate triggers
549
+ this.removeEvents();
550
+
551
+ // register events
552
+ const eventDefinitions = this.events();
553
+ Object.keys(eventDefinitions).forEach((elementSelector) => {
554
+ const events = eventDefinitions[elementSelector];
555
+ const selectorWhiteList = { window, document, this: this.getRoot() };
556
+ const whiteListElement = selectorWhiteList[elementSelector];
557
+ const eventTargets = whiteListElement
558
+ ? [whiteListElement]
559
+ : this.getRoot().querySelectorAll(elementSelector);
560
+
561
+ eventTargets.forEach((eventTarget) => {
562
+ Object.keys(events).forEach((eventName) => {
563
+ const notation = events[eventName];
564
+ if (typeof notation === 'function') {
565
+ const callback = notation.bind(this);
566
+ eventTarget.addEventListener(eventName, callback);
567
+ this._registeredEvents.push({ eventTarget, eventName, callback });
568
+ } else if (typeof notation === 'object' && typeof notation.listener === 'function') {
569
+ const callback = notation.listener.bind(this);
570
+ eventTarget.addEventListener(eventName, callback, notation.options ?? {});
571
+ this._registeredEvents.push({ eventTarget, eventName, callback });
572
+ }
573
+ });
574
+ });
575
+ });
576
+ }
577
+
578
+ /**
579
+ * Removes all events from the element that where previously registered from the events() map.
580
+ */
581
+ removeEvents() {
582
+ this._registeredEvents.forEach(({ eventTarget, eventName, callback }) => {
583
+ if (eventTarget === window || eventTarget === document || this.getRoot().contains(eventTarget)) {
584
+ eventTarget.removeEventListener(eventName, callback);
585
+ }
586
+ });
587
+ this._registeredEvents = [];
588
+ }
589
+
590
+ /**
591
+ * Stores an object that references child elements & DOM nodes that have a ref attribute defined.
592
+ * References will be accessible on the element under this.$refs
593
+ */
594
+ registerRefs() {
595
+ const refsNodeList = this.getRoot().querySelectorAll('[ref]');
596
+ const refsArray = Array.from(refsNodeList);
597
+ const refsMap = {};
598
+
599
+ refsArray.forEach((refNode) => {
600
+ const refKey = refNode.getAttribute('ref');
601
+ const isListKey = refKey.endsWith('[]');
602
+ const cleanKey = isListKey ? refKey.slice(0, -2) : refKey;
603
+ const hasListRef = Array.isArray(refsMap[cleanKey]);
604
+
605
+ if (isListKey) {
606
+ if (!refsMap[cleanKey] || !hasListRef) {
607
+ // create or overwrite previously registered single ref
608
+ refsMap[cleanKey] = [];
609
+ }
610
+ refsMap[cleanKey].push(refNode);
611
+ } else if (!hasListRef) {
612
+ refsMap[refKey] = refNode;
613
+ } else {
614
+ console.warn(
615
+ `Did not register singular ref ${refKey}, ${refNode} as a ref list is already registered under the same name.`,
616
+ );
617
+ }
618
+ });
619
+
620
+ this.$refs = refsMap;
621
+ }
622
+
623
+ /**
624
+ * Helper function for dispatching custom events
625
+ * @param {string} name
626
+ * @param {Object} [data]
627
+ * @param {CustomEventInit} [options]
628
+ * @param {boolean} [cancelable] - DEPRECATED
629
+ * @param {boolean} [composed] - DEPRECATED
630
+ */
631
+ dispatch(name, data, options, cancelable = false, composed = false) {
632
+ let eventInit = {};
633
+
634
+ if (options !== undefined && typeof options === 'boolean') {
635
+ eventInit['bubbles'] = options;
636
+ eventInit['cancelable'] = cancelable;
637
+ eventInit['composed'] = composed;
638
+ eventInit['detail'] = data;
639
+ console.warn(
640
+ `[${this.localName}] Using the old parameter options for the event is deprecated and will be removed in v2.0! Please use the new options object that is in line with the "CustomEventInit" options`,
641
+ );
642
+ } else {
643
+ eventInit = {
644
+ bubbles: true,
645
+ cancelable: true,
646
+ composed: true,
647
+ detail: data,
648
+ ...options,
649
+ };
650
+ }
651
+
652
+ const event = new CustomEvent(name, eventInit);
653
+ this.dispatchEvent(event);
654
+ }
655
+
656
+ /**
657
+ * Get the root element
658
+ * @returns {HTMLElement}
659
+ */
660
+ getRoot() {
661
+ return this;
662
+ }
663
+
664
+ /**
665
+ * This method will be used to serialize this elements state.
666
+ * By default, it will generate an object with all properties from this.properties().
667
+ * You can override this and return a different object with more/less/different properties.
668
+ * @return {{[string: any]: *}}
669
+ */
670
+ serializeState() {
671
+ const keys = Object.keys(this.properties());
672
+ return Object.fromEntries(keys.map((key) => [key, this[key]]));
673
+ }
674
+
675
+ /**
676
+ * This method will be used to restore properties on the element from a previously serialized state.
677
+ * You can override this and handle the deserialization differently if the default is not enough.
678
+ * @param {{[string: any]: *}} serializedState
679
+ */
680
+ restoreState(serializedState) {
681
+ Object.assign(this, serializedState);
682
+ }
678
683
  }
679
684
 
680
685
  export { BaseElement, getClosestParentCustomElementNode, isOfSameNodeType };