From e1dce358c3a67f609f49aae288ad982cdc08c278 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 27 Oct 2025 16:19:49 +0000 Subject: [PATCH 1/5] Migrate ha-icon-picker to generic picker --- src/components/ha-icon-picker.ts | 203 ++++++++++++++++--------------- src/translations/en.json | 3 + 2 files changed, 106 insertions(+), 100 deletions(-) diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index 8b7348853de1..df81f5ba5f32 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -1,44 +1,39 @@ -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"; -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; 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) => { + const iconId = `mdi:${icon.name}`; + return { + id: iconId, + primary: iconId, + icon: iconId, + search_labels: [icon.name, ...icon.keywords], + sorting_label: iconId, + }; + }); - const customIconLoads: Promise[] = []; + const customIconLoads: Promise[] = []; Object.keys(customIcons).forEach((iconSet) => { customIconLoads.push(loadCustomIconItems(iconSet)); }); @@ -47,18 +42,25 @@ 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 ?? [], - })); + const customIconItems = iconList.map((icon) => { + const iconId = `${iconsetPrefix}:${icon.name}`; + return { + id: iconId, + primary: iconId, + icon: iconId, + search_labels: [icon.name, ...(icon.keywords ?? [])], + sorting_label: iconId, + }; + }); return customIconItems; } catch (_err) { // eslint-disable-next-line no-console @@ -67,13 +69,18 @@ const loadCustomIconItems = async (iconsetPrefix: string) => { } }; -const rowRenderer: ComboBoxLitRenderer = (item) => html` +const rowRenderer: RenderItemFunction = (item) => html` - - ${item.icon} + + ${item.id} `; +const valueRenderer = (value: string) => html` + + ${value} +`; + @customElement("ha-icon-picker") export class HaIconPicker extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @@ -94,87 +101,89 @@ export class HaIconPicker extends LitElement { @property({ type: Boolean }) public invalid = false; + protected firstUpdated() { + if (!ICONS_LOADED) { + loadIcons().then(() => { + this.requestUpdate(); + }); + } + } + + private _getItems = (): 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) { - return iconItems; - } - - const filteredItems: RankedIcon[] = []; - const addIcon = (icon: string, rank: number) => - filteredItems.push({ icon, rank }); - - // 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); - } - } + private _filterIcons = ( + filter: string, + items: PickerComboBoxItem[] + ): PickerComboBoxItem[] => { + if (!filter) { + return items; + } - // Allow preview for custom icon not in list - if (filteredItems.length === 0) { - addIcon(filter, 0); + const filteredItems: RankedIcon[] = []; + const addIcon = (item: PickerComboBoxItem, rank: number) => + filteredItems.push({ item, rank }); + + // Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords + for (const item of items) { + const iconName = item.id.split(":")[1] || item.id; + const parts = iconName.split("-"); + const keywords = item.search_labels?.slice(1) || []; + + if (parts.includes(filter)) { + addIcon(item, 1); + } else if (keywords.includes(filter)) { + addIcon(item, 2); + } else if (item.id.includes(filter)) { + addIcon(item, 3); + } else if (keywords.some((word) => word.includes(filter))) { + addIcon(item, 4); } - - return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank); } - ); - - 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(); + // Allow preview for custom icon not in list + if (filteredItems.length === 0) { + addIcon( + { + id: filter, + primary: filter, + icon: filter, + search_labels: [filter], + sorting_label: filter, + }, + 0 + ); } - } + + return filteredItems + .sort((itemA, itemB) => itemA.rank - itemB.rank) + .map((item) => item.item); + }; private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); @@ -199,15 +208,9 @@ export class HaIconPicker extends LitElement { } 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/translations/en.json b/src/translations/en.json index 14f23a56ca53..535d788ffee0 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -761,6 +761,9 @@ "no_match": "No matching languages found", "no_languages": "No languages available" }, + "icon-picker": { + "no_match": "No matching icons found" + }, "tts-picker": { "tts": "Text-to-speech", "none": "None" From 34443a009defe7776b5bcaf8ed85271ccf879ca8 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 27 Oct 2025 16:21:17 +0000 Subject: [PATCH 2/5] Reorder --- src/components/ha-icon-picker.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index df81f5ba5f32..2dfb01907294 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -101,16 +101,6 @@ export class HaIconPicker extends LitElement { @property({ type: Boolean }) public invalid = false; - protected firstUpdated() { - if (!ICONS_LOADED) { - loadIcons().then(() => { - this.requestUpdate(); - }); - } - } - - private _getItems = (): PickerComboBoxItem[] => ICONS; - protected render(): TemplateResult { return html` item.item); }; + private _getItems = (): PickerComboBoxItem[] => ICONS; + + protected firstUpdated() { + if (!ICONS_LOADED) { + loadIcons().then(() => { + this.requestUpdate(); + }); + } + } + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); this._setValue(ev.detail.value); From 70bb29e242461cf38bca4b28fc0d0aebbb516bb9 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 27 Oct 2025 16:26:29 +0000 Subject: [PATCH 3/5] memo --- src/components/ha-icon-picker.ts | 86 ++++++++++++++++---------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index 2dfb01907294..4fdcaff79b66 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -2,6 +2,7 @@ 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"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { customIcons } from "../data/custom_icons"; import type { HomeAssistant, ValueChangedEvent } from "../types"; @@ -127,53 +128,52 @@ export class HaIconPicker extends LitElement { } // Filter can take a significant chunk of frame (up to 3-5 ms) - private _filterIcons = ( - filter: string, - items: PickerComboBoxItem[] - ): PickerComboBoxItem[] => { - if (!filter) { - return items; - } + private _filterIcons = memoizeOne( + (filter: string, items: PickerComboBoxItem[]): PickerComboBoxItem[] => { + if (!filter) { + return items; + } - const filteredItems: RankedIcon[] = []; - const addIcon = (item: PickerComboBoxItem, rank: number) => - filteredItems.push({ item, rank }); - - // Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords - for (const item of items) { - const iconName = item.id.split(":")[1] || item.id; - const parts = iconName.split("-"); - const keywords = item.search_labels?.slice(1) || []; - - if (parts.includes(filter)) { - addIcon(item, 1); - } else if (keywords.includes(filter)) { - addIcon(item, 2); - } else if (item.id.includes(filter)) { - addIcon(item, 3); - } else if (keywords.some((word) => word.includes(filter))) { - addIcon(item, 4); + const filteredItems: RankedIcon[] = []; + const addIcon = (item: PickerComboBoxItem, rank: number) => + filteredItems.push({ item, rank }); + + // Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords + for (const item of items) { + const iconName = item.id.split(":")[1] || item.id; + const parts = iconName.split("-"); + const keywords = item.search_labels?.slice(1) || []; + + if (parts.includes(filter)) { + addIcon(item, 1); + } else if (keywords.includes(filter)) { + addIcon(item, 2); + } else if (item.id.includes(filter)) { + addIcon(item, 3); + } else if (keywords.some((word) => word.includes(filter))) { + addIcon(item, 4); + } } - } - // Allow preview for custom icon not in list - if (filteredItems.length === 0) { - addIcon( - { - id: filter, - primary: filter, - icon: filter, - search_labels: [filter], - sorting_label: filter, - }, - 0 - ); - } + // Allow preview for custom icon not in list + if (filteredItems.length === 0) { + addIcon( + { + id: filter, + primary: filter, + icon: filter, + search_labels: [filter], + sorting_label: filter, + }, + 0 + ); + } - return filteredItems - .sort((itemA, itemB) => itemA.rank - itemB.rank) - .map((item) => item.item); - }; + return filteredItems + .sort((itemA, itemB) => itemA.rank - itemB.rank) + .map((item) => item.item); + } + ); private _getItems = (): PickerComboBoxItem[] => ICONS; From 01ba7571f005c813285b043a97d87fcbfe0110c7 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 28 Oct 2025 16:01:49 +0000 Subject: [PATCH 4/5] Restore optimised parts code --- src/components/ha-icon-picker.ts | 57 +++++++++++++++++--------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index 4fdcaff79b66..ff8cac723eeb 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -11,30 +11,31 @@ import "./ha-generic-picker"; import "./ha-icon"; import type { PickerComboBoxItem } from "./ha-picker-combo-box"; +interface IconItem { + icon: string; + parts: Set; + keywords: string[]; +} + interface RankedIcon { item: PickerComboBoxItem; rank: number; } -let ICONS: PickerComboBoxItem[] = []; +let ICONS: IconItem[] = []; let ICONS_LOADED = false; const loadIcons = async () => { ICONS_LOADED = true; const iconList = await import("../../build/mdi/iconList.json"); - ICONS = iconList.default.map((icon) => { - const iconId = `mdi:${icon.name}`; - return { - id: iconId, - primary: iconId, - icon: iconId, - search_labels: [icon.name, ...icon.keywords], - sorting_label: iconId, - }; - }); + ICONS = iconList.default.map((icon) => ({ + icon: `mdi:${icon.name}`, + parts: new Set(icon.name.split("-")), + keywords: icon.keywords, + })); - const customIconLoads: Promise[] = []; + const customIconLoads: Promise[] = []; Object.keys(customIcons).forEach((iconSet) => { customIconLoads.push(loadCustomIconItems(iconSet)); }); @@ -43,25 +44,18 @@ const loadIcons = async () => { }); }; -const loadCustomIconItems = async ( - iconsetPrefix: string -): Promise => { +const loadCustomIconItems = async (iconsetPrefix: string) => { try { const getIconList = customIcons[iconsetPrefix].getIconList; if (typeof getIconList !== "function") { return []; } const iconList = await getIconList(); - const customIconItems = iconList.map((icon) => { - const iconId = `${iconsetPrefix}:${icon.name}`; - return { - id: iconId, - primary: iconId, - icon: iconId, - search_labels: [icon.name, ...(icon.keywords ?? [])], - sorting_label: iconId, - }; - }); + const customIconItems = iconList.map((icon) => ({ + icon: `${iconsetPrefix}:${icon.name}`, + parts: new Set(icon.name.split("-")), + keywords: icon.keywords ?? [], + })); return customIconItems; } catch (_err) { // eslint-disable-next-line no-console @@ -175,7 +169,18 @@ export class HaIconPicker extends LitElement { } ); - private _getItems = (): PickerComboBoxItem[] => ICONS; + private _getItems = (): PickerComboBoxItem[] => + ICONS.map((icon: IconItem) => ({ + id: icon.icon, + primary: icon.icon, + icon: icon.icon, + search_labels: [ + icon.icon.split(":")[1] || icon.icon, + ...Array.from(icon.parts), + ...icon.keywords, + ], + sorting_label: icon.icon, + })); protected firstUpdated() { if (!ICONS_LOADED) { From c6c2d47ee3664035700cc06c65328096461b5281 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 29 Oct 2025 09:03:13 +0000 Subject: [PATCH 5/5] Add support for error state --- src/components/ha-generic-picker.ts | 24 +++++++++++++++++++----- src/components/ha-icon-picker.ts | 2 ++ src/components/ha-picker-field.ts | 9 +++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 256d4c29b476..df29e57b47d3 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -103,6 +103,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() { @@ -137,6 +141,8 @@ export class HaGenericPicker extends LitElement { .value=${this.value} .required=${this.required} .disabled=${this.disabled} + .errorMessage=${this.errorMessage} + .invalid=${this.invalid} .hideClearIcon=${this.hideClearIcon} .valueRenderer=${this.valueRenderer} > @@ -205,11 +211,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 = () => { @@ -308,6 +319,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); diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index ff8cac723eeb..823438c77c4a 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -108,6 +108,8 @@ export class HaIconPicker extends LitElement { .disabled=${this.disabled} .required=${this.required} .placeholder=${this.placeholder} + .errorMessage=${this.errorMessage} + .invalid=${this.invalid} .rowRenderer=${rowRenderer} .valueRenderer=${valueRenderer} .searchFn=${this._filterIcons} diff --git a/src/components/ha-picker-field.ts b/src/components/ha-picker-field.ts index a78c10a9a325..f820bb2bd469 100644 --- a/src/components/ha-picker-field.ts +++ b/src/components/ha-picker-field.ts @@ -39,6 +39,10 @@ export class HaPickerField extends LitElement { @property({ attribute: false }) public valueRenderer?: PickerValueRenderer; + @property({ attribute: "error-message" }) public errorMessage?: string; + + @property({ type: Boolean, reflect: true }) public invalid = false; + @query("ha-combo-box-item", true) public item!: HaComboBoxItem; public async focus() { @@ -142,6 +146,11 @@ export class HaPickerField extends LitElement { background-color: var(--mdc-theme-primary); } + :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;