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"