diff --git a/src/controllers/mutation-controller.ts b/src/controllers/mutation-controller.ts new file mode 100644 index 0000000..9f24358 --- /dev/null +++ b/src/controllers/mutation-controller.ts @@ -0,0 +1,149 @@ +import { type ReactiveController, type ReactiveControllerHost } from "lit"; + +/** + * Controller responsible for observing mutations within a host. + */ +export class MutationController implements ReactiveController { + /** + * Value provided by the callback function, invoked when a mutation occurs. + */ + public value: T | undefined; + + /** + * Determines whether the mutation observer can observe mutations. This is set to `true` when `disconnect` + * was called explicitly, and set to `false` when `observe` is called. + */ + private canObserve = true; + + /** + * Configuration options. + */ + private readonly config?: Configuration; + + /** + * Host this controller is attached to. + */ + private readonly host: Node & ReactiveControllerHost; + + /** + * Determines whether the mutation observer is currently observing the host. + */ + private isObserving = false; + + /** + * Mutation observer monitoring changes. + */ + private readonly observer: MutationObserver; + + /** + * Initializes a new instance of the {@link MutationController} class. + * @param host Host to attach to, and observe. + * @param config Configuration options. + */ + constructor(host: Node & ReactiveControllerHost, config?: Configuration) { + this.host = host; + this.observer = new MutationObserver((mutations: MutationRecord[]) => this.handleChanges(mutations)); + this.config = config; + + this.host.addController(this); + } + + /** + * Disconnects the mutation observer from the host. + */ + public disconnect(): void { + this.canObserve = false; + this.hostDisconnected(); + } + + /** + * @inheritdoc + */ + public hostConnected(): void { + if (this.canObserve) { + this.observe(); + } + } + + /** + * @inheritdoc + */ + public hostDisconnected(): void { + this.observer.disconnect(); + this.isObserving = false; + } + + /** + * @inheritdoc + */ + public hostUpdated(): void { + // Eagerly deliver any changes that happened during update. + const pendingRecords = this.observer.takeRecords(); + if (pendingRecords.length) { + this.handleChanges(pendingRecords); + } + } + + /** + * Observes mutations on the host. When mutations are already being observed, nothing happens. + */ + public observe(): void { + this.canObserve = true; + + if (!this.isObserving) { + const options = Object.assign( + { + attributes: true, + childList: true, + subtree: true, + }, + this.config?.options, + ); + + // Start observing. + this.observer.observe(this.host, options); + this.isObserving = true; + + // Initialize the value. + this.handleChanges([]); + } + } + + /** + * Invokes the callback function, and assigns the result to the controller's `value`. + * @param mutations Mutations that occurred. + */ + private handleChanges(mutations: MutationRecord[]): void { + if (this.config?.callback) { + const newValue = this.config.callback(mutations); + const oldValue = this.value; + + this.value = newValue; + + if (oldValue !== newValue && this.config.changed) { + this.config.changed(); + } + } + + this.host.requestUpdate(); + } +} + +/** + * Configuration options. + */ +type Configuration = { + /** + * Callback function invoked when a mutation occurs. + * @param mutations Mutations that occurred. + * @returns The new value of the mutation controller. + */ + callback(mutations: MutationRecord[]): T; + + changed?(): void; + + /** + * Options provided to the mutation observer. + */ + options?: MutationObserverInit; +}; diff --git a/src/controllers/option-controller.ts b/src/controllers/option-controller.ts new file mode 100644 index 0000000..e186e2b --- /dev/null +++ b/src/controllers/option-controller.ts @@ -0,0 +1,31 @@ +import { type ReactiveControllerHost } from "lit"; + +import { MutationController } from "./mutation-controller.js"; + +/** + * Controller responsible for monitoring options, and option groups, within a host. + */ +export class OptionController extends MutationController<(HTMLOptionElement | HTMLOptGroupElement)[]> { + /** + * Initializes a new instance of the {@link OptionController} class. + * @param host Host to attach to, and observe. + * @param change Function invoked after the value changed. + */ + constructor(host: HTMLElement & ReactiveControllerHost, changed?: () => void) { + super(host, { + callback: () => { + return Array.from(host.querySelectorAll(":scope > option, :scope > sd-optgroup")); + }, + changed: () => { + if (changed) { + changed(); + } + }, + options: { + attributes: true, + childList: true, + subtree: true, + }, + }); + } +} diff --git a/src/mixins/data-sourced.ts b/src/mixins/data-sourced.ts index e3cdbb4..e125ab6 100644 --- a/src/mixins/data-sourced.ts +++ b/src/mixins/data-sourced.ts @@ -3,7 +3,8 @@ import { LitElement } from "lit"; import { property } from "lit/decorators.js"; import { SendToPropertyInspectorEvent } from "stream-deck"; -import { FilteredMutationObserver, i18n, LocalizedMessage, localizedMessagePropertyOptions } from "../core"; +import { OptionController } from "../controllers/option-controller"; +import { i18n, LocalizedMessage, localizedMessagePropertyOptions } from "../core"; import streamDeckClient from "../stream-deck/stream-deck-client"; export type DataSourceResult = DataSourceResultItem[]; @@ -29,18 +30,8 @@ export const DataSourced = >(superClass: T) => class DataSourced extends superClass { private _dataSourceInitialized = false; private _itemsDataSource?: SendToPropertyInspectorEvent; - private _mutationObserver = new FilteredMutationObserver(["optgroup", "option"], () => this.refresh()); - /** - * Initializes a new instance of the data source mixin. - * @param args The arguments. - * @constructor - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(...args: any[]) { - super(args); - this._mutationObserver.observe(this); - } + private readonly _optionController = new OptionController(this, () => this.refresh()); /** * When specified, the items will be data sourced from the Stream Deck using the specified `dataSource` as the payload (sub) event. @@ -174,7 +165,7 @@ export const DataSourced = >(superClass: T) => return items; }; - return this._mutationObserver.items.reduce(reducer, []); + return (this._optionController.value ?? []).reduce(reducer, []); } /**