import {choose} from 'lit/directives/choose.js'; import {classMap} from "lit/directives/class-map.js"; import {type CSSResultGroup, html, type PropertyValues, render, unsafeCSS} from 'lit'; import {property} from 'lit/decorators.js'; import {sha256} from '../../utilities/sha256'; import {styleMap} from "lit/directives/style-map.js"; import {unsafeSVG} from 'lit/directives/unsafe-svg.js'; import ZincElement from '../../internal/zinc-element'; import type {IconNode, SVGProps} from 'lucide'; import styles from './icon.scss'; // The full lucide icon set is large, so it loads lazily into its own chunk the // first time a lucide icon is rendered. let lucideIcons: Record | undefined; let lucidePromise: Promise | undefined; function loadLucideIcons(): Promise { lucidePromise ??= import('lucide').then(m => { lucideIcons = m.icons as Record; }).catch(() => { // Failed chunk load: fall back to an empty set so icons render their // "not found" state instead of rejecting updateComplete on every render lucideIcons = {}; }); return lucidePromise; } export type IconLibrary = "src" | "material" | "material-outlined" | "material-round" | "material-sharp" | "material-two-tone" | "material-symbols-outlined" | "gravatar" | "libravatar" | "avatar" | "brands" | "line" | "lucide"; export type IconColor = "default" | "primary" | "accent" | "info" | "warning" | "error" | "success" | "white" | "disabled" | "red" | "blue" | "green" | "orange" | "yellow" | "indigo" | "violet" | "pink" | "grey" | (string & Record); /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/icon * @status experimental * @since 1.0 * * @dependency zn-example * * @event zn-event-name - Emitted as an example. * * @slot - The default slot. * @slot example - An example slot. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnIcon extends ZincElement { static styles: CSSResultGroup = unsafeCSS(styles); @property({reflect: true}) src: string; @property({reflect: true}) alt: string; @property({type: Number, reflect: true}) size: number = 24; @property({type: Boolean, reflect: true}) round = false; @property({type: Boolean, reflect: true}) tile = false; @property({type: Boolean, reflect: true}) depth = false; @property({reflect: true}) library: IconLibrary; @property({reflect: true}) color: IconColor; @property({reflect: true}) fill: IconColor; @property({type: Boolean}) padded: boolean = false; @property({type: Boolean}) blink: boolean = false; @property({type: Boolean}) squared: boolean = false; private static readonly presetColors = new Set([ 'default', 'primary', 'accent', 'info', 'warning', 'error', 'success', 'white', 'disabled', 'red', 'blue', 'green', 'orange', 'yellow', 'indigo', 'violet', 'pink', 'grey', ]); private isPresetColor(color: string): boolean { return ZnIcon.presetColors.has(color); } gravatarOptions = ""; defaultLibrary: IconLibrary = "material-symbols-outlined"; private libraryAutoSet = false; convertToLibrary(input: string): IconLibrary { return this.convertIndicatorToLibrary(input) ?? this.defaultLibrary } private convertIndicatorToLibrary(input: string): IconLibrary | undefined { switch (input) { case "material": case "mat": case "m": return "material"; case "material-outlined": case "mo": return "material-outlined"; case "material-round": case "mr": return "material-round"; case "material-sharp": case "ms": return "material-sharp"; case "material-two-tone": case "mt": case "m2": return "material-two-tone"; case "material-symbols-outlined": case "mso": return "material-symbols-outlined"; case "gravatar": case "grav": return "gravatar"; case "libravatar": return "libravatar"; case "avatar": case "av": return "avatar"; case "line": return "line"; case "lucide": case "lu": return "lucide"; case 'brand': case 'brands': return "brands"; } return undefined; } connectedCallback() { super.connectedCallback(); // load the material icons font if the library is set to material render(html` `, document.head); } protected override willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); if (changedProperties.has('src')) { this.parseSrc(); } } // Make `await el.updateComplete` cover the lazy lucide load and the // re-render it triggers. protected override async getUpdateComplete(): Promise { const result = await super.getUpdateComplete(); if (this.library === 'lucide' && !lucideIcons) { await loadLucideIcons(); this.requestUpdate(); await super.getUpdateComplete(); } return result; } private parseSrc() { if (this.libraryAutoSet) { this.library = undefined as unknown as IconLibrary; this.libraryAutoSet = false; } let hashFragment = ''; if (this.src && this.src.includes('#')) { const split = this.src.split('#', 2); this.src = split[0]; hashFragment = split[1] ?? ''; } if (this.src && this.src.includes('@')) { const atIndex = this.src.lastIndexOf('@'); const libraryOrDomain = this.src.slice(atIndex + 1); const indicatedLibrary = this.convertIndicatorToLibrary(libraryOrDomain); if (libraryOrDomain.includes('.')) { this.library = this.library ?? "gravatar"; } else if ((this.library === undefined) && indicatedLibrary !== undefined) { this.library = indicatedLibrary; this.libraryAutoSet = true; this.src = this.src.slice(0, atIndex); } else if (this.library !== undefined && indicatedLibrary === this.library) { this.src = this.src.slice(0, atIndex); } if (this.library === "gravatar" || this.library === "libravatar") { this.applyHashFragment(hashFragment); this.src = sha256(this.normalizeRavatarEmail(this.src)); } else { this.applyHashFragment(hashFragment); } } else if (!this.library && this.src && !this.src.includes('/')) { this.applyHashFragment(hashFragment); this.library = this.defaultLibrary; this.libraryAutoSet = true; } else { this.applyHashFragment(hashFragment); } } private applyHashFragment(hashFragment: string) { if (!hashFragment) { return; } const attributes = hashFragment.split(','); attributes.forEach(attr => { if (attr === 'round') { this.round = true; } if (attr === 'tile') { this.tile = true; } if (attr === 'depth') { this.depth = true; } }); if ((this.library === 'gravatar' || this.library === 'libravatar')) { const defaultOption = attributes.find(attr => !['round', 'tile', 'depth'].includes(attr)); if (defaultOption) { this.gravatarOptions = `&d=${defaultOption}`; } } } private normalizeRavatarEmail(email: string) { return email.trim().toLowerCase(); } render() { return html`
${this.library && this.library !== "src" ? html` ${choose(this.library, [ ["material", () => html`${this.src}`], ["material-outlined", () => html`${this.src}`], ["material-round", () => html`${this.src}`], ["material-sharp", () => html`${this.src}`], ["material-two-tone", () => html`${this.src}`], ["brands", () => html`${this.src}`], ["line", () => html``], ["lucide", () => this.renderLucideIcon()], ["material-symbols-outlined", () => html`${this.src}`], ["gravatar", () => html`${this.alt}`], ["libravatar", () => html`${this.alt}`], ["avatar", () => html`${this.getAvatarInitials(this.src)}`] ], () => html`Library not supported: ${this.library}`)}` : ''} ${(!this.library || this.library === 'src') && this.src ? html` ${this.alt}` : ''} ${!this.library && !this.src ? html` ` : ''}
`; } private getAvatarInitials(avatar: string) { const regex = /(^.|[A-Z]|(?<=[^a-zA-Z0-9])[a-z])/g; // Use matchAll to get an iterator of all matches const matchesIterator = avatar.matchAll(regex); // Convert the iterator to an array of matched strings const matches = [...matchesIterator].map(match => match[0]); // Check if there are any matches and process the result if (matches.length > 0) { return matches.join('').slice(0, 2).toUpperCase(); } return avatar.slice(0, 2).toUpperCase() } private renderLucideIcon() { if (!lucideIcons) { void loadLucideIcons().then(() => this.requestUpdate()); return html``; } const icon = this.getLucideIcon(this.src); if (!icon) { return html`Icon not found: ${this.src ?? ''}`; } return unsafeSVG(this.getLucideSvg(icon)); } private getLucideIcon(src = ''): IconNode | undefined { if (!src) { return undefined; } const candidates = [src, this.toPascalCase(src)]; const iconSet = lucideIcons ?? {}; for (const candidate of candidates) { if (Object.prototype.hasOwnProperty.call(iconSet, candidate)) { return iconSet[candidate]; } } return undefined; } private toPascalCase(input: string) { return input .split(/[-_\s]+/) .filter(Boolean) .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); } private getLucideSvg(icon: IconNode) { const children = icon .map(([tag, attrs]) => `<${tag}${this.getSvgAttributes(attrs)} />`) .join(''); return ` `; } private getSvgAttributes(attrs: SVGProps) { return Object.entries(attrs) .filter(([, value]) => value !== undefined) .map(([name, value]) => ` ${name}="${this.escapeSvgAttribute(String(value))}"`) .join(''); } private escapeSvgAttribute(value: string) { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } protected getColorForAvatar(avatarInitials: string) { const colors = [ '#00AA55', '#0882a8', '#ed55ed', '#5eae00', '#09389e', '#d17300', '#5e0be2', '#5b7a13', '#ddb100', '#d400c2', '#DC2A2A', '#a967ff', ]; // Generate a hash from the initials const hash = Array.from(avatarInitials).reduce((acc, char) => acc + char.charCodeAt(0), 0); // Use the hash to select a color return colors[hash % colors.length]; } }