Skip to content

Commit

Permalink
Improve filtering and performance for icon picker (home-assistant#14401)
Browse files Browse the repository at this point in the history
Co-authored-by: Paul Bottein <[email protected]>
  • Loading branch information
steverep and piitaya authored Dec 1, 2022
1 parent 915563c commit faa57e4
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 85 deletions.
20 changes: 16 additions & 4 deletions src/components/ha-combo-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -82,6 +83,9 @@ export class HaComboBox extends LitElement {

@property({ attribute: false }) public filteredItems?: any[];

@property({ attribute: false })
public dataProvider?: ComboBoxDataProvider<any>;

@property({ attribute: "allow-custom-value", type: Boolean })
public allowCustomValue = false;

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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<HTMLElement>(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -363,3 +368,10 @@ declare global {
"ha-combo-box": HaComboBox;
}
}

declare global {
interface HASSDomEvents {
"filter-changed": { value: string };
"opened-changed": { value: boolean };
}
}
185 changes: 104 additions & 81 deletions src/components/ha-icon-picker.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
keywords: string[];
};
let iconItems: IconItem[] = [];
let iconLoaded = false;

// eslint-disable-next-line lit/prefer-static-styles
const rowRenderer: ComboBoxLitRenderer<IconItem> = (item) => html`<mwc-list-item
graphic="avatar"
>
<ha-icon .icon=${item.icon} slot="graphic"></ha-icon>
${item.icon}
</mwc-list-item>`;
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<IconItem[]>[] = [];
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<IconItem | RankedIcon> = (item) =>
html`<mwc-list-item graphic="avatar">
<ha-icon .icon=${item.icon} slot="graphic"></ha-icon>
${item.icon}
</mwc-list-item>`;

@customElement("ha-icon-picker")
export class HaIconPicker extends LitElement {
Expand All @@ -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`
<ha-combo-box
Expand All @@ -58,7 +102,7 @@ export class HaIconPicker extends LitElement {
item-label-path="icon"
.value=${this._value}
allow-custom-value
.filteredItems=${iconItems}
.dataProvider=${ICONS_LOADED ? this._iconProvider : undefined}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
Expand All @@ -70,7 +114,6 @@ export class HaIconPicker extends LitElement {
icon
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
>
${this._value || this.placeholder
? html`
Expand All @@ -87,48 +130,57 @@ export class HaIconPicker extends LitElement {
`;
}

private async _openedChanged(ev: PolymerChangedEvent<boolean>) {
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<IconItem | RankedIcon>
) => {
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<boolean>) {
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<string>) {
ev.stopPropagation();
this._setValue(ev.detail.value);
Expand All @@ -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 || "";
}
Expand Down

0 comments on commit faa57e4

Please sign in to comment.