From faa57e4c024e5db29e77005f951e61b1de202df5 Mon Sep 17 00:00:00 2001 From: Steve Repsher Date: Thu, 1 Dec 2022 10:59:45 -0500 Subject: [PATCH] Improve filtering and performance for icon picker (#14401) Co-authored-by: Paul Bottein --- src/components/ha-combo-box.ts | 20 +++- src/components/ha-icon-picker.ts | 185 +++++++++++++++++-------------- 2 files changed, 120 insertions(+), 85 deletions(-) diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts index 3b2f56c72089..dd4d4289e1f5 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -3,6 +3,7 @@ import { mdiClose, mdiMenuDown, mdiMenuUp } from "@mdi/js"; import { ComboBoxLitRenderer, comboBoxRenderer } from "@vaadin/combo-box/lit"; import "@vaadin/combo-box/theme/material/vaadin-combo-box-light"; import type { + ComboBoxDataProvider, ComboBoxLight, ComboBoxLightFilterChangedEvent, ComboBoxLightOpenedChangedEvent, @@ -82,6 +83,9 @@ export class HaComboBox extends LitElement { @property({ attribute: false }) public filteredItems?: any[]; + @property({ attribute: false }) + public dataProvider?: ComboBoxDataProvider; + @property({ attribute: "allow-custom-value", type: Boolean }) public allowCustomValue = false; @@ -148,6 +152,7 @@ export class HaComboBox extends LitElement { .items=${this.items} .value=${this.value || ""} .filteredItems=${this.filteredItems} + .dataProvider=${this.dataProvider} .allowCustomValue=${this.allowCustomValue} .disabled=${this.disabled} .required=${this.required} @@ -225,13 +230,13 @@ export class HaComboBox extends LitElement { } private _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { + ev.stopPropagation(); const opened = ev.detail.value; // delay this so we can handle click event for toggle button before setting _opened setTimeout(() => { this.opened = opened; }, 0); - // @ts-ignore - fireEvent(this, ev.type, ev.detail); + fireEvent(this, "opened-changed", { value: ev.detail.value }); if (opened) { const overlay = document.querySelector( @@ -300,8 +305,8 @@ export class HaComboBox extends LitElement { } private _filterChanged(ev: ComboBoxLightFilterChangedEvent) { - // @ts-ignore - fireEvent(this, ev.type, ev.detail, { composed: false }); + ev.stopPropagation(); + fireEvent(this, "filter-changed", { value: ev.detail.value }); } private _valueChanged(ev: ComboBoxLightValueChangedEvent) { @@ -363,3 +368,10 @@ declare global { "ha-combo-box": HaComboBox; } } + +declare global { + interface HASSDomEvents { + "filter-changed": { value: string }; + "opened-changed": { value: boolean }; + } +} diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index a424ffeda19b..abb3d34aee46 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -1,28 +1,76 @@ -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { + ComboBoxDataProviderCallback, + ComboBoxDataProviderParams, +} from "@vaadin/combo-box/vaadin-combo-box-light"; +import { css, html, LitElement, TemplateResult } 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 { PolymerChangedEvent } from "../polymer-types"; import { HomeAssistant } from "../types"; import "./ha-combo-box"; -import type { HaComboBox } from "./ha-combo-box"; import "./ha-icon"; type IconItem = { icon: string; + parts: Set; keywords: string[]; }; -let iconItems: IconItem[] = []; -let iconLoaded = false; -// eslint-disable-next-line lit/prefer-static-styles -const rowRenderer: ComboBoxLitRenderer = (item) => html` - - ${item.icon} -`; +type RankedIcon = { + icon: string; + rank: number; +}; + +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) => ({ + icon: `mdi:${icon.name}`, + parts: new Set(icon.name.split("-")), + keywords: icon.keywords, + })); + + const customIconLoads: Promise[] = []; + Object.keys(customIcons).forEach((iconSet) => { + customIconLoads.push(loadCustomIconItems(iconSet)); + }); + (await Promise.all(customIconLoads)).forEach((customIconItems) => { + ICONS.push(...customIconItems); + }); +}; + +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) => ({ + icon: `${iconsetPrefix}:${icon.name}`, + parts: new Set(icon.name.split("-")), + keywords: icon.keywords ?? [], + })); + return customIconItems; + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Unable to load icon list for ${iconsetPrefix} iconset`); + return []; + } +}; + +const rowRenderer: ComboBoxLitRenderer = (item) => + html` + + ${item.icon} + `; @customElement("ha-icon-picker") export class HaIconPicker extends LitElement { @@ -46,10 +94,6 @@ export class HaIconPicker extends LitElement { @property({ type: Boolean }) public invalid = false; - @state() private _opened = false; - - @query("ha-combo-box", true) private comboBox!: HaComboBox; - protected render(): TemplateResult { return html` ${this._value || this.placeholder ? html` @@ -87,48 +130,57 @@ export class HaIconPicker extends LitElement { `; } - private async _openedChanged(ev: PolymerChangedEvent) { - this._opened = ev.detail.value; - if (this._opened && !iconLoaded) { - const iconList = await import("../../build/mdi/iconList.json"); + // 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; + } - iconItems = iconList.default.map((icon) => ({ - icon: `mdi:${icon.name}`, - keywords: icon.keywords, - })); - iconLoaded = true; + 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); + } + } - this.comboBox.filteredItems = iconItems; + // Allow preview for custom icon not in list + if (filteredItems.length === 0) { + addIcon(filter, 0); + } - Object.keys(customIcons).forEach((iconSet) => { - this._loadCustomIconItems(iconSet); - }); + 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 _loadCustomIconItems(iconsetPrefix: string) { - try { - const getIconList = customIcons[iconsetPrefix].getIconList; - if (typeof getIconList !== "function") { - return; - } - const iconList = await getIconList(); - const customIconItems = iconList.map((icon) => ({ - icon: `${iconsetPrefix}:${icon.name}`, - keywords: icon.keywords ?? [], - })); - iconItems.push(...customIconItems); - this.comboBox.filteredItems = iconItems; - } catch (e) { - // eslint-disable-next-line - console.warn(`Unable to load icon list for ${iconsetPrefix} iconset`); + private async _openedChanged(ev: PolymerChangedEvent) { + const opened = ev.detail.value; + if (opened && !ICONS_LOADED) { + await loadIcons(); + this.requestUpdate(); } } - protected shouldUpdate(changedProps: PropertyValues) { - return !this._opened || changedProps.has("_opened"); - } - private _valueChanged(ev: PolymerChangedEvent) { ev.stopPropagation(); this._setValue(ev.detail.value); @@ -147,35 +199,6 @@ export class HaIconPicker extends LitElement { ); } - private _filterChanged(ev: CustomEvent): void { - const filterString = ev.detail.value.toLowerCase(); - const characterCount = filterString.length; - if (characterCount >= 2) { - const filteredItems: IconItem[] = []; - const filteredItemsByKeywords: IconItem[] = []; - - iconItems.forEach((item) => { - if (item.icon.includes(filterString)) { - filteredItems.push(item); - return; - } - if (item.keywords.some((t) => t.includes(filterString))) { - filteredItemsByKeywords.push(item); - } - }); - - filteredItems.push(...filteredItemsByKeywords); - - if (filteredItems.length > 0) { - this.comboBox.filteredItems = filteredItems; - } else { - this.comboBox.filteredItems = [{ icon: filterString, keywords: [] }]; - } - } else { - this.comboBox.filteredItems = iconItems; - } - } - private get _value() { return this.value || ""; }