import {classMap} from 'lit/directives/class-map.js'; import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from 'lit'; import {defaultValue} from '../../internal/default-value'; import {FormControlController} from '../../internal/form'; import {property, query, state} from 'lit/decorators.js'; import ZincElement from '../../internal/zinc-element'; import ZnButton from '../button'; import ZnDialog from '../dialog'; import ZnIcon from '../icon'; import ZnInput from '../input'; import ZnOption from '../option'; import ZnSelect from '../select'; import type {ZincFormControl} from '../../internal/zinc-element'; import formControlStyles from '../../form-control.scss'; import styles from './icon-picker.scss'; export default class ZnIconPicker extends ZincElement implements ZincFormControl { static styles: CSSResultGroup = [unsafeCSS(formControlStyles), unsafeCSS(styles)]; static dependencies = { 'zn-icon': ZnIcon, 'zn-button': ZnButton, 'zn-dialog': ZnDialog, 'zn-input': ZnInput, 'zn-select': ZnSelect, 'zn-option': ZnOption }; private readonly formControlController = new FormControlController(this, { assumeInteractionOn: ['zn-change'], // With allow-upload, a chosen file is submitted under `name` in place of // the icon string — the two are mutually exclusive. value: (el: ZnIconPicker) => el._file ?? el.icon, setValue: (el: ZnIconPicker, value: string) => { el.clearFile(); el.icon = value ?? ''; } }); @property() name = ''; @property() icon = ''; @property() label = ''; // Matches zn-icon's defaultLibrary so the picked icon previews the same way // it will render wherever the stored name is displayed. @property() library: string = 'material-symbols-outlined'; @property() color: string = ''; @property({type: Boolean, attribute: 'no-color'}) noColor: boolean = false; @property({type: Boolean, attribute: 'no-library'}) noLibrary: boolean = false; @property({attribute: 'help-text'}) helpText: string = ''; @property({type: Boolean, reflect: true}) disabled = false; @property({type: Boolean, reflect: true}) required = false; @property({type: Boolean, attribute: 'trigger-submit'}) triggerSubmit = false; @property({type: Boolean, attribute: 'allow-upload'}) allowUpload = false; @property() accept = 'image/*'; @property({reflect: true}) form: string; @defaultValue('icon') defaultValue = ''; @state() private _dialogOpen = false; @state() private _searchQuery = ''; @state() private _iconList: string[] = []; @state() private _filteredIcons: string[] = []; // Pending selections (not committed until confirm) @state() private _pendingIcon = ''; @state() private _pendingLibrary = ''; @state() private _pendingColor = ''; @state() private _pendingFile: File | null = null; @state() private _pendingFileUrl: string | null = null; @state() private _mode: 'icon' | 'upload' = 'icon'; // Committed upload (allow-upload mode) @state() private _file: File | null = null; @state() private _fileUrl: string | null = null; @query('zn-dialog') private _dialog: ZnDialog; @query('.icon-picker__file-input') private _fileInput: HTMLInputElement; get value(): string { return this.icon; } set value(val: string) { this.icon = val; } /** The chosen file when the user uploaded an image instead of picking an icon. */ get file(): File | null { return this._file; } /** True when the current value renders as an image (an uploaded file or a URL) rather than a library icon. */ get isImageValue(): boolean { return !!this._file || this.icon.includes('/'); } get validity(): ValidityState { if (this.required && !this.icon && !this._file) { return { valid: false, valueMissing: true, badInput: false, customError: false, patternMismatch: false, rangeOverflow: false, rangeUnderflow: false, stepMismatch: false, tooLong: false, tooShort: false, typeMismatch: false, } as ValidityState; } return { valid: true, valueMissing: false, badInput: false, customError: false, patternMismatch: false, rangeOverflow: false, rangeUnderflow: false, stepMismatch: false, tooLong: false, tooShort: false, typeMismatch: false, } as ValidityState; } get validationMessage(): string { return this.required && !this.icon && !this._file ? 'Please select an icon.' : ''; } checkValidity(): boolean { return this.validity.valid; } getForm(): HTMLFormElement | null { return this.formControlController.getForm(); } reportValidity(): boolean { return this.checkValidity(); } setCustomValidity(_message: string) { this.formControlController.updateValidity(); } private static readonly freeInputLibraries = new Set(['gravatar', 'libravatar', 'avatar']); private isFreeInputLibrary(library: string): boolean { return ZnIconPicker.freeInputLibraries.has(library); } // The icon name lists are only needed once the picker dialog opens, so they // load lazily into their own chunks. Falls back to an empty list on a failed // chunk load so callers never reject. private async getIconsForLibrary(library: string): Promise { try { switch (library) { case 'brands': return (await import('./brand-icons')).brandIcons; case 'line': return (await import('./line-icons')).lineIcons; case 'lucide': return (await import('./lucide-icons')).lucideIcons; default: { const lists = await import('./material-icons'); switch (library) { case 'material-outlined': return lists.material_outlinedIcons; case 'material-round': return lists.material_roundIcons; case 'material-sharp': return lists.material_sharpIcons; case 'material-two-tone': return lists.material_two_toneIcons; case 'material-symbols-outlined': return lists.material_symbols_outlinedIcons; default: return lists.materialIcons; } } } } catch { return []; } } private async openDialog() { this._pendingIcon = this.isImageValue ? '' : this.icon; this._pendingLibrary = this.library; this._pendingColor = this.color; this._searchQuery = ''; this._mode = this.allowUpload && this.isImageValue ? 'upload' : 'icon'; this._iconList = await this.getIconsForLibrary(this.library); this._filteredIcons = this._iconList.slice(0, 200); this._dialogOpen = true; await this.updateComplete; this._dialog.show(); } private closeDialog() { this.discardPendingFile(); this._dialog.hide(); this._dialogOpen = false; } private handleConfirm() { if (this._mode === 'upload') { if (this._pendingFile) { if (this._fileUrl) { URL.revokeObjectURL(this._fileUrl); } this._file = this._pendingFile; this._fileUrl = this._pendingFileUrl; this._pendingFile = null; this._pendingFileUrl = null; this.icon = ''; } } else { this.icon = this._pendingIcon; this.library = this._pendingLibrary; this.color = this._pendingColor; this.clearFile(); } this.closeDialog(); this.emit('zn-change'); if (this.triggerSubmit) { this.formControlController.submit(); } } private handleCancel() { this.closeDialog(); } private handleFileSelect(e: Event) { const input = e.target as HTMLInputElement; const file = input.files?.[0] ?? null; if (!file) return; this.discardPendingFile(); this._pendingFile = file; this._pendingFileUrl = URL.createObjectURL(file); input.value = ''; } private discardPendingFile() { if (this._pendingFileUrl) { URL.revokeObjectURL(this._pendingFileUrl); } this._pendingFile = null; this._pendingFileUrl = null; } private clearFile() { if (this._fileUrl) { URL.revokeObjectURL(this._fileUrl); } this._file = null; this._fileUrl = null; } disconnectedCallback() { super.disconnectedCallback(); this.discardPendingFile(); if (this._fileUrl) { URL.revokeObjectURL(this._fileUrl); } } // The [icon]/[library]/[color] hidden inputs must live in the light DOM — // shadow DOM inputs are invisible to form submission. private syncHiddenInputs() { const fields: [string, string, boolean][] = [ ['icon', this.icon, true], ['library', this.library, !this.noLibrary], ['color', this.color, !this.noColor], ]; for (const [key, value, enabled] of fields) { const name = `${this.name}[${key}]`; let input = this.querySelector(`input[type="hidden"][name="${name}"]`); if (!this.name || !enabled) { input?.remove(); continue; } if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = name; this.appendChild(input); } input.value = value ?? ''; } } protected updated(changedProperties: PropertyValues) { super.updated(changedProperties); this.syncHiddenInputs(); } private handleSearchInput(e: Event) { const input = e.target as HTMLInputElement; this._searchQuery = input.value.toLowerCase(); this.filterIcons(); } private filterIcons() { if (!this._searchQuery) { this._filteredIcons = this._iconList.slice(0, 200); } else { this._filteredIcons = this._iconList .filter(name => name.includes(this._searchQuery)) .slice(0, 200); } } private handleIconSelect(iconName: string) { this._pendingIcon = iconName; } private async handleLibraryChange(e: Event) { const select = e.target as HTMLSelectElement; const wasFreeInput = this.isFreeInputLibrary(this._pendingLibrary); this._pendingLibrary = select.value; const isFreeInput = this.isFreeInputLibrary(this._pendingLibrary); // Clear pending icon when switching between grid and free-input modes if (wasFreeInput !== isFreeInput) { this._pendingIcon = ''; } if (!isFreeInput) { this._iconList = await this.getIconsForLibrary(this._pendingLibrary); this.filterIcons(); } } private handleColorInput(e: Event) { const input = e.target as ZnInput; this._pendingColor = input.value; } private handleFreeInput(e: Event) { const input = e.target as HTMLInputElement; this._pendingIcon = input.value; } private handleClear(e: Event) { e.stopPropagation(); this.icon = ''; this.color = ''; this.clearFile(); this.emit('zn-change'); if (this.triggerSubmit) { this.formControlController.submit(); } } private _handleTriggerClick() { if (this.disabled) return; this.openDialog(); } private _handleTriggerKeyDown(e: KeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._handleTriggerClick(); } } render() { const hasLabel = !!this.label; const hasHelpText = !!this.helpText; const hasValue = !!this.icon || !!this._file; // Image values (uploaded file or URL) omit the library so zn-icon's URL // auto-detection renders an instead of a font ligature. const triggerIcon = this._fileUrl ?? this.icon; const triggerLibrary = hasValue && !this.isImageValue ? this.library : nothing; const pendingPreviewUrl = this._pendingFileUrl ?? (this.isImageValue && !this._file ? this.icon : this._fileUrl); return html`
${hasValue && this.isImageValue ? html`
` : html` ${hasValue ? 'Click to edit' : 'Set an icon'} ${hasValue ? html` ` : nothing} `}
${this.helpText}
${this._dialogOpen ? html`
${this.allowUpload ? html`
this._mode = 'icon'}> Icon Library this._mode = 'upload'}> Upload Image
` : nothing} ${this._mode === 'upload' ? html`
${this._pendingFile ? html` ${this._pendingFile.name} ` : nothing} this._fileInput.click()}> ${pendingPreviewUrl ? 'Choose a different file' : 'Choose a file'}
` : html`
${!this.noLibrary ? html` Material Material Outlined Material Round Material Sharp Material Two Tone Material Symbols Outlined Brands Line Lucide Gravatar Libravatar Avatar ` : nothing} ${!this.noColor ? html` ` : nothing}
${this.isFreeInputLibrary(this._pendingLibrary) ? html` ` : html`
${this._filteredIcons.length === 0 ? html`
No icons found
` : this._filteredIcons.map(iconName => html` `)}
`} `}
${this._mode === 'upload' ? html`
${pendingPreviewUrl ? html` ${this._pendingFile?.name ?? ''} ` : html` Choose an image `}
` : this._pendingIcon ? html`
${this._pendingIcon} ${this._pendingLibrary}
` : html`
Select an icon
`}
` : nothing}
`; } }