Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 88 additions & 85 deletions src/components/ha-icon-picker.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,40 @@
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<string>;
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<IconItem[]>[] = [];
const customIconLoads: Promise<PickerComboBoxItem[]>[] = [];
Object.keys(customIcons).forEach((iconSet) => {
customIconLoads.push(loadCustomIconItems(iconSet));
});
Expand All @@ -47,18 +43,25 @@ const loadIcons = async () => {
});
};

const loadCustomIconItems = async (iconsetPrefix: string) => {
const loadCustomIconItems = async (
iconsetPrefix: string
): Promise<PickerComboBoxItem[]> => {
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
Expand All @@ -67,13 +70,18 @@ const loadCustomIconItems = async (iconsetPrefix: string) => {
}
};

const rowRenderer: ComboBoxLitRenderer<IconItem | RankedIcon> = (item) => html`
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button">
<ha-icon .icon=${item.icon} slot="start"></ha-icon>
${item.icon}
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
</ha-combo-box-item>
`;

const valueRenderer = (value: string) => html`
<ha-icon .icon=${value} slot="start"></ha-icon>
<span slot="headline">${value}</span>
`;

@customElement("ha-icon-picker")
export class HaIconPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
Expand All @@ -96,83 +104,84 @@ export class HaIconPicker extends LitElement {

protected render(): TemplateResult {
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
item-value-path="icon"
item-label-path="icon"
.value=${this._value}
allow-custom-value
.dataProvider=${ICONS_LOADED ? this._iconProvider : undefined}
.getItems=${this._getItems}
.label=${this.label}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new picker label looks out of place next to textfields now

Image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original for reference:

Image

the icon is also missing, this is due to the placeholder vs value problem

The main issue for me is the label being outside the textfield now. Maybe an alternative design could be used here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if all labels should be outside the field for consistency. We will have to do it when migrating to web awesome, right?

.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${this.placeholder}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.renderer=${rowRenderer}
icon
@opened-changed=${this._openedChanged}
.rowRenderer=${rowRenderer}
.valueRenderer=${valueRenderer}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
${this._value || this.placeholder
? html`
<ha-icon .icon=${this._value || this.placeholder} slot="icon">
</ha-icon>
`
: html`<slot slot="icon" name="fallback"></slot>`}
</ha-combo-box>
</ha-generic-picker>
`;
}

// 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<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 _openedChanged(ev: ValueChangedEvent<boolean>) {
const opened = ev.detail.value;
if (opened && !ICONS_LOADED) {
await loadIcons();
this.requestUpdate();
private _getItems = (): PickerComboBoxItem[] => ICONS;

protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this.requestUpdate();
});
}
}

Expand All @@ -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;
}
`;
}
Expand Down
3 changes: 3 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading