mount-observer 0.1.2 → 0.1.4

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.ts CHANGED
@@ -6,8 +6,10 @@ import {
6
6
  AttrChange,
7
7
  WeakDual,
8
8
  EventConfig,
9
- EventConstructor
9
+ EventConstructor,
10
+ Constructor
10
11
  } from './types.js';
12
+ import { arr } from './arr.js';
11
13
  import {
12
14
  MountEvent,
13
15
  DismountEvent,
@@ -23,6 +25,16 @@ import {
23
25
  import { whereOutside } from './whereOutside.js';
24
26
 
25
27
  export class MountObserver extends EventTarget implements IMountObserver {
28
+ // Static registry for registered handlers
29
+ static #handlerRegistry = new Map<string, Constructor>();
30
+
31
+ static define(name: string, handler: Constructor): void {
32
+ if (this.#handlerRegistry.has(name)) {
33
+ throw new Error(`${name} already in use`);
34
+ }
35
+ this.#handlerRegistry.set(name, handler);
36
+ }
37
+
26
38
  #init: MountInit;
27
39
  #options: MountObserverOptions;
28
40
  #abortController: AbortController;
@@ -43,7 +55,10 @@ export class MountObserver extends EventTarget implements IMountObserver {
43
55
  #checkAttrChangesFn: ((element: Element) => AttrChange[]) | null = null;
44
56
  #mediaQueryCleanup?: () => void;
45
57
  #mediaMatches: boolean = true;
46
- #assignGingerlySource: Record<string, any> | undefined;
58
+ #asgMtSource: Record<string, any> | undefined;
59
+ #asgDisMtSource: Record<string, any> | undefined;
60
+ #elementNotifiers = new WeakMap<Element, EventTarget>();
61
+ #notifierMountedElements = new WeakSet<Element>();
47
62
 
48
63
  constructor(init: MountInit, options: MountObserverOptions = {}) {
49
64
  super();
@@ -51,9 +66,16 @@ export class MountObserver extends EventTarget implements IMountObserver {
51
66
  this.#options = options;
52
67
  this.#abortController = new AbortController();
53
68
 
54
- // Make a copy of assignGingerly config using structuredClone
55
- if (init.assignGingerly !== undefined) {
56
- this.#assignGingerlySource = structuredClone(init.assignGingerly);
69
+ const {
70
+ assignOnMount, assignOnDismount, do: doValue, reference, whereAttr, loadingEagerness,
71
+ import: imp
72
+ } = init;
73
+ // Make a copy of assignOnMount config using structuredClone
74
+ if (assignOnMount !== undefined) {
75
+ this.#asgMtSource = structuredClone(assignOnMount);
76
+ }
77
+ if (assignOnDismount !== undefined) {
78
+ this.#asgDisMtSource = structuredClone(assignOnDismount);
57
79
  }
58
80
 
59
81
  if (options.disconnectedSignal) {
@@ -62,17 +84,71 @@ export class MountObserver extends EventTarget implements IMountObserver {
62
84
  });
63
85
  }
64
86
 
87
+ // Validate do property if it contains string references
88
+ if (doValue !== undefined) {
89
+ this.#validateDoHandlers();
90
+ }
91
+
92
+ // Validate reference property if present
93
+ if (reference !== undefined) {
94
+ this.#validateReference();
95
+ }
96
+
65
97
  // Preload whereAttr utilities if needed
66
- if (init.whereAttr) {
98
+ if (whereAttr) {
67
99
  this.#preloadWhereAttrUtilities();
68
100
  }
69
101
 
70
102
  // Start loading imports if eager
71
- if (init.loadingEagerness === 'eager' && init.import) {
103
+ if (loadingEagerness === 'eager' && imp) {
72
104
  this.#loadImports();
73
105
  }
74
106
  }
75
107
 
108
+ #validateDoHandlers(): void {
109
+ const doValue = this.#init.do;
110
+ if (doValue === undefined) return;
111
+
112
+ const handlers = Array.isArray(doValue) ? doValue : [doValue];
113
+
114
+ for (const handler of handlers) {
115
+ if (typeof handler === 'string') {
116
+ if (!MountObserver.#handlerRegistry.has(handler)) {
117
+ throw new Error(`No handler defined for ${handler}`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ #validateReference(): void {
124
+ if (!this.#init.import) {
125
+ throw new Error('reference property requires import to be defined');
126
+ }
127
+
128
+ // Normalize import to array
129
+ const imports = Array.isArray(this.#init.import)
130
+ ? this.#init.import
131
+ : [this.#init.import];
132
+
133
+ // Normalize reference to array
134
+ const references = arr(this.#init.reference);
135
+
136
+ // Validate each reference index
137
+ for (const index of references) {
138
+ // Check if index is within bounds
139
+ if (index < 0 || index >= imports.length) {
140
+ throw new Error(`reference index ${index} is out of bounds (import array length: ${imports.length})`);
141
+ }
142
+
143
+ const importItem = imports[index];
144
+
145
+ // Check if it's a JS module (not a 2D array with type option)
146
+ if (Array.isArray(importItem)) {
147
+ throw new Error(`reference index ${index} points to a non-JS module import (array with type option)`);
148
+ }
149
+ }
150
+ }
151
+
76
152
  async #preloadWhereAttrUtilities(): Promise<void> {
77
153
  if (!this.#matchesWhereAttrFn) {
78
154
  const { matchesWhereAttr } = await import('./whereAttr.js');
@@ -120,10 +196,26 @@ export class MountObserver extends EventTarget implements IMountObserver {
120
196
  return this.#abortController.signal;
121
197
  }
122
198
 
199
+ getNotifier(element: Element): EventTarget {
200
+ // Return cached notifier if it exists
201
+ let notifier = this.#elementNotifiers.get(element);
202
+ if (notifier) {
203
+ return notifier;
204
+ }
205
+
206
+ // Create new EventTarget for this element
207
+ notifier = new EventTarget();
208
+ this.#elementNotifiers.set(element, notifier);
209
+ return notifier;
210
+ }
211
+
123
212
  async observe(rootNode: Node): Promise<void> {
124
213
  if (this.#rootNode) {
125
214
  throw new Error('Already observing');
126
215
  }
216
+ if(this.#asgMtSource || this.#asgDisMtSource){
217
+ await import('assign-gingerly/object-extension.js');
218
+ }
127
219
 
128
220
  this.#rootNode = new WeakRef(rootNode);
129
221
 
@@ -137,6 +229,11 @@ export class MountObserver extends EventTarget implements IMountObserver {
137
229
  await this.#preloadWhereAttrUtilities();
138
230
  }
139
231
 
232
+ // Wait for eager imports to complete if they were started in constructor
233
+ if (this.#init.loadingEagerness === 'eager' && this.#init.import && !this.#importsLoaded) {
234
+ await this.#loadImports();
235
+ }
236
+
140
237
  // Process existing elements only if media matches
141
238
  if (this.#mediaMatches) {
142
239
  this.#processNode(rootNode);
@@ -176,6 +273,22 @@ export class MountObserver extends EventTarget implements IMountObserver {
176
273
  // Batch and dispatch attribute changes
177
274
  if (attrChanges.length > 0) {
178
275
  this.dispatchEvent(new AttrChangeEvent(attrChanges, this.#init));
276
+
277
+ // Dispatch filtered attrchange events to element-specific notifiers
278
+ const changesByElement = new Map<Element, AttrChange[]>();
279
+ for (const change of attrChanges) {
280
+ if (!changesByElement.has(change.element)) {
281
+ changesByElement.set(change.element, []);
282
+ }
283
+ changesByElement.get(change.element)!.push(change);
284
+ }
285
+
286
+ for (const [element, changes] of changesByElement) {
287
+ const notifier = this.#elementNotifiers.get(element);
288
+ if (notifier) {
289
+ notifier.dispatchEvent(new AttrChangeEvent(changes, this.#init));
290
+ }
291
+ }
179
292
  }
180
293
  };
181
294
 
@@ -223,6 +336,26 @@ export class MountObserver extends EventTarget implements IMountObserver {
223
336
  this.#modules = await loadImports(this.#init.import);
224
337
  this.#importsLoaded = true;
225
338
 
339
+ // Validate referenced whereInstanceOf if reference is specified
340
+ if (this.#init.reference !== undefined) {
341
+ const references = arr(this.#init.reference);
342
+
343
+ for (const index of references) {
344
+ const module = this.#modules[index];
345
+ if (module && module.whereInstanceOf !== undefined) {
346
+ // Validate that it's a Constructor or array of Constructors
347
+ const whereInstanceOf = module.whereInstanceOf;
348
+ const constructors = arr(whereInstanceOf);
349
+
350
+ for (const constructor of constructors) {
351
+ if (typeof constructor !== 'function') {
352
+ throw new Error(`Referenced module at index ${index} exports invalid whereInstanceOf: must be a Constructor or array of Constructors`);
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
358
+
226
359
  this.dispatchEvent(new LoadEvent(this.#modules, this.#init));
227
360
  }
228
361
 
@@ -237,8 +370,8 @@ export class MountObserver extends EventTarget implements IMountObserver {
237
370
  }
238
371
 
239
372
  // Process children
240
- if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_NODE) {
241
- const root = node as Element | Document;
373
+ if ('querySelectorAll' in node) {
374
+ const root = node as DocumentFragment;
242
375
 
243
376
  // Get all elements matching the CSS selector first
244
377
  root.querySelectorAll(this.#init.whereElementMatches).forEach(child => {
@@ -280,9 +413,7 @@ export class MountObserver extends EventTarget implements IMountObserver {
280
413
 
281
414
  // Check whereInstanceOf condition if specified
282
415
  if (this.#init.whereInstanceOf) {
283
- const constructors = Array.isArray(this.#init.whereInstanceOf)
284
- ? this.#init.whereInstanceOf
285
- : [this.#init.whereInstanceOf];
416
+ const constructors = arr(this.#init.whereInstanceOf);
286
417
 
287
418
  // Element must be an instance of at least one constructor (OR logic for array)
288
419
  const matchesInstanceOf = constructors.some(constructor => element instanceof constructor);
@@ -292,6 +423,25 @@ export class MountObserver extends EventTarget implements IMountObserver {
292
423
  }
293
424
  }
294
425
 
426
+ // Check referenced whereInstanceOf if imports are loaded and reference is specified
427
+ if (this.#importsLoaded && this.#init.reference !== undefined) {
428
+ const references = arr(this.#init.reference);
429
+
430
+ for (const index of references) {
431
+ const module = this.#modules[index];
432
+ if (module && module.whereInstanceOf !== undefined) {
433
+ const constructors = arr(module.whereInstanceOf);
434
+
435
+ // Element must be an instance of at least one constructor (OR logic within this module)
436
+ const matchesInstanceOf = constructors.some((constructor: Constructor) => element instanceof constructor);
437
+
438
+ if (!matchesInstanceOf) {
439
+ return false;
440
+ }
441
+ }
442
+ }
443
+ }
444
+
295
445
  // All conditions passed
296
446
  return true;
297
447
  }
@@ -323,28 +473,62 @@ export class MountObserver extends EventTarget implements IMountObserver {
323
473
  const context: MountContext = {
324
474
  modules: this.#modules,
325
475
  observer: this,
326
- observeInfo: {
327
- rootNode
328
- }
476
+ rootNode,
477
+ mountInit: this.#init,
329
478
  };
330
479
 
331
480
  // Apply assignGingerly if specified
332
- if (this.#assignGingerlySource) {
333
- const { assignGingerly } = await import('assign-gingerly/index.js');
334
- assignGingerly(element, this.#assignGingerlySource);
481
+ if (this.#asgMtSource) {
482
+ element.assignGingerly(this.#asgMtSource);
335
483
  }
336
484
 
337
- // Call do callback
338
- if (this.#init.do) {
339
- if (typeof this.#init.do === 'function') {
340
- this.#init.do(element, context);
341
- } else if (this.#init.do.mount) {
342
- this.#init.do.mount(element, context);
485
+ // Check if notifier exists BEFORE calling do callback
486
+ const notifierExistedBeforeDo = this.#elementNotifiers.has(element);
487
+
488
+ // Call do callback(s) - can be string, function, or array
489
+ if (this.#init.do !== undefined) {
490
+ const doHandlers = Array.isArray(this.#init.do) ? this.#init.do : [this.#init.do];
491
+
492
+ for (const handler of doHandlers) {
493
+ if (typeof handler === 'string') {
494
+ // Registered handler - instantiate it
495
+ const HandlerClass = MountObserver.#handlerRegistry.get(handler);
496
+ if (HandlerClass) {
497
+ new HandlerClass(element, context);
498
+ }
499
+ } else if (typeof handler === 'function') {
500
+ // Inline function
501
+ handler(element, context);
502
+ }
503
+ }
504
+ }
505
+
506
+ // Call referenced do functions from imported modules
507
+ if (this.#init.reference !== undefined) {
508
+ const references = arr(this.#init.reference);
509
+
510
+ for (const index of references) {
511
+ const module = this.#modules[index];
512
+ if (module && typeof module.do === 'function') {
513
+ module.do(element, context);
514
+ }
343
515
  }
344
516
  }
345
517
 
346
518
  // Dispatch mount event
347
- this.dispatchEvent(new MountEvent(element, this.#modules, this.#init));
519
+ const mountEvent = new MountEvent(element, this.#modules, this.#init, context);
520
+ this.dispatchEvent(mountEvent);
521
+
522
+ // Dispatch to element-specific notifier only if:
523
+ // 1. Notifier existed before do callback (wasn't just created), AND
524
+ // 2. Element hasn't already received a mount event on its notifier
525
+ if (notifierExistedBeforeDo && !this.#notifierMountedElements.has(element)) {
526
+ const notifier = this.#elementNotifiers.get(element);
527
+ if (notifier) {
528
+ this.#notifierMountedElements.add(element);
529
+ notifier.dispatchEvent(mountEvent);
530
+ }
531
+ }
348
532
 
349
533
  // Emit events from mounted element if configured
350
534
  if (this.#init.mountedElemEmits) {
@@ -357,6 +541,12 @@ export class MountObserver extends EventTarget implements IMountObserver {
357
541
  const changes = this.#checkAttrChangesFn(element);
358
542
  if (changes.length > 0) {
359
543
  this.dispatchEvent(new AttrChangeEvent(changes, this.#init));
544
+
545
+ // Also dispatch to element-specific notifier
546
+ const notifier = this.#elementNotifiers.get(element);
547
+ if (notifier) {
548
+ notifier.dispatchEvent(new AttrChangeEvent(changes, this.#init));
549
+ }
360
550
  }
361
551
  }
362
552
  }
@@ -364,35 +554,43 @@ export class MountObserver extends EventTarget implements IMountObserver {
364
554
  async assignGingerly(config: Record<string, any> | undefined): Promise<void> {
365
555
  // Handle undefined case
366
556
  if (config === undefined) {
367
- this.#assignGingerlySource = undefined;
557
+ this.#asgMtSource = undefined;
368
558
  return;
369
559
  }
370
560
 
371
- const { assignGingerly } = await import('assign-gingerly/index.js');
561
+ await import('assign-gingerly/object-extension.js');
372
562
 
373
563
  // Update the source config for future mounted elements
374
- if (this.#assignGingerlySource === undefined) {
564
+ if (this.#asgMtSource === undefined) {
375
565
  // No existing config, just clone the passed in object
376
- this.#assignGingerlySource = structuredClone(config);
566
+ this.#asgMtSource = structuredClone(config);
377
567
  } else {
378
568
  // Merge into existing config using assignGingerly
379
- assignGingerly(this.#assignGingerlySource, config);
569
+ this.#asgMtSource.assignGingerly(config);
570
+ //assignGingerly(this.#asgMtSource, config);
380
571
  }
381
572
 
382
573
  // Apply to already mounted elements using setWeak for iteration
383
574
  for (const ref of this.#mountedElements.setWeak) {
384
575
  const element = ref.deref();
385
576
  if (element) {
386
- assignGingerly(element, config);
577
+ element.assignGingerly(config);
578
+ //assignGingerly(element, config);
387
579
  }
388
580
  }
389
581
  }
390
582
 
391
- #handleRemoval(element: Element): void {
583
+ async #handleRemoval(element: Element): Promise<void> {
392
584
  if (!this.#mountedElements.weakSet.has(element)) {
393
585
  return;
394
586
  }
395
587
 
588
+
589
+
590
+ // Apply assignGingerly if specified for dismount
591
+ if (this.#asgDisMtSource) {
592
+ element.assignGingerly(this.#asgDisMtSource);
593
+ }
396
594
  // Remove from both structures
397
595
  this.#mountedElements.weakSet.delete(element);
398
596
  for (const ref of this.#mountedElements.setWeak) {
@@ -401,6 +599,12 @@ export class MountObserver extends EventTarget implements IMountObserver {
401
599
  break;
402
600
  }
403
601
  }
602
+
603
+ // Remove from processed set so element can be re-mounted
604
+ this.#processedDoForElement.delete(element);
605
+
606
+ // Remove from notifier mounted tracking so mount event can fire again
607
+ this.#notifierMountedElements.delete(element);
404
608
 
405
609
  const rootNode = this.#rootNode?.deref();
406
610
  if (!rootNode) {
@@ -411,28 +615,34 @@ export class MountObserver extends EventTarget implements IMountObserver {
411
615
  const context: MountContext = {
412
616
  modules: this.#modules,
413
617
  observer: this,
414
- observeInfo: {
415
- rootNode
416
- }
618
+ rootNode,
619
+ mountInit: this.#init,
417
620
  };
418
621
 
419
- // Call dismount callback
420
- if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.dismount) {
421
- this.#init.do.dismount(element, context);
422
- }
423
622
 
424
623
  // Dispatch dismount event
425
- this.dispatchEvent(new DismountEvent(element, 'where-element-matches-failed', this.#init));
624
+ const dismountEvent = new DismountEvent(element, 'where-element-matches-failed', this.#init);
625
+ this.dispatchEvent(dismountEvent);
626
+
627
+ // Dispatch to element-specific notifier
628
+ const notifier = this.#elementNotifiers.get(element);
629
+ if (notifier) {
630
+ notifier.dispatchEvent(dismountEvent);
631
+ }
426
632
 
427
633
  // Check if element is being moved within the same root
428
634
  // If it's truly disconnected, dispatch disconnect event
429
635
  setTimeout(() => {
430
636
  if (!rootNode.contains(element)) {
431
- if (this.#init.do && typeof this.#init.do !== 'function' && this.#init.do.disconnect) {
432
- this.#init.do.disconnect(element, context);
433
- }
434
637
 
435
- this.dispatchEvent(new DisconnectEvent(element, this.#init));
638
+ const disconnectEvent = new DisconnectEvent(element, this.#init);
639
+ this.dispatchEvent(disconnectEvent);
640
+
641
+ // Dispatch to element-specific notifier
642
+ const notifier = this.#elementNotifiers.get(element);
643
+ if (notifier) {
644
+ notifier.dispatchEvent(disconnectEvent);
645
+ }
436
646
  }
437
647
  }, 0);
438
648
  }