Skip to content

Commit

Permalink
feat: defaultDebounce & fix undefined props
Browse files Browse the repository at this point in the history
  • Loading branch information
Sv443 committed Nov 12, 2023
1 parent 7f3e05a commit 3ae212a
Show file tree
Hide file tree
Showing 2 changed files with 57 additions and 24 deletions.
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ See the [license file](./LICENSE.txt) for details.
### SelectorObserver
Usage:
```ts
new SelectorObserver(baseElement: Element, options?: MutationObserverInit)
new SelectorObserver(baseElement: Element, options?: SelectorObserverOptions)
```
A class that manages listeners that are called when elements at given selectors are found in the DOM.
Expand All @@ -142,6 +142,8 @@ If you want to observe the entire document, you can pass `document.body`.
The `options` parameter is optional and will be passed to the MutationObserver that is used internally.
The default options are `{ childList: true, subtree: true }` - you may see the [MutationObserver.observe() documentation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options) for more information and a list of options.
For example, if you want to trigger the listeners when certain attributes change, pass `{ attributes: true, attributeFilter: ["class", "data-my-attribute"] }`
Additionally, there are the following extra options:
- `defaultDebounce` - if set to a number, this debounce will be applied to every listener that doesn't have a custom debounce set (defaults to 0)
⚠️ The instances of this class need to be created after the `baseElement` is available in the DOM (at the earliest when using `@run-at document-end` or after `DOMContentLoaded` has fired).
Expand All @@ -151,18 +153,26 @@ For example, if you want to trigger the listeners when certain attributes change
`addListener<TElement = HTMLElement>(selector: string, options: SelectorListenerOptions): void`
Adds a listener (specified in `options.listener`) for the given selector that will be called once the selector exists in the DOM. It will be passed the element(s) that match the selector as the only argument.
The listener will be called immediately if the selector already exists in the DOM.
> `options.listener` is the only required property of the `options` object.
> It is a function that will be called once the selector exists in the DOM.
> It will be passed the found element or NodeList of elements, depending on if `options.all` is set to true or false.
If `options.all` is set to true, querySelectorAll() will be used instead and the listener will be passed a `NodeList` of matching elements.
This will also include elements that were already found in a previous listener call.
If set to false (default), querySelector() will be used and only the first matching element will be returned.
> If `options.all` is set to true, querySelectorAll() will be used instead and the listener will be passed a `NodeList` of matching elements.
> This will also include elements that were already found in a previous listener call.
> If set to false (default), querySelector() will be used and only the first matching element will be returned.
If `options.continuous` is set to true, the listener will not be deregistered after it was called once (defaults to false).
> If `options.continuous` is set to true, the listener will not be deregistered after it was called once (defaults to false).
>
> ⚠️ You should keep usage of this option to a minimum, as it will cause the listener to be called every time the selector is *checked for and found* and this can stack up quite quickly.
> ⚠️ You should try to only use this option on SelectorObserver instances that are scoped really low in the DOM tree to prevent as many selector checks as possible from being triggered.
> ⚠️ I also recommend always setting a debounce time (see constructor or below) if you use this option.
If `options.debounce` is set to a number above 0, the listener will be debounced by that amount of milliseconds (defaults to 0).
E.g. if the debounce time is set to 200 and the selector is found twice within 100ms, only the last call of the listener will be executed.
> If `options.debounce` is set to a number above 0, the listener will be debounced by that amount of milliseconds (defaults to 0).
> E.g. if the debounce time is set to 200 and the selector is found twice within 100ms, only the last call of the listener will be executed.
When using TypeScript, the generic `TElement` can be used to specify the type of the element(s) that the listener will return.
It will default to HTMLElement if left undefined.
> When using TypeScript, the generic `TElement` can be used to specify the type of the element(s) that the listener will return.
> It will default to HTMLElement if left undefined.
<br>
Expand Down Expand Up @@ -231,11 +241,21 @@ document.addEventListener("DOMContentLoaded", () => {
const barObserver = new SelectorObserver(document.body, {
// only check if the following attributes change:
attributeFilter: ["class", "style", "data-whatever"],
// debounce all listeners by 100ms unless specified otherwise:
defaultDebounce: 100,
});

observer.addListener("#my-element", {
listener: (element) => {
console.log("Element attributes changed:", element);
console.log("Element's attributes changed:", element);
},
});

observer.addListener("#my-other-element", {
// set the debounce higher than provided by the defaultDebounce property:
debounce: 250,
listener: (element) => {
console.log("Other element's attributes changed:", element);
},
});

Expand All @@ -250,6 +270,7 @@ document.addEventListener("DOMContentLoaded", () => {
continuous: true, // don't remove the listener after it was called once
debounce: 50, // debounce the listener by 50ms
listener: (elements) => {
// type of `elements` is NodeListOf<HTMLInputElement>
console.log("Input elements found:", elements);
},
});
Expand Down
40 changes: 26 additions & 14 deletions lib/SelectorObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,35 @@ type SelectorOptionsCommon = {
debounce?: number;
};

export type SelectorObserverOptions = MutationObserverInit & {
/** If set, applies this debounce in milliseconds to all listeners that don't have their own debounce set */
defaultDebounce?: number;
};

/** Observes the children of the given element for changes */
export class SelectorObserver {
private enabled = true;
private enabled = false;
private baseElement: Element;
private observer: MutationObserver;
private observerOptions: MutationObserverInit;
private listenerMap = new Map<string, SelectorListenerOptions[]>();
private observerOptions: SelectorObserverOptions;
private listenerMap: Map<string, SelectorListenerOptions[]>;
private readonly dbgId = Math.floor(Math.random() * 1000000);

/**
* Creates a new SelectorObserver that will observe the children of the given base element for changes (only creation and deletion of elements by default)
* @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
* TODO: support passing a selector for the base element to be able to queue listeners before the element is available
*/
constructor(baseElement: Element, observerOptions?: MutationObserverInit) {
constructor(baseElement: Element, options: SelectorObserverOptions = {}) {
this.baseElement = baseElement;

this.observer = new MutationObserver(this.checkSelectors);
this.listenerMap = new Map<string, SelectorListenerOptions[]>();
// if the arrow func isn't there, `this` will be undefined in the callback
this.observer = new MutationObserver(() => this.checkSelectors());
this.observerOptions = {
childList: true,
subtree: true,
...observerOptions,
...options,
};

this.enable();
Expand All @@ -59,19 +68,18 @@ export class SelectorObserver {
const oneElement = one ? this.baseElement.querySelector<HTMLElement>(selector) : null;

for(const options of listeners) {
if(!this.enabled)
return;
if(options.all) {
if(allElements && allElements.length > 0) {
options.listener(allElements);
if(!options.continuous)
this.listenerMap.get(selector)!.splice(this.listenerMap.get(selector)!.indexOf(options), 1);
this.removeListener(selector, options);
}
} else {
}
else {
if(oneElement) {
options.listener(oneElement);
if(!options.continuous)
this.listenerMap.get(selector)!.splice(this.listenerMap.get(selector)!.indexOf(options), 1);
this.removeListener(selector, options);
}
}
if(this.listenerMap.get(selector)?.length === 0)
Expand Down Expand Up @@ -99,8 +107,12 @@ export class SelectorObserver {
*/
public addListener<TElem extends Element = HTMLElement>(selector: string, options: SelectorListenerOptions<TElem>) {
options = { all: false, continuous: false, debounce: 0, ...options };
if(options.debounce && options.debounce > 0)
options.listener = this.debounce(options.listener as ((arg: NodeListOf<Element> | Element) => void), options.debounce);
if((options.debounce && options.debounce > 0) || (this.observerOptions.defaultDebounce && this.observerOptions.defaultDebounce > 0)) {
options.listener = this.debounce(
options.listener as ((arg: NodeListOf<Element> | Element) => void),
(options.debounce || this.observerOptions.defaultDebounce)!,
);
}
if(this.listenerMap.has(selector))
this.listenerMap.get(selector)!.push(options as SelectorListenerOptions<Element>);
else
Expand Down Expand Up @@ -152,7 +164,7 @@ export class SelectorObserver {
if(!listeners)
return false;
const index = listeners.indexOf(options);
if(index !== -1) {
if(index > -1) {
listeners.splice(index, 1);
return true;
}
Expand Down

0 comments on commit 3ae212a

Please sign in to comment.