Improve category picker UI and search (#25560)

This commit is contained in:
Paul Bottein 2025-05-22 18:14:26 +02:00 committed by GitHub
parent 3355986585
commit 399458f811
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 190 additions and 198 deletions

View File

@ -116,23 +116,26 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
} }
); );
private _valueRenderer: PickerValueRenderer = (value) => { private _computeValueRenderer = memoizeOne(
const label = this._labelMap(this._labels).get(value); (labels: LabelRegistryEntry[] | undefined): PickerValueRenderer =>
(value) => {
const label = this._labelMap(labels).get(value);
if (!label) { if (!label) {
return html` return html`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span> <span slot="headline">${value}</span>
`; `;
} }
return html` return html`
${label.icon ${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>` ? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`} : html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span> <span slot="headline">${label.name}</span>
`; `;
}; }
);
private _getLabels = memoizeOne( private _getLabels = memoizeOne(
( (
@ -388,6 +391,8 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.placeholder ?? this.placeholder ??
this.hass.localize("ui.components.label-picker.label"); this.hass.localize("ui.components.label-picker.label");
const valueRenderer = this._computeValueRenderer(this._labels);
return html` return html`
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
@ -400,7 +405,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${this._valueRenderer} .valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

@ -1,17 +1,14 @@
import { mdiTag } from "@mdi/js"; import { mdiTag, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit"; import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import type { ScorableTextItem } from "../../../common/string/filter/sequence-matching"; import "../../../components/ha-generic-picker";
import { fuzzyFilterSort } from "../../../common/string/filter/sequence-matching"; import type { HaGenericPicker } from "../../../components/ha-generic-picker";
import "../../../components/ha-combo-box"; import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import type { HaComboBox } from "../../../components/ha-combo-box"; import type { PickerValueRenderer } from "../../../components/ha-picker-field";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import type { CategoryRegistryEntry } from "../../../data/category_registry"; import type { CategoryRegistryEntry } from "../../../data/category_registry";
import { import {
@ -22,20 +19,8 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, ValueChangedEvent } from "../../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { showCategoryRegistryDetailDialog } from "./show-dialog-category-registry-detail"; import { showCategoryRegistryDetailDialog } from "./show-dialog-category-registry-detail";
type ScorableCategoryRegistryEntry = ScorableTextItem & CategoryRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const NO_CATEGORIES_ID = "___NO_CATEGORIES___"; const NO_CATEGORIES_ID = "___NO_CATEGORIES___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
const rowRenderer: ComboBoxLitRenderer<CategoryRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiTag} slot="start"></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
`;
@customElement("ha-category-picker") @customElement("ha-category-picker")
export class HaCategoryPicker extends SubscribeMixin(LitElement) { export class HaCategoryPicker extends SubscribeMixin(LitElement) {
@ -58,14 +43,17 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _categories?: CategoryRegistryEntry[]; @state() private _categories?: CategoryRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox; @query("ha-generic-picker") private _picker?: HaGenericPicker;
protected hassSubscribeRequiredHostProps = ["scope"]; protected hassSubscribeRequiredHostProps = ["scope"];
public async open() {
await this.updateComplete;
await this._picker?.open();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] { protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [ return [
subscribeCategoryRegistry( subscribeCategoryRegistry(
@ -78,186 +66,185 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
]; ];
} }
private _suggestion?: string; private _categoryMap = memoizeOne(
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getCategories = memoizeOne(
( (
categories: CategoryRegistryEntry[] | undefined, categories: CategoryRegistryEntry[] | undefined
noAdd: this["noAdd"] ): Map<string, CategoryRegistryEntry> => {
): CategoryRegistryEntry[] => { if (!categories) {
const result = categories ? [...categories] : []; return new Map();
if (!result?.length) {
result.push({
category_id: NO_CATEGORIES_ID,
name: this.hass.localize(
"ui.components.category-picker.no_categories"
),
icon: null,
});
} }
return new Map(
return noAdd categories.map((category) => [category.category_id, category])
? result );
: [
...result,
{
category_id: ADD_NEW_ID,
name: this.hass.localize("ui.components.category-picker.add_new"),
icon: "mdi:plus",
},
];
} }
); );
protected updated(changedProps: PropertyValues) { private _computeValueRenderer = memoizeOne(
if ( (categories: CategoryRegistryEntry[] | undefined): PickerValueRenderer =>
(!this._init && this.hass && this._categories) || (value) => {
(this._init && changedProps.has("_opened") && this._opened) const category = this._categoryMap(categories).get(value);
) {
this._init = true;
const categories = this._getCategories(this._categories, this.noAdd).map(
(label) => ({
...label,
strings: [label.name],
})
);
this.comboBox.items = categories;
this.comboBox.filteredItems = categories;
}
}
protected render() { if (!category) {
if (!this._categories) { return html`
return nothing; <ha-svg-icon slot="start" .path=${mdiTag}></ha-svg-icon>
} <span slot="headline">${value}</span>
return html` `;
<ha-combo-box }
.hass=${this.hass}
.helper=${this.helper}
item-value-path="category_id"
item-id-path="category_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.category-picker.category")
: this.label}
.placeholder=${this.placeholder}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._categoryChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void { return html`
const target = ev.target as HaComboBox; ${category.icon
const filterString = ev.detail.value; ? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
if (!filterString) { : html`<ha-svg-icon slot="start" .path=${mdiTag}></ha-svg-icon>`}
this.comboBox.filteredItems = this.comboBox.items; <span slot="headline">${category.name}</span>
return; `;
} }
);
const filteredItems = fuzzyFilterSort<ScorableCategoryRegistryEntry>( private _getCategories = memoizeOne(
filterString, (categories: CategoryRegistryEntry[] | undefined): PickerComboBoxItem[] => {
target.items?.filter( if (!categories || categories.length === 0) {
(item) => ![NO_CATEGORIES_ID, ADD_NEW_ID].includes(item.category_id) return [
) || []
);
if (filteredItems?.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{ {
category_id: NO_CATEGORIES_ID, id: NO_CATEGORIES_ID,
name: this.hass.localize("ui.components.category-picker.no_match"), primary: this.hass.localize(
icon: null, "ui.components.category-picker.no_categories"
},
] as ScorableCategoryRegistryEntry[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
category_id: ADD_NEW_SUGGESTION_ID,
name: this.hass.localize(
"ui.components.category-picker.add_new_sugestion",
{ name: this._suggestion }
), ),
icon: "mdi:plus", icon_path: mdiTag,
}, },
]; ];
} }
} else {
this.comboBox.filteredItems = filteredItems; const items = categories.map<PickerComboBoxItem>((category) => ({
id: category.category_id,
primary: category.name,
icon: category.icon || undefined,
icon_path: category.icon ? undefined : mdiTag,
sorting_label: category.name,
search_labels: [category.name, category.category_id].filter(
(v): v is string => Boolean(v)
),
}));
return items;
} }
} );
private get _value() { private _getItems = () => this._getCategories(this._categories);
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) { private _allCategoryNames = memoizeOne(
this._opened = ev.detail.value; (categories?: CategoryRegistryEntry[]) => {
} if (!categories) {
return [];
private _categoryChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_CATEGORIES_ID) {
newValue = "";
this.comboBox.setInputValue("");
return;
}
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
} }
return [
...new Set(
categories
.map((category) => category.name.toLowerCase())
.filter(Boolean) as string[]
),
];
}
);
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
}
const allCategoryNames = this._allCategoryNames(this._categories);
if (
searchString &&
!allCategoryNames.includes(searchString.toLowerCase())
) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.category-picker.add_new_sugestion",
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
}
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.category-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.category-picker.category");
const valueRenderer = this._computeValueRenderer(this._categories);
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.category-picker.no_match"
)}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (value === NO_CATEGORIES_ID) {
return; return;
} }
(ev.target as any).value = this._value; if (!value) {
this._setValue(undefined);
return;
}
this.hass.loadFragmentTranslation("config"); if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
showCategoryRegistryDetailDialog(this, { const suggestedName = value.substring(ADD_NEW_ID.length);
scope: this.scope!,
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values) => {
const category = await createCategoryRegistryEntry(
this.hass,
this.scope!,
values
);
this._categories = [...this._categories!, category];
this.comboBox.filteredItems = this._getCategories(
this._categories,
this.noAdd
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(category.category_id);
return category;
},
});
this._suggestion = undefined; showCategoryRegistryDetailDialog(this, {
this.comboBox.setInputValue(""); scope: this.scope!,
suggestedName: suggestedName,
createEntry: async (values) => {
const category = await createCategoryRegistryEntry(
this.hass,
this.scope!,
values
);
this._setValue(category.category_id);
return category;
},
});
return;
}
this._setValue(value);
} }
private _setValue(value?: string) { private _setValue(value?: string) {