import { PluginContainer, PluginModule } from '@arcmantle/injector'; import { traverseDomUp } from '@arcmantle/library/dom'; import type { Writeable } from '@arcmantle/library/types'; import { effect } from '@preact/signals-core'; import { render, type RenderOptions, type RootPart } from 'lit-html'; import { type CSSStyle, getInheritanceFlatStyles } from '../shared/css.ts'; import type { ReactiveController, ReactiveControllerHost } from '../shared/reactive-controller.ts'; import type { AdapterMetadata } from './types.ts'; // Ensure metadata is enabled. TypeScript does not polyfill // Symbol.metadata, so we must ensure that it exists. (Symbol as { metadata: symbol; }).metadata ??= Symbol('metadata'); export class AdapterBase extends HTMLElement { declare ['constructor']: typeof AdapterBase; protected static adapter: typeof AdapterElement; static shadowRootOptions: ShadowRootInit = { mode: 'open' }; constructor() { super(); const base = this.constructor as any as typeof AdapterBase; const metadata = base.adapter.metadata; // We need to set up the adapter and the properties. for (const prop of Object.values(metadata.propertyMetadata)) { Object.defineProperty(this, prop.propName, { get(this: AdapterBase) { return this.adapter?.[prop.propName as keyof AdapterElement]; }, set(this: AdapterBase, value) { if (!this.adapterInitialized) prop.initialValue = value; else (this.adapter as Record)[prop.propName] = value; }, }); } if (metadata.observedAttributes) this.__attrCtrl = new MutationObserver(this.observeAttributes.bind(this)); } readonly renderRoot: DocumentFragment | HTMLElement; readonly adapter: AdapterElement; adapterInitialized: boolean = false; pluginContainer: PluginContainer; dynamicStylesheet?: CSSStyleSheet; dynamicCssResultCache?: string; protected __attrCtrl: MutationObserver | undefined; protected connectedCallback(): void { // Can be used to identify a wc from a regular HTMLElement. this.setAttribute('data-wc', ''); (this as Writeable).renderRoot ??= this.createRenderRoot(); this.connectAdapter(); } createRenderRoot(): HTMLElement | DocumentFragment { const renderRoot = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); (this as Writeable).renderRoot = this.shadowRoot!; const base = this.constructor as any as typeof AdapterBase; const metadata = base.adapter.metadata; metadata.styles = getInheritanceFlatStyles('styles', base.adapter); const styles = [ ...metadata.styles ]; if ('renderStyles' in base.adapter.prototype) { this.dynamicStylesheet ??= new CSSStyleSheet(); this.dynamicCssResultCache = ''; if (!styles.includes(this.dynamicStylesheet)) styles.push(this.dynamicStylesheet); } renderRoot.adoptedStyleSheets = styles; return renderRoot; } protected disconnectedCallback(): void { this.disconnectAdapter(); } protected resolveContainer(): PluginContainer | Promise { let container = traverseDomUp(this, (node, stop) => { if (!(node instanceof AdapterBase)) return; const base = node.constructor as any as typeof AdapterBase; const metadata = base.adapter.metadata; const container = metadata.pluginContainer; if (container instanceof PluginContainer) stop(container); }); if (!container) { const base = this.constructor as any as typeof AdapterBase; const metadata = base.adapter.metadata; metadata.pluginContainer = container = new PluginContainer(); console.warn( 'No plugin container found in the DOM, ' + 'creating a new one for element:', this.tagName, ); } return container; } protected async connectAdapter(): Promise { const base = this.constructor as any as typeof AdapterBase; const metadata = base.adapter.metadata; // One time setup of the adapter. if (!this.adapterInitialized) { // Resolve the plugin container. this.pluginContainer = await this.resolveContainer(); base.adapter.modules.forEach(module => this.pluginContainer.load(module)); (this as Writeable).adapter = new base.adapter(new WeakRef(this)); this.adapterInitialized = true; // Set the props that were assigned before the adapter was initialized. // This is done after the initialized flag as the setter has a check to only // set the value if the adapter is initialized. for (const prop of Object.values(metadata.propertyMetadata)) (this as Record)[prop.propName] = prop.initialValue; // Set the initial values of the attribute properties. metadata.observedAttributes?.forEach(attr => { if (!this.hasAttribute(attr)) return; const value = this.getAttribute(attr) ?? ''; this.attributeChanged(attr, value); }); } // Observe the attributes for changes. this.__attrCtrl?.observe(this, { attributes: true, attributeFilter: metadata.observedAttributes, }); // If this is the first time the adapter has connected, // call the firstConnected method. if (!this.adapter.hasConnected) this.adapter.firstConnected(); // Call the connected method on the adapter. this.adapter.connected(); this.adapter.updateComplete.then(() => { setTimeout(() => this.adapter.afterConnected()); }); } protected disconnectAdapter(): void { // First clean up anything that may react to changes. this.__attrCtrl?.disconnect(); // Then clean up the adapter. this.adapter?.disconnected(); } protected observeAttributes(entries: MutationRecord[]): void { entries.forEach(entry => { const name = entry.attributeName; if (!name) return; const target = entry.target as HTMLElement; if (!(target instanceof HTMLElement)) return; this.attributeChanged( name, target.getAttribute(entry.attributeName)!, ); }); }; protected attributeChanged(name: string, value: string): void { const base = this.constructor as any as typeof AdapterBase; const metadata = base.adapter.metadata; const propMeta = metadata.propertyMetadata?.[name]; if (!propMeta) return void console.warn(`Unknown attribute: ${ name }`); const adapter = this.adapter; if (!adapter) return; const type = propMeta.type; let convertedValue: any; if (type === Boolean) convertedValue = value === 'true' || value === ''; else if (type === String) convertedValue = value || ''; else if (type === Number) convertedValue = Number(value); else if (type === Object) convertedValue = JSON.parse(value); convertedValue = convertedValue ?? undefined; if (adapter[propMeta.propName as keyof AdapterElement] !== convertedValue) (adapter as any)[propMeta.propName] = convertedValue; } } export class AdapterElement implements ReactiveControllerHost { declare ['constructor']: typeof AdapterElement; constructor(element: WeakRef) { this.__element = element; } static readonly tagName: string; static readonly styles: CSSStyle; static readonly adapterBase: typeof AdapterBase = AdapterBase; static register(): void { if (globalThis.customElements.get(this.tagName)) return; // We create a new class that extends the base element class. // The newly created class sets the this class as the adapter. const cls = this.createElementClass(this); if (!this.tagName) throw new Error('AdapterElement must have a static tagName property.'); if (!globalThis.customElements.get(this.tagName)) globalThis.customElements.define(this.tagName, cls); } static createElementClass(adapterClass: typeof AdapterElement): typeof AdapterBase { const cls = class extends this.adapterBase { protected static override adapter = adapterClass; }; Object.defineProperty(cls, 'name', { value: adapterClass.tagName.replaceAll('-', '_'), }); return cls; } declare static [Symbol.metadata]: AdapterMetadata; static get metadata(): AdapterMetadata { const metadata = (this[Symbol.metadata] ??= {} as any); metadata.observedAttributes ??= []; metadata.propertyMetadata ??= {}; metadata.signalProps ??= []; metadata.changedProps ??= new Map(); metadata.previousProps ??= new Map(); return metadata; } static readonly modules: readonly PluginModule[] = []; private __element: WeakRef; private __unsubEffect?: () => void; private __controllers: Set = new Set(); private __updatePromise: Promise = Promise.resolve(true); private __eventListeners?: Map>; protected readonly renderOptions: RenderOptions = { host: this }; hasConnected = false; hasUpdated = false; isUpdatePending = false; childPart: RootPart | undefined = undefined; get updateComplete(): Promise { return this.getUpdateComplete(); } protected getUpdateComplete(): Promise { return this.__updatePromise; } get element(): AdapterBase { const element = this.__element.deref(); if (!element) throw new Error('Element reference has been lost...'); return element; } //#region component-lifecycle /** Called first time this instance of the element is connected to the DOM. */ firstConnected(): void { this.hasConnected = true; } /** Called every time this instance of the element is connected to the DOM. */ connected(): void { this.requestUpdate(); for (const controller of this.__controllers) controller.hostConnected?.(); this.childPart?.setConnected(true); } /** Called after a setTimeout of 0 after the render method. */ afterConnected(): void {} disconnected(): void { this.childPart?.setConnected(false); this.__unsubEffect?.(); this.__unsubEffect = undefined; this.__eventListeners?.forEach((listeners, type) => { for (const { listener, options } of listeners) this.removeEventListener(type, listener, options); }); this.__eventListeners?.clear(); for (const controller of this.__controllers) controller.hostDisconnected?.(); } protected beforeUpdate(changedProps: Map): void {} protected update(changedProps: Map): void { const value = this.render(); if (!this.hasUpdated) this.renderOptions.isConnected = this.element.isConnected; this.__markUpdated(); this.childPart = render(value, this.element.renderRoot, this.renderOptions); if (this.renderStyles) this.updateRenderStyles(); } protected render(): unknown { return; }; protected updateRenderStyles(): void { const styles = this.renderStyles?.('', dynamicCss); if (styles === undefined) return; const element = this.element; if (styles !== element.dynamicCssResultCache) { element.dynamicCssResultCache = styles; element.dynamicStylesheet!.replaceSync(styles); } } protected renderStyles?(styles: string, css: DynamicCSS,): string | void; protected afterUpdate(changedProps: Map): void {} protected afterFirstUpdate(changedProps: Map): void {} //#endregion component-lifecycle addController(controller: ReactiveController): void { this.__controllers.add(controller); if (this.element.renderRoot !== undefined && this.element.isConnected) controller.hostConnected?.(); } removeController(controller: ReactiveController): void { this.__controllers.delete(controller); } requestUpdate(): void { if (this.isUpdatePending) return; this.__updatePromise = this.enqueueUpdate(); } protected async enqueueUpdate(): Promise { this.isUpdatePending = true; try { // Ensure any previous update has resolved before updating. // This `await` also ensures that property changes are batched. await this.__updatePromise; } catch (e) { // Refire any previous errors async so they do not disrupt the update // cycle. Errors are refired so developers have a chance to observe // them, and this can be done by implementing // `window.onunhandledrejection`. Promise.reject(e); } const result = this.scheduleUpdate(); if (result?.then) await result; return !this.isUpdatePending; } protected scheduleUpdate(): void | Promise { const result = this.performUpdate(); return result; } performUpdate(): void { // Abort any update if one is not pending when this is called. // This can happen if `performUpdate` is called early to "flush" the update. if (!this.isUpdatePending) return; this.__unsubEffect?.(); const selfRef = new WeakRef(this); this.__unsubEffect = effect(this.__createUpdateEffect(selfRef)); } protected shouldUpdate(changedProperties: Map): boolean { return true; } protected __didUpdate(changedProperties: Map): void { this.__controllers?.forEach((c) => c.hostUpdated?.()); if (!this.hasUpdated) { this.hasUpdated = true; this.afterFirstUpdate(changedProperties); } this.afterUpdate(changedProperties); } protected __markUpdated(): void { const base = this.constructor; const metadata = base.metadata; metadata.changedProps = new Map(); this.isUpdatePending = false; } // *Important* do not use "this" in the effect function. // This is avoid potential memory leaks by ensuring that the effect // does not hold a reference to the element. // Instead, use a WeakRef to the element. // This allows the effect to be garbage collected when the element is removed. protected __createUpdateEffect(ref: WeakRef) { let nativeUpdate: boolean = true; return (): void => { const self = ref.deref(); if (!self) return console.warn('Element reference lost during update.'); if (!nativeUpdate) return self.requestUpdate(); nativeUpdate = false; const base = self.constructor; const metadata = base.metadata; if (!self.hasUpdated) { // Create renderRoot before first update. This occurs in `connectedCallback` // but is done here to support out of tree calls to `enableUpdating`/`performUpdate`. const element = self.element as Writeable; element.renderRoot ??= element.createRenderRoot(); } for (const prop of metadata.signalProps) { const value = self[prop as keyof typeof self]; const previous = !self.hasUpdated ? undefined : metadata.previousProps.get(prop); if (!metadata.changedProps.has(prop) && previous !== value) { metadata.changedProps.set(prop, previous); metadata.previousProps.set(prop, value); } } let shouldUpdate = false; try { shouldUpdate = self.shouldUpdate(metadata.changedProps); if (shouldUpdate) { self.beforeUpdate(metadata.changedProps); self.__controllers?.forEach((c) => c.hostUpdate?.()); self.update(metadata.changedProps); } else { this.__markUpdated(); } } catch (e) { // Prevent `firstUpdated` and `updated` from running when there's an update exception. shouldUpdate = false; // Ensure element can accept additional updates after an exception. self.__markUpdated(); throw e; } // The update is no longer considered pending and further updates are now allowed. if (shouldUpdate) self.__didUpdate(metadata.changedProps); }; }; //#region consumer-api /** Retrieves a bound value from the dependency injection container. */ get inject(): PluginContainer { const element = this.element; return element.pluginContainer; } query(selector: string): T | undefined { const root = this.element.renderRoot; return root.querySelector(selector) ?? undefined; } queryAll(selector: string): T[] { const root = this.element.renderRoot; if (!root) return []; return [ ...root.querySelectorAll(selector) ]; } //#endregion consumer-api //#region HTMLElement-interfaces get classList(): HTMLElement['classList'] { return this.element.classList; } get querySelector(): HTMLElement['querySelector'] { const element = this.element; return (...args: Parameters) => element.renderRoot.querySelector(...args); } get dispatchEvent(): HTMLElement['dispatchEvent'] { const element = this.element; return element.dispatchEvent.bind(element); } get addEventListener(): HTMLElement['addEventListener'] { return this.__addEventListener.bind(this); } get removeEventListener(): HTMLElement['removeEventListener'] { return this.__removeEventListener.bind(this); } protected __addEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, ): void { this.element.addEventListener(type, listener, options); if (!this.__eventListeners) this.__eventListeners = new Map(); const listeners = this.__eventListeners.get(type) ?? this.__eventListeners.set(type, new Set()).get(type)!; listeners.add({ type, listener, options }); } protected __removeEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions, ): void { this.element.removeEventListener(type, listener, options); const listeners = this.__eventListeners?.get(type); if (!listeners) return; for (const lst of listeners) { const { type: t, listener: l, options: o } = lst; if (t === type && l === listener && o === options) listeners.delete(lst); } } //#endregion HTMLElement-interfaces } export type DynamicCSS = (strings: TemplateStringsArray, ...values: any[]) => string; const dynamicCss: DynamicCSS = (strings, ...values): string => { let result = ''; for (let i = 0; i < strings.length; i++) { result += strings[i]!; const value = values[i]; if (value !== undefined) result += value; } result = result.replaceAll(/\n+/g, ''); return result; };