SelectorObserver.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /** Options for the `onSelector()` method of {@linkcode SelectorObserver} */
  2. export type SelectorListenerOptions<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. export type SelectorObserverOptions = MutationObserverInit & {
  22. /** If set, applies this debounce in milliseconds to all listeners that don't have their own debounce set */
  23. defaultDebounce?: number;
  24. };
  25. /** Observes the children of the given element for changes */
  26. export class SelectorObserver {
  27. private enabled = false;
  28. private baseElement: Element;
  29. private observer: MutationObserver;
  30. private observerOptions: SelectorObserverOptions;
  31. private listenerMap: Map<string, SelectorListenerOptions[]>;
  32. private readonly dbgId = Math.floor(Math.random() * 1000000);
  33. /**
  34. * Creates a new SelectorObserver that will observe the children of the given base element for changes (only creation and deletion of elements by default)
  35. * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
  36. * TODO: support passing a selector for the base element to be able to queue listeners before the element is available
  37. */
  38. constructor(baseElement: Element, options: SelectorObserverOptions = {}) {
  39. this.baseElement = baseElement;
  40. this.listenerMap = new Map<string, SelectorListenerOptions[]>();
  41. // if the arrow func isn't there, `this` will be undefined in the callback
  42. this.observer = new MutationObserver(() => this.checkSelectors());
  43. this.observerOptions = {
  44. childList: true,
  45. subtree: true,
  46. ...options,
  47. };
  48. this.enable();
  49. }
  50. private checkSelectors() {
  51. for(const [selector, listeners] of this.listenerMap.entries()) {
  52. if(!this.enabled)
  53. return;
  54. const all = listeners.some(listener => listener.all);
  55. const one = listeners.some(listener => !listener.all);
  56. const allElements = all ? this.baseElement.querySelectorAll<HTMLElement>(selector) : null;
  57. const oneElement = one ? this.baseElement.querySelector<HTMLElement>(selector) : null;
  58. for(const options of listeners) {
  59. if(options.all) {
  60. if(allElements && allElements.length > 0) {
  61. options.listener(allElements);
  62. if(!options.continuous)
  63. this.removeListener(selector, options);
  64. }
  65. }
  66. else {
  67. if(oneElement) {
  68. options.listener(oneElement);
  69. if(!options.continuous)
  70. this.removeListener(selector, options);
  71. }
  72. }
  73. if(this.listenerMap.get(selector)?.length === 0)
  74. this.listenerMap.delete(selector);
  75. }
  76. }
  77. }
  78. private debounce<TArgs>(func: (...args: TArgs[]) => void, time: number): (...args: TArgs[]) => void {
  79. let timeout: number;
  80. return function(...args: TArgs[]) {
  81. clearTimeout(timeout);
  82. timeout = setTimeout(() => func.apply(this, args), time) as unknown as number;
  83. };
  84. }
  85. /**
  86. * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
  87. * @param selector The selector to observe
  88. * @param options Options for the selector observation
  89. * @param options.listener Gets called whenever the selector was found in the DOM
  90. * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
  91. * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
  92. * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
  93. */
  94. public addListener<TElem extends Element = HTMLElement>(selector: string, options: SelectorListenerOptions<TElem>) {
  95. options = { all: false, continuous: false, debounce: 0, ...options };
  96. if((options.debounce && options.debounce > 0) || (this.observerOptions.defaultDebounce && this.observerOptions.defaultDebounce > 0)) {
  97. options.listener = this.debounce(
  98. options.listener as ((arg: NodeListOf<Element> | Element) => void),
  99. (options.debounce || this.observerOptions.defaultDebounce)!,
  100. );
  101. }
  102. if(this.listenerMap.has(selector))
  103. this.listenerMap.get(selector)!.push(options as SelectorListenerOptions<Element>);
  104. else
  105. this.listenerMap.set(selector, [options as SelectorListenerOptions<Element>]);
  106. this.checkSelectors();
  107. }
  108. /** Disables the observation of the child elements */
  109. public disable() {
  110. if(!this.enabled)
  111. return;
  112. this.enabled = false;
  113. this.observer.disconnect();
  114. }
  115. /** Reenables the observation of the child elements */
  116. public enable() {
  117. if(this.enabled)
  118. return;
  119. this.enabled = true;
  120. this.observer.observe(this.baseElement, this.observerOptions);
  121. }
  122. /** Returns whether the observation of the child elements is currently enabled */
  123. public isEnabled() {
  124. return this.enabled;
  125. }
  126. /** Removes all listeners that have been registered with {@linkcode addListener()} */
  127. public clearListeners() {
  128. this.listenerMap.clear();
  129. }
  130. /**
  131. * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
  132. * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
  133. */
  134. public removeAllListeners(selector: string) {
  135. return this.listenerMap.delete(selector);
  136. }
  137. /**
  138. * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
  139. * @returns Returns true when the listener was found and removed, false otherwise
  140. */
  141. public removeListener(selector: string, options: SelectorListenerOptions) {
  142. const listeners = this.listenerMap.get(selector);
  143. if(!listeners)
  144. return false;
  145. const index = listeners.indexOf(options);
  146. if(index > -1) {
  147. listeners.splice(index, 1);
  148. return true;
  149. }
  150. return false;
  151. }
  152. /** Returns all listeners that have been registered with {@linkcode addListener()} */
  153. public getAllListeners() {
  154. return this.listenerMap;
  155. }
  156. /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
  157. public getListeners(selector: string) {
  158. return this.listenerMap.get(selector);
  159. }
  160. }