SelectorObserver.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  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 = {
  22. /** If set, applies this debounce in milliseconds to all listeners that don't have their own debounce set */
  23. defaultDebounce?: number;
  24. /** Whether to disable the observer when no listeners are present - default is true */
  25. disableOnNoListeners?: boolean;
  26. /** Whether to ensure the observer is enabled when a new listener is added - default is true */
  27. enableOnAddListener?: boolean;
  28. };
  29. export type SelectorObserverConstructorOptions = MutationObserverInit & SelectorObserverOptions;
  30. /** Observes the children of the given element for changes */
  31. export class SelectorObserver {
  32. private enabled = false;
  33. private baseElement: Element | string;
  34. private observer: MutationObserver;
  35. private observerOptions: MutationObserverInit;
  36. private customOptions: SelectorObserverOptions;
  37. private listenerMap: Map<string, SelectorListenerOptions[]>;
  38. /**
  39. * Creates a new SelectorObserver that will observe the children of the given base element selector for changes (only creation and deletion of elements by default)
  40. * @param baseElementSelector The selector of the element to observe
  41. * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
  42. */
  43. constructor(baseElementSelector: string, options?: SelectorObserverConstructorOptions)
  44. /**
  45. * Creates a new SelectorObserver that will observe the children of the given base element for changes (only creation and deletion of elements by default)
  46. * @param baseElement The element to observe
  47. * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
  48. */
  49. constructor(baseElement: Element, options?: SelectorObserverConstructorOptions)
  50. constructor(baseElement: Element | string, options: SelectorObserverConstructorOptions = {}) {
  51. this.baseElement = baseElement;
  52. this.listenerMap = new Map<string, SelectorListenerOptions[]>();
  53. // if the arrow func isn't there, `this` will be undefined in the callback
  54. this.observer = new MutationObserver(() => this.checkAllSelectors());
  55. const {
  56. defaultDebounce,
  57. disableOnNoListeners,
  58. enableOnAddListener,
  59. ...observerOptions
  60. } = options;
  61. this.observerOptions = {
  62. childList: true,
  63. subtree: true,
  64. ...observerOptions,
  65. };
  66. this.customOptions = {
  67. defaultDebounce: defaultDebounce ?? 0,
  68. disableOnNoListeners: disableOnNoListeners ?? false,
  69. enableOnAddListener: enableOnAddListener ?? true,
  70. };
  71. }
  72. private checkAllSelectors() {
  73. for(const [selector, listeners] of this.listenerMap.entries())
  74. this.checkSelector(selector, listeners);
  75. }
  76. private checkSelector(selector: string, listeners: SelectorListenerOptions[]) {
  77. if(!this.enabled)
  78. return;
  79. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  80. if(!baseElement)
  81. return;
  82. const all = listeners.some(listener => listener.all);
  83. const one = listeners.some(listener => !listener.all);
  84. const allElements = all ? baseElement.querySelectorAll<HTMLElement>(selector) : null;
  85. const oneElement = one ? baseElement.querySelector<HTMLElement>(selector) : null;
  86. for(const options of listeners) {
  87. if(options.all) {
  88. if(allElements && allElements.length > 0) {
  89. options.listener(allElements);
  90. if(!options.continuous)
  91. this.removeListener(selector, options);
  92. }
  93. }
  94. else {
  95. if(oneElement) {
  96. options.listener(oneElement);
  97. if(!options.continuous)
  98. this.removeListener(selector, options);
  99. }
  100. }
  101. if(this.listenerMap.get(selector)?.length === 0)
  102. this.listenerMap.delete(selector);
  103. if(this.listenerMap.size === 0 && this.customOptions.disableOnNoListeners)
  104. this.disable();
  105. }
  106. }
  107. private debounce<TArgs>(func: (...args: TArgs[]) => void, time: number): (...args: TArgs[]) => void {
  108. let timeout: number;
  109. return function(...args: TArgs[]) {
  110. clearTimeout(timeout);
  111. timeout = setTimeout(() => func.apply(this, args), time) as unknown as number;
  112. };
  113. }
  114. /**
  115. * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
  116. * @param selector The selector to observe
  117. * @param options Options for the selector observation
  118. * @param options.listener Gets called whenever the selector was found in the DOM
  119. * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
  120. * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
  121. * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
  122. */
  123. public addListener<TElem extends Element = HTMLElement>(selector: string, options: SelectorListenerOptions<TElem>) {
  124. options = { all: false, continuous: false, debounce: 0, ...options };
  125. if((options.debounce && options.debounce > 0) || (this.customOptions.defaultDebounce && this.customOptions.defaultDebounce > 0)) {
  126. options.listener = this.debounce(
  127. options.listener as ((arg: NodeListOf<Element> | Element) => void),
  128. (options.debounce || this.customOptions.defaultDebounce)!,
  129. );
  130. }
  131. if(this.listenerMap.has(selector))
  132. this.listenerMap.get(selector)!.push(options as SelectorListenerOptions<Element>);
  133. else
  134. this.listenerMap.set(selector, [options as SelectorListenerOptions<Element>]);
  135. if(this.enabled === false && this.customOptions.enableOnAddListener)
  136. this.enable();
  137. this.checkSelector(selector, [options as SelectorListenerOptions<Element>]);
  138. }
  139. /** Disables the observation of the child elements */
  140. public disable() {
  141. if(!this.enabled)
  142. return;
  143. this.enabled = false;
  144. this.observer.disconnect();
  145. }
  146. /**
  147. * Enables or reenables the observation of the child elements.
  148. * @param immediatelyCheckSelectors Whether to immediately check if all previously registered selectors exist (default is true)
  149. * @returns Returns true when the observation was enabled, false otherwise (e.g. when the base element wasn't found)
  150. */
  151. public enable(immediatelyCheckSelectors = true) {
  152. const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
  153. if(this.enabled || !baseElement)
  154. return false;
  155. this.enabled = true;
  156. this.observer.observe(baseElement, this.observerOptions);
  157. if(immediatelyCheckSelectors)
  158. this.checkAllSelectors();
  159. return true;
  160. }
  161. /** Returns whether the observation of the child elements is currently enabled */
  162. public isEnabled() {
  163. return this.enabled;
  164. }
  165. /** Removes all listeners that have been registered with {@linkcode addListener()} */
  166. public clearListeners() {
  167. this.listenerMap.clear();
  168. }
  169. /**
  170. * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
  171. * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
  172. */
  173. public removeAllListeners(selector: string) {
  174. return this.listenerMap.delete(selector);
  175. }
  176. /**
  177. * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
  178. * @returns Returns true when the listener was found and removed, false otherwise
  179. */
  180. public removeListener(selector: string, options: SelectorListenerOptions) {
  181. const listeners = this.listenerMap.get(selector);
  182. if(!listeners)
  183. return false;
  184. const index = listeners.indexOf(options);
  185. if(index > -1) {
  186. listeners.splice(index, 1);
  187. return true;
  188. }
  189. return false;
  190. }
  191. /** Returns all listeners that have been registered with {@linkcode addListener()} */
  192. public getAllListeners() {
  193. return this.listenerMap;
  194. }
  195. /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
  196. public getListeners(selector: string) {
  197. return this.listenerMap.get(selector);
  198. }
  199. }