@lordicon/element
Version:
This package offers developers a convenient method for embedding, controlling, and customizing animated icons from Lordicon within web projects.
945 lines (813 loc) • 28.1 kB
text/typescript
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<string, TriggerConstructor> = new Map<string, TriggerConstructor>();
/**
* 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<HTMLElement>(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<void> {
// 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<void>((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<IconData> {
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<void> {
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;
}
}