import { IconProperties, LegacyIconProperties, Player } from '@lordicon/web'; import { IconData, Trigger, TriggerConstructor } from './interfaces'; import { parseColors, parseState, parseStroke, } from './parsers'; /** * Defines the available strategies for loading icons in the custom element. * - 'lazy': Loads the icon when it enters the viewport. * - 'interaction': Loads the icon after a user interaction. * - 'delay': Loads the icon after a specified delay. */ export type LoadingType = 'lazy' | 'interaction' | 'delay'; /** * List of DOM events that can trigger icon loading when using the 'interaction' loading strategy. */ const INTERACTION_LOADING_EVENTS = ['click', 'mouseenter', 'mouseleave']; /** * Checks if the browser supports constructable stylesheets for better style encapsulation. * See: https://developers.google.com/web/updates/2019/02/constructable-stylesheets */ const SUPPORTS_ADOPTING_STYLE_SHEETS = 'adoptedStyleSheets' in Document.prototype && 'replace' in CSSStyleSheet.prototype; /** * Main CSS styles for the custom element, ensuring proper layout, sizing, and color handling. */ const ELEMENT_STYLE = ` :host { position: relative; display: inline-block; width: 32px; height: 32px; transform: translate3d(0px, 0px, 0px); } :host(.current-color) svg path[fill] { fill: currentColor; } :host(.current-color) svg path[stroke] { stroke: currentColor; } svg { position: absolute; pointer-events: none; display: block; transform: unset!important; } ::slotted(*) { position: absolute; left: 0; top: 0; width: 100%; height: 100%; } `; /** * Holds a reference to the shared stylesheet instance if constructable stylesheets are supported. */ let styleSheet: CSSStyleSheet | null = null; /** * Enumerates all supported attributes for the custom element. */ type SUPPORTED_ATTRIBUTES = | 'colors' | 'src' | 'state' | 'trigger' | 'loading' | 'target' | 'stroke' | 'speed'; /** * List of attributes observed by the custom element for changes. */ const OBSERVED_ATTRIBUTES: SUPPORTED_ATTRIBUTES[] = [ 'colors', 'src', 'state', 'trigger', 'loading', 'target', 'stroke', 'speed', ]; /** * The Lordicon custom element class. * Handles icon loading, rendering, customization, and interaction logic. */ export class Element extends HTMLElement { protected static _definedTriggers: Map = new Map(); /** * Returns the current version of the element. */ static get version() { return '__BUILD_VERSION__'; } /** * Returns the list of attributes to observe for changes. */ static get observedAttributes() { return OBSERVED_ATTRIBUTES; } /** * Registers a custom trigger for icon interaction. * Triggers define how the icon responds to user actions. * @param name The name of the trigger. * @param triggerClass The trigger class constructor. */ static defineTrigger(name: string, triggerClass: TriggerConstructor) { Element._definedTriggers.set(name, triggerClass); } protected _root?: ShadowRoot; protected _isConnected: boolean = false; protected _ready: boolean = false; protected _assignedIconData?: IconData; protected _loadedIconData?: IconData; protected _triggerInstance?: Trigger; protected _playerInstance?: Player; protected _animationContainer?: HTMLElement; /** * Stores a callback for deferred icon loading, used by lazy/interation/delay strategies. */ delayedLoading: ((cancel?: boolean) => void) | null = null; /** * Handles changes to observed attributes and delegates to the appropriate handler. * @param name The attribute name. * @param oldValue The previous value. * @param newValue The new value. */ protected attributeChangedCallback( name: SUPPORTED_ATTRIBUTES, _oldValue: any, _newValue: any ) { this[`${name}Changed`].call(this); } /** * Called when the element is added to the DOM. * Sets up shadow DOM, styles, and loading strategy. */ protected connectedCallback() { // Create elements only once. if (!this._root) { this.createElements(); } if (this.loading === 'lazy') { // Lazy loading: load icon when it enters the viewport. let intersectionObserver: IntersectionObserver | undefined = undefined; this.delayedLoading = (cancel?: boolean) => { intersectionObserver!.unobserve(this); intersectionObserver = undefined; this.delayedLoading = null; if (!cancel) { this.createPlayer(); } }; const callback: IntersectionObserverCallback = (entries, _observer) => { entries.forEach(entry => { if (entry.isIntersecting && intersectionObserver) { if (this.delayedLoading) { this.delayedLoading(); } } }); }; intersectionObserver = new IntersectionObserver(callback); intersectionObserver.observe(this); } else if (this.loading === 'interaction') { // Interaction loading: load icon after user interaction. let interactionEvent: string | undefined = undefined; this.delayedLoading = (cancel?: boolean) => { for (const eventName of INTERACTION_LOADING_EVENTS) { (targetElement || this).removeEventListener(eventName, interactionCallback); } this.delayedLoading = null; if (!cancel) { this.createPlayer().then(() => { if (interactionEvent) { (targetElement || this).dispatchEvent(new Event(interactionEvent)); } }); } }; const targetElement = this.target ? this.findTarget(this.target) : null; let interactionCallback: (this: Element, event: Event) => void = (event: Event) => { const eventName = event?.type; if (!interactionEvent) { interactionEvent = eventName; if (this.delayedLoading) { this.delayedLoading(); } } else { interactionEvent = eventName; } } interactionCallback = interactionCallback.bind(this); // Attach event listeners for all supported interaction events. for (const eventName of INTERACTION_LOADING_EVENTS) { (targetElement || this).addEventListener(eventName, interactionCallback); } } else if (this.loading === 'delay') { // Delay loading: load icon after a specified timeout. this.delayedLoading = (cancel?: boolean) => { this.delayedLoading = null; if (!cancel) { this.createPlayer(); } }; // Get the delay duration from the attribute or use a default value. const delay = this.hasAttribute('loading-delay') ? +this.getAttribute('loading-delay')! : 0; // Delay loading setTimeout(() => { if (this.delayedLoading) { this.delayedLoading(); } }, delay); } else { this.createPlayer(); } this._isConnected = true; } /** * Called when the element is removed from the DOM. * Cleans up any resources and event listeners. */ protected disconnectedCallback() { // Cancel any pending loading. if (this.delayedLoading) { this.delayedLoading(true); } // Destroy player and trigger instances. this.destroyPlayer(); this._isConnected = false; } /** * Finds a target element by traversing up the DOM tree. * It first attempts to find the target using `closest()`. If that fails, * it falls back to a method that can traverse across Shadow DOM boundaries. * @param selector The CSS selector for the target element. * @returns The found HTMLElement or null. */ protected findTarget(selector: string): HTMLElement | null { // First, try the simple, fast `closest()` method. const closestTarget = this.closest(selector); if (closestTarget) { return closestTarget; } // If `closest()` fails, it might be because the target is outside this shadow DOM. // Fallback to a method that can cross shadow boundaries. const root = this.getRootNode(); if (root instanceof ShadowRoot && root.host) { return this.findTargetAcrossShadowBoundaries(root.host, selector); } return null; } /** * Helper method to find a target by traversing up from a starting element, * crossing shadow boundaries if necessary. * @param startElement The element to start searching from. * @param selector The CSS selector for the target element. * @returns The found HTMLElement or null. */ private findTargetAcrossShadowBoundaries(startElement: globalThis.Element | null, selector: string): HTMLElement | null { let current: Node | null = startElement; while (current) { // Check if the current node is an element and matches the selector if (current.nodeType === Node.ELEMENT_NODE && (current as globalThis.Element).matches(selector)) { return current as HTMLElement; } // Move up to the parent node if (current.parentNode) { current = current.parentNode; } else { // If there's no parentNode, we might be at a shadow root. // Get the host of the shadow root to continue traversal. const root = current.getRootNode(); if (root instanceof ShadowRoot) { current = root.host; } else { // We've reached the top of the main document break; } } } return null; } /** * Creates the shadow DOM structure and attaches styles and slots. */ protected createElements() { // Attach shadow root. this._root = this.attachShadow({ mode: 'open' }); // Attach styles (using constructable stylesheet if supported). if (SUPPORTS_ADOPTING_STYLE_SHEETS) { if (!styleSheet) { styleSheet = new CSSStyleSheet(); styleSheet.replaceSync(ELEMENT_STYLE); } this._root.adoptedStyleSheets = [styleSheet]; } else { const style = document.createElement('style'); style.innerHTML = ELEMENT_STYLE; this._root.appendChild(style); } // Create main container for the animation const container = document.createElement('div'); container.classList.add('body'); this._root.appendChild(container); // Store reference to the animation container this._animationContainer = container; // Create slot for light DOM content. this.createSlot(); } /** * Creates a slot element inside the shadow DOM for projecting light DOM content. */ protected createSlot() { const slot = document.createElement('slot'); this._root!.appendChild(slot); } /** * Destroys the slot element from the shadow DOM. */ protected destroySlot() { const slot = this._root!.querySelector('slot'); if (slot) { this._root!.removeChild(slot); } } /** * Factory method for creating a Player instance. * Can be overridden for custom player instantiation. */ protected playerFactory(container: HTMLElement, iconData: IconData, properties: IconProperties & LegacyIconProperties) { return new Player( container, iconData, properties, { autoInit: false, }, ); } /** * Instantiates the Player and sets up dynamic styles, triggers, and event listeners. * Handles asynchronous icon data loading. */ protected async createPlayer(): Promise { // Prevent duplicate player creation during deferred loading. if (this.delayedLoading) { return; } const iconData = await this.loadIconData(); if (!iconData) { return; } // Create the Player instance with parsed properties this._playerInstance = this.playerFactory( this.animationContainer!, iconData, { state: parseState(this.state), stroke: parseStroke(this.stroke), colors: parseColors(this.colors), // legacy properties scale: parseFloat('' + this.getAttribute('scale') || ''), axisX: parseFloat('' + this.getAttribute('axis-x') || ''), axisY: parseFloat('' + this.getAttribute('axis-y') || ''), }, ); // Generate dynamic CSS for custom colors const colors = Object.entries(this._playerInstance!.colors || {}); if (colors.length) { let styleContent = ''; for (const [key, _value] of colors) { styleContent += ` :host(:not(.current-color)) svg path[fill].${key} { fill: var(--lord-icon-${key}, var(--lord-icon-${key}-base, #000)); } :host(:not(.current-color)) svg path[stroke].${key} { stroke: var(--lord-icon-${key}, var(--lord-icon-${key}-base, #000)); } ` } const style = document.createElement('style'); style.innerHTML = styleContent; this.animationContainer!.appendChild(style); } // Initialize the Player this._playerInstance.init(); // Set up event listeners for Player lifecycle events this._playerInstance.addEventListener('ready', () => { if (this._triggerInstance && this._triggerInstance.onReady) { this._triggerInstance.onReady(); } }); this._playerInstance.addEventListener('refresh', () => { this.refresh(); if (this._triggerInstance && this._triggerInstance.onRefresh) { this._triggerInstance.onRefresh(); } }); this._playerInstance.addEventListener('complete', () => { if (this._triggerInstance && this._triggerInstance.onComplete) { this._triggerInstance.onComplete(); } }); this._playerInstance.addEventListener('frame', () => { if (this._triggerInstance && this._triggerInstance.onFrame) { this._triggerInstance.onFrame(); } }); // Synchronize CSS variables and refresh state. this.refresh(); // Set up the trigger if defined. this.triggerChanged(); // Wait for the Player to be ready before marking the element as ready await new Promise((resolve, _reject) => { if (this._playerInstance!.ready) { resolve(); } else { this._playerInstance!.addEventListener('ready', resolve); } }); // Remove the slot for light DOM content as the icon is now ready. this.destroySlot(); this._ready = true; // Dispatch a 'ready' event for external listeners. this.dispatchEvent(new CustomEvent('ready')); } /** * Destroys the Player and Trigger instances, cleaning up all resources. * Called when the icon data changes or the element is disconnected. */ protected destroyPlayer() { // Mark as not ready. this._ready = false; // Clear loaded icon data. this._loadedIconData = undefined; // Disconnect and remove trigger instance. if (this._triggerInstance) { if (this._triggerInstance.onDisconnected) { this._triggerInstance.onDisconnected(); } this._triggerInstance = undefined; } // Destroy and remove Player instance. if (this._playerInstance) { this._playerInstance.destroy(); this._playerInstance = undefined; // Recreate the slot for light DOM content. this.createSlot(); } } /** * Loads icon data from the 'src' attribute or uses the assigned icon data. * Returns the icon data object or undefined if loading fails. */ protected async loadIconData(): Promise { let icon = this.icon; if (!icon && this.src) { const response = await fetch(this.src); this._loadedIconData = icon = await response.json(); } return icon; } /** * Synchronizes the element's state with the Player instance. * Updates CSS variables and other dynamic properties. */ protected refresh() { this.movePaletteToCssVariables(); } /** * Updates CSS variables for icon colors based on the Player's palette. * CSS variables take precedence over other color assignments. */ protected movePaletteToCssVariables() { for (const [key, value] of Object.entries(this._playerInstance!.colors || {})) { if (value) { this.animationContainer!.style.setProperty(`--lord-icon-${key}-base`, value); } else { this.animationContainer!.style.removeProperty(`--lord-icon-${key}-base`); } } } /** * Called when the 'target' attribute changes. * Reloads the trigger to use the new target element. */ protected targetChanged() { this.triggerChanged(); } /** * Called when the 'loading' attribute changes. */ protected loadingChanged() { } /** * Called when the 'trigger' attribute changes. * Disconnects the old trigger and instantiates the new one. */ protected triggerChanged(): void { if (this._triggerInstance) { if (this._triggerInstance.onDisconnected) { this._triggerInstance.onDisconnected(); } this._triggerInstance = undefined; this._playerInstance?.pause(); } if (!this.trigger || !this._playerInstance) { return; } const TriggerClass = Element._definedTriggers.get(this.trigger); if (!TriggerClass) { throw new Error(`Can't use unregistered trigger: '${this.trigger}'!`); } const targetElement = this.target ? this.findTarget(this.target) : null; this._triggerInstance = new TriggerClass( this._playerInstance, this, targetElement || this, ); if (this._triggerInstance.onConnected) { this._triggerInstance.onConnected(); } if (this._playerInstance.ready && this._triggerInstance.onReady) { this._triggerInstance.onReady(); } } /** * Called when the 'colors' attribute changes. * Updates the Player's color palette. */ protected colorsChanged() { if (!this._playerInstance) { return; } this._playerInstance.colors = parseColors(this.colors) || null; } /** * Called when the 'stroke' attribute changes. * Updates the Player's stroke width. */ protected strokeChanged() { if (!this._playerInstance) { return; } this._playerInstance.stroke = parseStroke(this.stroke) || null; } /** * Called when the 'speed' attribute changes. * Updates the Player's animation speed. */ protected speedChanged() { if (!this._playerInstance) { return; } const speed = this.getAttribute('speed'); if (speed) { const parsedSpeed = parseFloat(speed); if (!isNaN(parsedSpeed)) { this._playerInstance.speed = parsedSpeed; } else { this._playerInstance.speed = 1; } } else { this._playerInstance.speed = 1; } } /** * Called when the 'state' attribute changes. * Updates the Player's animation state. */ protected stateChanged() { if (!this._playerInstance) { return; } this._playerInstance.state = this.state; // Notify the trigger instance about the state change. this._triggerInstance?.onState?.(); } /** * Called when the 'icon' attribute changes. * Reloads the Player with the new icon. */ protected iconChanged() { if (!this._isConnected) { return; } this.destroyPlayer(); this.createPlayer(); } /** * Called when the 'src' attribute changes. * Reloads the Player with the new icon source. */ protected srcChanged() { if (!this._isConnected) { return; } this.destroyPlayer(); this.createPlayer(); } /** * Directly assigns icon data to the element. * Triggers a reload if the data changes. */ set icon(value: IconData | undefined) { if (value !== this._assignedIconData) { this._assignedIconData = value; // Clear loaded icon data to avoid conflicts. this._loadedIconData = undefined; this.iconChanged(); } } /** * Gets the currently assigned or loaded icon data. */ get icon(): IconData | undefined { return this._assignedIconData || this._loadedIconData; } /** * Sets the 'src' attribute for loading icon data from a URL. */ set src(value: string | null) { if (value) { this.setAttribute('src', value); } else { this.removeAttribute('src'); } } /** * Gets the current 'src' attribute value. */ get src(): string | null { return this.getAttribute('src'); } /** * Sets the animation state for the icon. * You can check available states from the player instance. */ set state(value: string | null) { if (value) { this.setAttribute('state', value); } else { this.removeAttribute('state'); } } /** * Gets the current animation state. */ get state(): string | null { return this.getAttribute('state'); } /** * Sets the color palette for the icon. * Accepts a comma-separated string, e.g. 'primary:#fdd394,secondary:#03a9f4'. */ set colors(value: string | null) { if (value) { this.setAttribute('colors', value); } else { this.removeAttribute('colors'); } } /** * Gets the current color palette string. */ get colors(): string | null { return this.getAttribute('colors'); } /** * Sets the trigger name for icon interaction. * The trigger must be registered beforehand. */ set trigger(value: string | null) { if (value) { this.setAttribute('trigger', value); } else { this.removeAttribute('trigger'); } } /** * Gets the current trigger name. */ get trigger(): string | null { return this.getAttribute('trigger'); } /** * Sets the loading strategy for the icon. * Options: 'lazy', 'interaction', or 'delay'. */ set loading(value: LoadingType | null) { if (value) { this.setAttribute('loading', value); } else { this.removeAttribute('loading'); } } /** * Gets the current loading strategy. */ get loading(): LoadingType | null { if (this.getAttribute('loading')) { const param = this.getAttribute('loading')!.toLowerCase(); if (param === 'lazy') { return 'lazy'; } else if (param === 'interaction') { return 'interaction'; } else if (param === 'delay') { return 'delay'; } } return null; } /** * Sets the CSS selector for the target element used for event listening. */ set target(value: string | null) { if (value) { this.setAttribute('target', value); } else { this.removeAttribute('target'); } } /** * Gets the current target selector. */ get target(): string | null { return this.getAttribute('target'); } /** * Sets the stroke style for the icon (e.g., 1, 2, 3, light, regular, bold). */ set stroke(value: string | null) { if (value) { this.setAttribute('stroke', value); } else { this.removeAttribute('stroke'); } } /** * Gets the current stroke width. */ get stroke(): string | null { if (this.hasAttribute('stroke')) { return this.getAttribute('stroke'); } return null; } /** * Sets the animation speed for the icon. * Accepts a number or a string that can be parsed to a number. */ set speed(value: string | number | null) { if (value) { this.setAttribute('speed', String(value)); } else { this.removeAttribute('speed'); } } /** * Gets the current animation speed. * Returns 1 if not set or invalid. */ get speed(): number { const speed = this.getAttribute('speed'); if (speed) { const parsedSpeed = parseFloat(speed); if (!isNaN(parsedSpeed)) { return parsedSpeed; } } return 1; // Default speed } /** * Returns true if the element is fully initialized and ready for interaction. * You can listen for the 'ready' event to detect readiness. */ get ready() { return this._ready; } /** * Returns a promise that resolves when the element is ready. * Useful for awaiting initialization in external code. */ get readyPromise(): Promise { if (this._ready) { return Promise.resolve(); } return new Promise(resolve => { this.addEventListener('ready', () => { resolve(); }, { once: true }); }); } /** * Returns the Player instance associated with this element. */ get playerInstance(): Player | undefined { return this._playerInstance; } /** * Returns the Trigger instance associated with this element. */ get triggerInstance(): Trigger | undefined { return this._triggerInstance; } /** * Returns the animation container element inside the shadow DOM. */ get animationContainer(): HTMLElement | undefined { return this._animationContainer; } }