1
0

SelectorObserver.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. /** Options for the `onSelector()` method of {@linkcode SelectorObserver} */
  2. export type OnSelectorOptions<TElem extends Element = HTMLElement> = SelectorOptionsOne<TElem> | SelectorOptionsAll<TElem>;
  3. type SelectorOptionsOne<TElem extends Element> = SelectorOptionsCommon & {
  4. /** Whether to use `querySelectorAll()` instead - default is false */
  5. all?: false;
  6. /** Gets called whenever the selector was found in the DOM */
  7. listener: (element: TElem) => void;
  8. };
  9. type SelectorOptionsAll<TElem extends Element> = SelectorOptionsCommon & {
  10. /** Whether to use `querySelectorAll()` instead - default is false */
  11. all: true;
  12. /** Gets called whenever the selector was found in the DOM */
  13. listener: (elements: NodeListOf<TElem>) => void;
  14. };
  15. type SelectorOptionsCommon = {
  16. /** Whether to call the listener continuously instead of once - default is false */
  17. continuous?: boolean;
  18. /** Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default) */
  19. debounce?: number;
  20. };
  21. /** Observes the children of the given element for changes */
  22. export class SelectorObserver {
  23. private enabled = true;
  24. private baseElement: Element;
  25. private observer: MutationObserver;
  26. private observerOptions: MutationObserverInit;
  27. private listenerMap = new Map<string, OnSelectorOptions[]>();
  28. /**
  29. * Creates a new SelectorObserver that will observe the children of the given base element for changes (only creation and deletion of elements by default)
  30. * @param options fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
  31. */
  32. constructor(baseElement: Element, observerOptions?: MutationObserverInit) {
  33. this.baseElement = baseElement;
  34. this.observer = new MutationObserver(this.checkSelectors);
  35. this.observerOptions = observerOptions ?? {};
  36. this.enable();
  37. }
  38. private checkSelectors() {
  39. for(const [selector, listeners] of this.listenerMap.entries()) {
  40. if(!this.enabled)
  41. return;
  42. const all = listeners.some(listener => listener.all);
  43. const one = listeners.some(listener => !listener.all);
  44. const allElements = all ? this.baseElement.querySelectorAll<HTMLElement>(selector) : null;
  45. const oneElement = one ? this.baseElement.querySelector<HTMLElement>(selector) : null;
  46. for(const options of listeners) {
  47. if(!this.enabled)
  48. return;
  49. if(options.all) {
  50. if(allElements && allElements.length > 0) {
  51. options.listener(allElements);
  52. if(!options.continuous)
  53. this.listenerMap.get(selector)!.splice(this.listenerMap.get(selector)!.indexOf(options), 1);
  54. }
  55. } else {
  56. if(oneElement) {
  57. options.listener(oneElement);
  58. if(!options.continuous)
  59. this.listenerMap.get(selector)!.splice(this.listenerMap.get(selector)!.indexOf(options), 1);
  60. }
  61. }
  62. }
  63. }
  64. }
  65. private debounce<TArgs>(func: (...args: TArgs[]) => void, time: number): (...args: TArgs[]) => void {
  66. let timeout: number;
  67. return function(...args: TArgs[]) {
  68. clearTimeout(timeout);
  69. timeout = setTimeout(() => func.apply(this, args), time) as unknown as number;
  70. };
  71. }
  72. /**
  73. * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
  74. * @param selector The selector to observe
  75. * @param options Options for the selector observation
  76. * @param options.listener Gets called whenever the selector was found in the DOM
  77. * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
  78. * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
  79. * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
  80. */
  81. public addListener<TElem extends Element = HTMLElement>(selector: string, options: OnSelectorOptions<TElem>) {
  82. options = { all: false, continuous: false, debounce: 0, ...options };
  83. if(options.debounce && options.debounce > 0)
  84. options.listener = this.debounce(options.listener as ((arg: NodeListOf<Element> | Element) => void), options.debounce);
  85. if(this.listenerMap.has(selector))
  86. this.listenerMap.get(selector)!.push(options as OnSelectorOptions<Element>);
  87. else
  88. this.listenerMap.set(selector, [options as OnSelectorOptions<Element>]);
  89. }
  90. /** Disables the observation of the child elements */
  91. public disable() {
  92. if(!this.enabled)
  93. return;
  94. this.enabled = false;
  95. this.observer.disconnect();
  96. }
  97. /** Reenables the observation of the child elements */
  98. public enable() {
  99. if(this.enabled)
  100. return;
  101. this.enabled = true;
  102. this.observer.observe(this.baseElement, {
  103. childList: true,
  104. subtree: true,
  105. ...this.observerOptions,
  106. });
  107. }
  108. /** Removes all listeners that have been registered with {@linkcode addListener()} */
  109. public clearListeners() {
  110. this.listenerMap.clear();
  111. }
  112. /**
  113. * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
  114. * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
  115. */
  116. public removeAllListeners(selector: string) {
  117. return this.listenerMap.delete(selector);
  118. }
  119. /**
  120. * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
  121. * @returns Returns true when the listener was found and removed, false otherwise
  122. */
  123. public removeListener(selector: string, options: OnSelectorOptions) {
  124. const listeners = this.listenerMap.get(selector);
  125. if(!listeners)
  126. return false;
  127. const index = listeners.indexOf(options);
  128. if(index !== -1) {
  129. listeners.splice(index, 1);
  130. return true;
  131. }
  132. return false;
  133. }
  134. /** Returns all listeners that have been registered with {@linkcode addListener()} */
  135. public getAllListeners() {
  136. return this.listenerMap;
  137. }
  138. /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
  139. public getListeners(selector: string) {
  140. return this.listenerMap.get(selector);
  141. }
  142. }