From 88faedba65980194a55e10233cf1cae652656239 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 16 Dec 2025 15:14:44 +0000 Subject: [PATCH] Migrate ha-icon-picker to generic picker (#27677) --- src/components/ha-generic-picker.ts | 40 +++--- src/components/ha-icon-picker.ts | 200 ++++++++++++++++------------ src/components/ha-picker-field.ts | 43 ++++-- src/translations/en.json | 3 + 4 files changed, 171 insertions(+), 115 deletions(-) diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 5872e577d4..3e5cb689b8 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -34,10 +34,12 @@ export class HaGenericPicker extends LitElement { @property({ type: Boolean, attribute: "allow-custom-value" }) public allowCustomValue; - @property() public label?: string; - @property() public value?: string; + @property() public icon?: string; + + @property() public label?: string; + @property() public helper?: string; @property() public placeholder?: string; @@ -140,6 +142,10 @@ export class HaGenericPicker extends LitElement { // helper to set new value after closing picker, to avoid flicker private _newValue?: string; + @property({ attribute: "error-message" }) public errorMessage?: string; + + @property({ type: Boolean, reflect: true }) public invalid = false; + private _unsubscribeTinyKeys?: () => void; protected render() { @@ -172,13 +178,15 @@ export class HaGenericPicker extends LitElement { aria-label=${ifDefined(this.label)} @click=${this.open} @clear=${this._clear} - .placeholder=${this.placeholder} + .icon=${this.icon} .showLabel=${this.showLabel} + .placeholder=${this.placeholder} .value=${this.value} + .valueRenderer=${this.valueRenderer} .required=${this.required} .disabled=${this.disabled} + .invalid=${this.invalid} .hideClearIcon=${this.hideClearIcon} - .valueRenderer=${this.valueRenderer} > `} @@ -261,11 +269,16 @@ export class HaGenericPicker extends LitElement { ); private _renderHelper() { - return this.helper - ? html`${this.helper}` - : nothing; + const showError = this.invalid && this.errorMessage; + const showHelper = !showError && this.helper; + + if (!showError && !showHelper) { + return nothing; + } + + return html` + ${showError ? this.errorMessage : this.helper} + `; } private _dialogOpened = () => { @@ -364,6 +377,9 @@ export class HaGenericPicker extends LitElement { display: block; margin: var(--ha-space-2) 0 0; } + :host([invalid]) ha-input-helper-text { + color: var(--mdc-theme-error, var(--error-color, #b00020)); + } wa-popover { --wa-space-l: var(--ha-space-0); @@ -386,12 +402,6 @@ export class HaGenericPicker extends LitElement { } } - @media (max-height: 1000px) { - wa-popover::part(body) { - max-height: 400px; - } - } - ha-bottom-sheet { --ha-bottom-sheet-height: 90vh; --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12)); diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index 8b7348853d..a8fde869f1 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -1,8 +1,4 @@ -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import type { - ComboBoxDataProviderCallback, - ComboBoxDataProviderParams, -} from "@vaadin/combo-box/vaadin-combo-box-light"; +import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { TemplateResult } from "lit"; import { LitElement, css, html } from "lit"; import { customElement, property } from "lit/decorators"; @@ -10,35 +6,54 @@ import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { customIcons } from "../data/custom_icons"; import type { HomeAssistant, ValueChangedEvent } from "../types"; -import "./ha-combo-box"; -import "./ha-icon"; import "./ha-combo-box-item"; - -interface IconItem { - icon: string; - parts: Set; - keywords: string[]; -} +import "./ha-generic-picker"; +import "./ha-icon"; +import type { PickerComboBoxItem } from "./ha-picker-combo-box"; interface RankedIcon { - icon: string; + item: PickerComboBoxItem; rank: number; } -let ICONS: IconItem[] = []; +let ICONS: PickerComboBoxItem[] = []; let ICONS_LOADED = false; +interface IconData { + name: string; + keywords?: string[]; +} + +const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => { + const iconId = `${prefix}:${icon.name}`; + const iconName = icon.name; + const parts = iconName.split("-"); + const keywords = icon.keywords ?? []; + const searchLabels: Record = { + iconName, + }; + parts.forEach((part, index) => { + searchLabels[`part${index}`] = part; + }); + keywords.forEach((keyword, index) => { + searchLabels[`keyword${index}`] = keyword; + }); + return { + id: iconId, + primary: iconId, + icon: iconId, + search_labels: searchLabels, + sorting_label: iconId, + }; +}; + const loadIcons = async () => { ICONS_LOADED = true; const iconList = await import("../../build/mdi/iconList.json"); - ICONS = iconList.default.map((icon) => ({ - icon: `mdi:${icon.name}`, - parts: new Set(icon.name.split("-")), - keywords: icon.keywords, - })); + ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi")); - const customIconLoads: Promise[] = []; + const customIconLoads: Promise[] = []; Object.keys(customIcons).forEach((iconSet) => { customIconLoads.push(loadCustomIconItems(iconSet)); }); @@ -47,19 +62,16 @@ const loadIcons = async () => { }); }; -const loadCustomIconItems = async (iconsetPrefix: string) => { +const loadCustomIconItems = async ( + iconsetPrefix: string +): Promise => { try { const getIconList = customIcons[iconsetPrefix].getIconList; if (typeof getIconList !== "function") { return []; } const iconList = await getIconList(); - const customIconItems = iconList.map((icon) => ({ - icon: `${iconsetPrefix}:${icon.name}`, - parts: new Set(icon.name.split("-")), - keywords: icon.keywords ?? [], - })); - return customIconItems; + return iconList.map((icon) => createIconItem(icon, iconsetPrefix)); } catch (_err) { // eslint-disable-next-line no-console console.warn(`Unable to load icon list for ${iconsetPrefix} iconset`); @@ -67,10 +79,10 @@ const loadCustomIconItems = async (iconsetPrefix: string) => { } }; -const rowRenderer: ComboBoxLitRenderer = (item) => html` +const rowRenderer: RenderItemFunction = (item) => html` - - ${item.icon} + + ${item.id} `; @@ -94,85 +106,99 @@ export class HaIconPicker extends LitElement { @property({ type: Boolean }) public invalid = false; + private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS; + protected render(): TemplateResult { return html` - - ${this._value || this.placeholder - ? html` - - - ` - : html``} - + `; } // Filter can take a significant chunk of frame (up to 3-5 ms) private _filterIcons = memoizeOne( - (filter: string, iconItems: IconItem[] = ICONS) => { - if (!filter) { + ( + filter: string, + filteredItems: PickerComboBoxItem[], + allItems: PickerComboBoxItem[] + ): PickerComboBoxItem[] => { + const normalizedFilter = filter.toLowerCase().replace(/\s+/g, "-"); + const iconItems = allItems?.length ? allItems : filteredItems; + + if (!normalizedFilter.length) { return iconItems; } - const filteredItems: RankedIcon[] = []; - const addIcon = (icon: string, rank: number) => - filteredItems.push({ icon, rank }); + const rankedItems: RankedIcon[] = []; // Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords for (const item of iconItems) { - if (item.parts.has(filter)) { - addIcon(item.icon, 1); - } else if (item.keywords.includes(filter)) { - addIcon(item.icon, 2); - } else if (item.icon.includes(filter)) { - addIcon(item.icon, 3); - } else if (item.keywords.some((word) => word.includes(filter))) { - addIcon(item.icon, 4); + const iconName = (item.id.split(":")[1] || item.id).toLowerCase(); + const parts = iconName.split("-"); + const keywords = item.search_labels + ? Object.values(item.search_labels) + .filter((v): v is string => v !== null) + .map((v) => v.toLowerCase()) + : []; + const id = item.id.toLowerCase(); + + if (parts.includes(normalizedFilter)) { + rankedItems.push({ item, rank: 1 }); + } else if (keywords.includes(normalizedFilter)) { + rankedItems.push({ item, rank: 2 }); + } else if (id.includes(normalizedFilter)) { + rankedItems.push({ item, rank: 3 }); + } else if (keywords.some((word) => word.includes(normalizedFilter))) { + rankedItems.push({ item, rank: 4 }); } } // Allow preview for custom icon not in list - if (filteredItems.length === 0) { - addIcon(filter, 0); + if (rankedItems.length === 0) { + rankedItems.push({ + item: { + id: filter, + primary: filter, + icon: filter, + search_labels: { keyword: filter }, + sorting_label: filter, + }, + rank: 0, + }); } - return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank); + return rankedItems + .sort((itemA, itemB) => itemA.rank - itemB.rank) + .map((item) => item.item); } ); - private _iconProvider = ( - params: ComboBoxDataProviderParams, - callback: ComboBoxDataProviderCallback - ) => { - const filteredItems = this._filterIcons(params.filter.toLowerCase(), ICONS); - const iStart = params.page * params.pageSize; - const iEnd = iStart + params.pageSize; - callback(filteredItems.slice(iStart, iEnd), filteredItems.length); - }; - - private async _openedChanged(ev: ValueChangedEvent) { - const opened = ev.detail.value; - if (opened && !ICONS_LOADED) { - await loadIcons(); - this.requestUpdate(); + protected firstUpdated() { + if (!ICONS_LOADED) { + loadIcons().then(() => { + this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS; + this.requestUpdate(); + }); } } @@ -194,20 +220,18 @@ export class HaIconPicker extends LitElement { ); } + private get _icon() { + return this.value?.length ? this.value : this.placeholder; + } + private get _value() { return this.value || ""; } static styles = css` - *[slot="icon"] { - color: var(--primary-text-color); - position: relative; - bottom: 2px; - } - *[slot="prefix"] { - margin-right: 8px; - margin-inline-end: 8px; - margin-inline-start: initial; + ha-generic-picker { + width: 100%; + display: block; } `; } diff --git a/src/components/ha-picker-field.ts b/src/components/ha-picker-field.ts index f9e9462ef8..be12a62238 100644 --- a/src/components/ha-picker-field.ts +++ b/src/components/ha-picker-field.ts @@ -15,6 +15,7 @@ import type { HomeAssistant } from "../types"; import "./ha-combo-box-item"; import type { HaComboBoxItem } from "./ha-combo-box-item"; import "./ha-icon-button"; +import "./ha-icon"; declare global { interface HASSDomEvents { @@ -32,6 +33,8 @@ export class HaPickerField extends LitElement { @property() public value?: string; + @property() public icon?: string; + @property() public helper?: string; @property() public placeholder?: string; @@ -49,6 +52,8 @@ export class HaPickerField extends LitElement { @property({ attribute: false }) public valueRenderer?: PickerValueRenderer; + @property({ type: Boolean, reflect: true }) public invalid = false; + @query("ha-combo-box-item", true) public item!: HaComboBoxItem; @state() @@ -61,23 +66,32 @@ export class HaPickerField extends LitElement { } protected render() { + const hasValue = !!this.value?.length; + const showClearIcon = !!this.value && !this.required && !this.disabled && !this.hideClearIcon; - const placeholder = this.showLabel - ? html`${this.placeholder}` - : nothing; + + const overlineLabel = + this.showLabel && hasValue && this.placeholder + ? html`${this.placeholder}` + : nothing; + + const headlineContent = hasValue + ? this.valueRenderer + ? this.valueRenderer(this.value ?? "") + : html`${this.value}` + : this.placeholder + ? html` + ${this.placeholder} + ` + : nothing; return html` - ${this.value - ? this.valueRenderer - ? html`${placeholder}${this.valueRenderer(this.value)}` - : html`${placeholder}${this.value}` - : html` - - ${this.placeholder} - - `} + ${this.icon + ? html`` + : nothing} + ${overlineLabel}${headlineContent} ${this.unknown ? html`
${this.unknownItemText || @@ -169,6 +183,11 @@ export class HaPickerField extends LitElement { background-color: var(--ha-color-fill-warning-quiet-resting); } + :host([invalid]) ha-combo-box-item:after { + height: 2px; + background-color: var(--mdc-theme-error, var(--error-color, #b00020)); + } + .clear { margin: 0 -8px; --mdc-icon-button-size: 32px; diff --git a/src/translations/en.json b/src/translations/en.json index b0b039e0a5..fcae62143e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -769,6 +769,9 @@ "no_match": "No languages found for {term}", "no_languages": "No languages available" }, + "icon-picker": { + "no_match": "No matching icons found" + }, "tts-picker": { "tts": "Text-to-speech", "none": "None"