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 8b7348853de1..823438c77c4a 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,9 +6,10 @@ 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"; +import "./ha-generic-picker"; +import "./ha-icon"; +import type { PickerComboBoxItem } from "./ha-picker-combo-box"; interface IconItem { icon: string; @@ -21,7 +18,7 @@ interface IconItem { } interface RankedIcon { - icon: string; + item: PickerComboBoxItem; rank: number; } @@ -67,13 +64,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; @@ -96,13 +98,11 @@ export class HaIconPicker extends LitElement { 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) => { + (filter: string, items: PickerComboBoxItem[]): PickerComboBoxItem[] => { if (!filter) { - return iconItems; + return items; } const filteredItems: RankedIcon[] = []; - const addIcon = (icon: string, rank: number) => - filteredItems.push({ icon, rank }); + 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 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); + 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(filter, 0); + addIcon( + { + id: filter, + primary: filter, + icon: filter, + search_labels: [filter], + sorting_label: filter, + }, + 0 + ); } - return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank); + return filteredItems + .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(); + 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) { + loadIcons().then(() => { + this.requestUpdate(); + }); } } @@ -199,15 +215,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/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; 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"