mount-observer 0.1.4 → 0.1.6

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/MountObserver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { arr } from './arr.js';
2
- import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, AttrChangeEvent, } from './Events.js';
2
+ import { MountEvent, DismountEvent, DisconnectEvent, LoadEvent, } from './Events.js';
3
3
  import { registerSharedObserver, unregisterSharedObserver } from './SharedMutationObserver.js';
4
- import { whereOutside } from './whereOutside.js';
4
+ import { withScopePerimeter } from './withScopePerimeter.js';
5
5
  export class MountObserver extends EventTarget {
6
6
  // Static registry for registered handlers
7
7
  static #handlerRegistry = new Map();
@@ -24,23 +24,32 @@ export class MountObserver extends EventTarget {
24
24
  #mutationCallback;
25
25
  #rootNode;
26
26
  #importsLoaded = false;
27
- #elementAttrStates = new WeakMap();
28
- #elementOnceAttrs = new WeakMap();
29
- #matchesWhereAttrFn = null;
30
- #buildAttrCoordinateMapFn = null;
31
- #checkAttrChangesFn = null;
32
27
  #mediaQueryCleanup;
33
28
  #mediaMatches = true;
34
29
  #asgMtSource;
35
30
  #asgDisMtSource;
31
+ #stageMtSource;
32
+ #stageReversals = new WeakMap();
33
+ #assignTentatively;
36
34
  #elementNotifiers = new WeakMap();
37
35
  #notifierMountedElements = new WeakSet();
38
- constructor(init, options = {}) {
36
+ constructor(config, options = {}) {
39
37
  super();
38
+ // Handle array shorthand - convert EnhancementConfig[] to MountConfig
39
+ let init;
40
+ if (Array.isArray(config)) {
41
+ init = {
42
+ matching: '*', // Match all elements, let withAttrs do the filtering
43
+ enhancementConfig: config
44
+ };
45
+ }
46
+ else {
47
+ init = config;
48
+ }
40
49
  this.#init = init;
41
50
  this.#options = options;
42
51
  this.#abortController = new AbortController();
43
- const { assignOnMount, assignOnDismount, do: doValue, reference, whereAttr, loadingEagerness, import: imp } = init;
52
+ const { assignOnMount, assignOnDismount, stageOnMount, do: doValue, reference, loadingEagerness, import: imp } = init;
44
53
  // Make a copy of assignOnMount config using structuredClone
45
54
  if (assignOnMount !== undefined) {
46
55
  this.#asgMtSource = structuredClone(assignOnMount);
@@ -48,6 +57,9 @@ export class MountObserver extends EventTarget {
48
57
  if (assignOnDismount !== undefined) {
49
58
  this.#asgDisMtSource = structuredClone(assignOnDismount);
50
59
  }
60
+ if (stageOnMount !== undefined) {
61
+ this.#stageMtSource = structuredClone(stageOnMount);
62
+ }
51
63
  if (options.disconnectedSignal) {
52
64
  options.disconnectedSignal.addEventListener('abort', () => {
53
65
  this.disconnect();
@@ -61,10 +73,6 @@ export class MountObserver extends EventTarget {
61
73
  if (reference !== undefined) {
62
74
  this.#validateReference();
63
75
  }
64
- // Preload whereAttr utilities if needed
65
- if (whereAttr) {
66
- this.#preloadWhereAttrUtilities();
67
- }
68
76
  // Start loading imports if eager
69
77
  if (loadingEagerness === 'eager' && imp) {
70
78
  this.#loadImports();
@@ -106,23 +114,6 @@ export class MountObserver extends EventTarget {
106
114
  }
107
115
  }
108
116
  }
109
- async #preloadWhereAttrUtilities() {
110
- if (!this.#matchesWhereAttrFn) {
111
- const { matchesWhereAttr } = await import('./whereAttr.js');
112
- this.#matchesWhereAttrFn = matchesWhereAttr;
113
- }
114
- if (!this.#buildAttrCoordinateMapFn) {
115
- const { buildAttrCoordinateMap } = await import('./attrCoordinates.js');
116
- this.#buildAttrCoordinateMapFn = buildAttrCoordinateMap;
117
- }
118
- if (!this.#checkAttrChangesFn) {
119
- const { checkAttrChanges } = await import('./attrChanges.js');
120
- // Create a bound function that passes the required parameters
121
- this.#checkAttrChangesFn = (element) => {
122
- return checkAttrChanges(element, this.#init, this.#buildAttrCoordinateMapFn, this.#elementAttrStates, this.#elementOnceAttrs);
123
- };
124
- }
125
- }
126
117
  async #setupMediaQuery() {
127
118
  if (!this.#rootNode) {
128
119
  throw new Error('Cannot setup media query before observe() is called');
@@ -153,19 +144,24 @@ export class MountObserver extends EventTarget {
153
144
  if (this.#asgMtSource || this.#asgDisMtSource) {
154
145
  await import('assign-gingerly/object-extension.js');
155
146
  }
147
+ if (this.#stageMtSource) {
148
+ const { assignTentatively } = await import('assign-gingerly/assignTentatively.js');
149
+ this.#assignTentatively = assignTentatively;
150
+ }
156
151
  this.#rootNode = new WeakRef(rootNode);
157
152
  // Set up media query if specified (needs rootNode to be set first)
158
- if (this.#init.whereMediaMatches) {
153
+ if (this.#init.withMediaMatching) {
159
154
  await this.#setupMediaQuery();
160
155
  }
161
- // Wait for whereAttr utilities to load if needed
162
- if (this.#init.whereAttr && !this.#matchesWhereAttrFn) {
163
- await this.#preloadWhereAttrUtilities();
164
- }
165
156
  // Wait for eager imports to complete if they were started in constructor
166
157
  if (this.#init.loadingEagerness === 'eager' && this.#init.import && !this.#importsLoaded) {
167
158
  await this.#loadImports();
168
159
  }
160
+ // Register enhancement configs if no imports (inline only)
161
+ // If imports exist, registration happens in #loadImports after modules are loaded
162
+ if (!this.#init.import && this.#init.enhancementConfig) {
163
+ await this.#registerEnhancementConfigs();
164
+ }
169
165
  // Process existing elements only if media matches
170
166
  if (this.#mediaMatches) {
171
167
  this.#processNode(rootNode);
@@ -176,7 +172,6 @@ export class MountObserver extends EventTarget {
176
172
  if (!this.#mediaMatches) {
177
173
  return;
178
174
  }
179
- const attrChanges = [];
180
175
  for (const mutation of mutations) {
181
176
  if (mutation.type === 'childList') {
182
177
  for (const node of mutation.addedNodes) {
@@ -190,43 +185,12 @@ export class MountObserver extends EventTarget {
190
185
  }
191
186
  });
192
187
  }
193
- else if (mutation.type === 'attributes' && mutation.target.nodeType === Node.ELEMENT_NODE) {
194
- // Handle attribute changes for mounted elements
195
- const element = mutation.target;
196
- if (this.#mountedElements.weakSet.has(element) && this.#checkAttrChangesFn) {
197
- const changes = this.#checkAttrChangesFn(element);
198
- attrChanges.push(...changes);
199
- }
200
- }
201
- }
202
- // Batch and dispatch attribute changes
203
- if (attrChanges.length > 0) {
204
- this.dispatchEvent(new AttrChangeEvent(attrChanges, this.#init));
205
- // Dispatch filtered attrchange events to element-specific notifiers
206
- const changesByElement = new Map();
207
- for (const change of attrChanges) {
208
- if (!changesByElement.has(change.element)) {
209
- changesByElement.set(change.element, []);
210
- }
211
- changesByElement.get(change.element).push(change);
212
- }
213
- for (const [element, changes] of changesByElement) {
214
- const notifier = this.#elementNotifiers.get(element);
215
- if (notifier) {
216
- notifier.dispatchEvent(new AttrChangeEvent(changes, this.#init));
217
- }
218
- }
219
188
  }
220
189
  };
221
190
  const observerConfig = {
222
191
  childList: true,
223
192
  subtree: true
224
193
  };
225
- // Add attribute observation if whereAttr is configured
226
- if (this.#init.whereAttr) {
227
- observerConfig.attributes = true;
228
- observerConfig.attributeOldValue = true;
229
- }
230
194
  // Register with shared mutation observer
231
195
  registerSharedObserver(rootNode, this.#mutationCallback, observerConfig);
232
196
  }
@@ -253,25 +217,111 @@ export class MountObserver extends EventTarget {
253
217
  const { loadImports } = await import('./loadImports.js');
254
218
  this.#modules = await loadImports(this.#init.import);
255
219
  this.#importsLoaded = true;
256
- // Validate referenced whereInstanceOf if reference is specified
220
+ // Validate referenced withInstance if reference is specified
257
221
  if (this.#init.reference !== undefined) {
258
222
  const references = arr(this.#init.reference);
259
223
  for (const index of references) {
260
224
  const module = this.#modules[index];
261
- if (module && module.whereInstanceOf !== undefined) {
225
+ if (module && module.withInstance !== undefined) {
262
226
  // Validate that it's a Constructor or array of Constructors
263
- const whereInstanceOf = module.whereInstanceOf;
264
- const constructors = arr(whereInstanceOf);
227
+ const withInstance = module.withInstance;
228
+ const constructors = arr(withInstance);
265
229
  for (const constructor of constructors) {
266
230
  if (typeof constructor !== 'function') {
267
- throw new Error(`Referenced module at index ${index} exports invalid whereInstanceOf: must be a Constructor or array of Constructors`);
231
+ throw new Error(`Referenced module at index ${index} exports invalid withInstance: must be a Constructor or array of Constructors`);
268
232
  }
269
233
  }
270
234
  }
271
235
  }
272
236
  }
237
+ // Register enhancement configs after imports are loaded
238
+ await this.#registerEnhancementConfigs();
273
239
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
274
240
  }
241
+ async #registerEnhancementConfigs() {
242
+ const rootNode = this.#rootNode?.deref();
243
+ if (!rootNode || !(rootNode instanceof Element)) {
244
+ return;
245
+ }
246
+ const registry = rootNode.customElementRegistry?.enhancementRegistry;
247
+ if (!registry) {
248
+ return;
249
+ }
250
+ const items = registry.getItems();
251
+ // Collect all enhancement configs to register
252
+ const configsToRegister = [];
253
+ // First, add inline enhancementConfig(s)
254
+ if (this.#init.enhancementConfig) {
255
+ const inlineConfigs = arr(this.#init.enhancementConfig);
256
+ configsToRegister.push(...inlineConfigs);
257
+ }
258
+ // Then, add referenced enhancementConfig(s) from imported modules
259
+ if (this.#importsLoaded && this.#init.reference !== undefined) {
260
+ const references = arr(this.#init.reference);
261
+ for (const index of references) {
262
+ const module = this.#modules[index];
263
+ if (module && module.enhancementConfig !== undefined) {
264
+ const referencedConfigs = arr(module.enhancementConfig);
265
+ configsToRegister.push(...referencedConfigs);
266
+ }
267
+ }
268
+ }
269
+ // Register each config if not already registered (using reference equality)
270
+ for (const config of configsToRegister) {
271
+ if (!items.includes(config)) {
272
+ registry.push(config);
273
+ }
274
+ }
275
+ }
276
+ /**
277
+ * Resolves template variables in a string recursively
278
+ * @param template - Template string with ${var} placeholders
279
+ * @param patterns - The patterns object containing variable values
280
+ * @returns Resolved string
281
+ */
282
+ #resolveAttrTemplate(template, patterns) {
283
+ return template.replace(/\$\{(\w+)\}/g, (match, varName) => {
284
+ const value = patterns[varName];
285
+ if (value === undefined) {
286
+ throw new Error(`Undefined template variable: ${varName}`);
287
+ }
288
+ if (typeof value === 'string') {
289
+ // Recursively resolve
290
+ return this.#resolveAttrTemplate(value, patterns);
291
+ }
292
+ return String(value);
293
+ });
294
+ }
295
+ /**
296
+ * Checks if element has attribute with enh- prefix handling
297
+ * @param element - The element to check
298
+ * @param attrName - The attribute name (without enh- prefix)
299
+ * @param allowUnprefixed - Pattern that element tag name must match to allow unprefixed attributes
300
+ * @returns true if element has the attribute
301
+ */
302
+ #hasAttributeWithEnhPrefix(element, attrName, allowUnprefixed) {
303
+ const isCustomElement = element.tagName.includes('-');
304
+ const isSVGElement = element instanceof SVGElement;
305
+ // For custom elements and SVG - strict enh- requirement
306
+ if (isCustomElement || isSVGElement) {
307
+ if (element.hasAttribute(`enh-${attrName}`)) {
308
+ return true;
309
+ }
310
+ // Only check unprefixed if tag name matches allowUnprefixed pattern
311
+ if (allowUnprefixed) {
312
+ const pattern = typeof allowUnprefixed === 'string'
313
+ ? new RegExp(allowUnprefixed)
314
+ : allowUnprefixed;
315
+ const tagName = element.tagName.toLowerCase();
316
+ if (pattern.test(tagName)) {
317
+ return element.hasAttribute(attrName);
318
+ }
319
+ }
320
+ return false;
321
+ }
322
+ // For built-in elements - enh- is alias (check both)
323
+ return element.hasAttribute(`enh-${attrName}`) || element.hasAttribute(attrName);
324
+ }
275
325
  #processNode(node) {
276
326
  // If it's an element node, check if it matches
277
327
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -281,10 +331,10 @@ export class MountObserver extends EventTarget {
281
331
  }
282
332
  }
283
333
  // Process children
284
- if ('querySelectorAll' in node) {
334
+ if ('querySelectorAll' in node && this.#init.matching) {
285
335
  const root = node;
286
336
  // Get all elements matching the CSS selector first
287
- root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
337
+ root.querySelectorAll(this.#init.matching).forEach(child => {
288
338
  if (this.#matchesSelector(child)) {
289
339
  this.#handleMatch(child);
290
340
  }
@@ -293,45 +343,37 @@ export class MountObserver extends EventTarget {
293
343
  }
294
344
  #matchesSelector(element) {
295
345
  //TODO: reduce redundncy with this.#init?
296
- // Check whereElementMatches condition
297
- const matchesElement = element.matches(this.#init.whereElementMatches);
346
+ // Check matching condition
347
+ if (!this.#init.matching) {
348
+ return false;
349
+ }
350
+ const matchesElement = element.matches(this.#init.matching);
298
351
  if (!matchesElement) {
299
352
  return false;
300
353
  }
301
- // Check whereOutside condition if specified (donut hole scoping)
302
- if (this.#init.whereOutside) {
354
+ // Check withScopePerimeter condition if specified (donut hole scoping)
355
+ if (this.#init.withScopePerimeter) {
303
356
  const rootNode = this.#rootNode?.deref();
304
- if (!rootNode || !whereOutside(rootNode, element, this.#init.whereOutside)) {
305
- return false;
306
- }
307
- }
308
- // Check whereAttr condition if specified
309
- if (this.#init.whereAttr) {
310
- // Use cached function (should be loaded by now from constructor)
311
- if (!this.#matchesWhereAttrFn) {
312
- console.warn('whereAttr utilities not loaded yet');
313
- return false;
314
- }
315
- if (!this.#matchesWhereAttrFn(element, this.#init.whereAttr)) {
357
+ if (!rootNode || !withScopePerimeter(rootNode, element, this.#init.withScopePerimeter)) {
316
358
  return false;
317
359
  }
318
360
  }
319
- // Check whereInstanceOf condition if specified
320
- if (this.#init.whereInstanceOf) {
321
- const constructors = arr(this.#init.whereInstanceOf);
361
+ // Check withInstance condition if specified
362
+ if (this.#init.withInstance) {
363
+ const constructors = arr(this.#init.withInstance);
322
364
  // Element must be an instance of at least one constructor (OR logic for array)
323
365
  const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
324
366
  if (!matchesInstanceOf) {
325
367
  return false;
326
368
  }
327
369
  }
328
- // Check referenced whereInstanceOf if imports are loaded and reference is specified
370
+ // Check referenced withInstance if imports are loaded and reference is specified
329
371
  if (this.#importsLoaded && this.#init.reference !== undefined) {
330
372
  const references = arr(this.#init.reference);
331
373
  for (const index of references) {
332
374
  const module = this.#modules[index];
333
- if (module && module.whereInstanceOf !== undefined) {
334
- const constructors = arr(module.whereInstanceOf);
375
+ if (module && module.withInstance !== undefined) {
376
+ const constructors = arr(module.withInstance);
335
377
  // Element must be an instance of at least one constructor (OR logic within this module)
336
378
  const matchesInstanceOf = constructors.some((constructor) => element instanceof constructor);
337
379
  if (!matchesInstanceOf) {
@@ -340,6 +382,65 @@ export class MountObserver extends EventTarget {
340
382
  }
341
383
  }
342
384
  }
385
+ //TODO: move to a separate file?
386
+ // Check withAttrs condition if specified (attribute-based matching)
387
+ // Check ALL enhancementConfigs (inline + referenced)
388
+ const enhancementConfigs = [];
389
+ // Add inline configs
390
+ if (this.#init.enhancementConfig) {
391
+ enhancementConfigs.push(...arr(this.#init.enhancementConfig));
392
+ }
393
+ // Add referenced configs if imports are loaded
394
+ if (this.#importsLoaded && this.#init.reference !== undefined) {
395
+ const references = arr(this.#init.reference);
396
+ for (const index of references) {
397
+ const module = this.#modules[index];
398
+ if (module && module.enhancementConfig !== undefined) {
399
+ enhancementConfigs.push(...arr(module.enhancementConfig));
400
+ }
401
+ }
402
+ }
403
+ // Check if ANY enhancementConfig has withAttrs - if so, element must match at least ONE
404
+ let hasAnyWithAttrs = false;
405
+ let matchesAnyWithAttrs = false;
406
+ for (const config of enhancementConfigs) {
407
+ if (!config.withAttrs) {
408
+ continue; // Skip configs without withAttrs
409
+ }
410
+ hasAnyWithAttrs = true;
411
+ const withAttrs = config.withAttrs;
412
+ const allowUnprefixed = config.allowUnprefixed;
413
+ // Collect all attribute names to check for this config
414
+ const attrNames = [];
415
+ for (const key in withAttrs) {
416
+ // Skip base and underscore-prefixed config keys
417
+ if (key === 'base' || key.startsWith('_')) {
418
+ continue;
419
+ }
420
+ const value = withAttrs[key];
421
+ if (typeof value === 'string') {
422
+ // Resolve template string to get actual attribute name
423
+ const attrName = this.#resolveAttrTemplate(value, withAttrs);
424
+ attrNames.push(attrName);
425
+ }
426
+ }
427
+ // Handle base attribute specially if present
428
+ if ('base' in withAttrs && typeof withAttrs.base === 'string') {
429
+ attrNames.push(withAttrs.base);
430
+ }
431
+ // Check if element has at least ONE of the specified attributes (OR logic within config)
432
+ if (attrNames.length > 0) {
433
+ const hasAnyAttribute = attrNames.some(attrName => this.#hasAttributeWithEnhPrefix(element, attrName, allowUnprefixed));
434
+ if (hasAnyAttribute) {
435
+ matchesAnyWithAttrs = true;
436
+ break; // Found a matching config, no need to check others
437
+ }
438
+ }
439
+ }
440
+ // If any config has withAttrs but element doesn't match any of them, reject
441
+ if (hasAnyWithAttrs && !matchesAnyWithAttrs) {
442
+ return false;
443
+ }
343
444
  // All conditions passed
344
445
  return true;
345
446
  }
@@ -366,12 +467,44 @@ export class MountObserver extends EventTarget {
366
467
  modules: this.#modules,
367
468
  observer: this,
368
469
  rootNode,
369
- mountInit: this.#init,
470
+ MountConfig: this.#init,
370
471
  };
371
472
  // Apply assignGingerly if specified
372
473
  if (this.#asgMtSource) {
373
474
  element.assignGingerly(this.#asgMtSource);
374
475
  }
476
+ // Apply assignTentatively if specified (staged assignments)
477
+ if (this.#stageMtSource && this.#assignTentatively) {
478
+ const reversal = {};
479
+ this.#assignTentatively(element, this.#stageMtSource, { reversal });
480
+ this.#stageReversals.set(element, reversal);
481
+ }
482
+ // Spawn enhancements if configured
483
+ // Process inline configs first, then referenced configs
484
+ const enhancementConfigs = [];
485
+ // Add inline configs
486
+ if (this.#init.enhancementConfig) {
487
+ enhancementConfigs.push(...arr(this.#init.enhancementConfig));
488
+ }
489
+ // Add referenced configs if imports are loaded
490
+ if (this.#importsLoaded && this.#init.reference !== undefined) {
491
+ const references = arr(this.#init.reference);
492
+ for (const index of references) {
493
+ const module = this.#modules[index];
494
+ if (module && module.enhancementConfig !== undefined) {
495
+ enhancementConfigs.push(...arr(module.enhancementConfig));
496
+ }
497
+ }
498
+ }
499
+ // Spawn each enhancement that has a spawn property
500
+ if (enhancementConfigs.length > 0) {
501
+ await import('assign-gingerly/object-extension.js');
502
+ for (const config of enhancementConfigs) {
503
+ if (config.spawn) {
504
+ element.enh.get(config, context);
505
+ }
506
+ }
507
+ }
375
508
  // Check if notifier exists BEFORE calling do callback
376
509
  const notifierExistedBeforeDo = this.#elementNotifiers.has(element);
377
510
  // Call do callback(s) - can be string, function, or array
@@ -419,18 +552,6 @@ export class MountObserver extends EventTarget {
419
552
  const { emitMountedElementEvents } = await import('./emitEvents.js');
420
553
  await emitMountedElementEvents(element, this.#init, this.#processedEventsForElement);
421
554
  }
422
- // Check for initial attribute changes if whereAttr is configured
423
- if (this.#checkAttrChangesFn) {
424
- const changes = this.#checkAttrChangesFn(element);
425
- if (changes.length > 0) {
426
- this.dispatchEvent(new AttrChangeEvent(changes, this.#init));
427
- // Also dispatch to element-specific notifier
428
- const notifier = this.#elementNotifiers.get(element);
429
- if (notifier) {
430
- notifier.dispatchEvent(new AttrChangeEvent(changes, this.#init));
431
- }
432
- }
433
- }
434
555
  }
435
556
  async assignGingerly(config) {
436
557
  // Handle undefined case
@@ -462,6 +583,14 @@ export class MountObserver extends EventTarget {
462
583
  if (!this.#mountedElements.weakSet.has(element)) {
463
584
  return;
464
585
  }
586
+ // Reverse tentative assignments first (restore original values)
587
+ if (this.#stageMtSource && this.#assignTentatively) {
588
+ const reversal = this.#stageReversals.get(element);
589
+ if (reversal) {
590
+ this.#assignTentatively(element, reversal);
591
+ this.#stageReversals.delete(element);
592
+ }
593
+ }
465
594
  // Apply assignGingerly if specified for dismount
466
595
  if (this.#asgDisMtSource) {
467
596
  element.assignGingerly(this.#asgDisMtSource);
@@ -487,10 +616,10 @@ export class MountObserver extends EventTarget {
487
616
  modules: this.#modules,
488
617
  observer: this,
489
618
  rootNode,
490
- mountInit: this.#init,
619
+ MountConfig: this.#init,
491
620
  };
492
621
  // Dispatch dismount event
493
- const dismountEvent = new DismountEvent(element, 'where-element-matches-failed', this.#init);
622
+ const dismountEvent = new DismountEvent(element, 'with-matching-failed', this.#init);
494
623
  this.dispatchEvent(dismountEvent);
495
624
  // Dispatch to element-specific notifier
496
625
  const notifier = this.#elementNotifiers.get(element);