diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index 68c3ed549f..16e51de099 100644 --- a/src/components/entity/ha-entities-picker.ts +++ b/src/components/entity/ha-entities-picker.ts @@ -4,8 +4,8 @@ import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import { isValidEntityId } from "../../common/entity/valid_entity_id"; import type { HomeAssistant, ValueChangedEvent } from "../../types"; -import type { HaEntityComboBoxEntityFilterFunc } from "./ha-entity-combo-box"; import "./ha-entity-picker"; +import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker"; @customElement("ha-entities-picker") class HaEntitiesPicker extends LitElement { @@ -72,7 +72,7 @@ class HaEntitiesPicker extends LitElement { public excludeEntities?: string[]; @property({ attribute: false }) - public entityFilter?: HaEntityComboBoxEntityFilterFunc; + public entityFilter?: HaEntityPickerEntityFilterFunc; @property({ attribute: false, type: Array }) public createDomains?: string[]; diff --git a/src/components/entity/ha-entity-combo-box.ts b/src/components/entity/ha-entity-combo-box.ts deleted file mode 100644 index 5d31baa43b..0000000000 --- a/src/components/entity/ha-entity-combo-box.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { mdiMagnify, mdiPlus } from "@mdi/js"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import Fuse from "fuse.js"; -import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { html, LitElement, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; -import { fireEvent } from "../../common/dom/fire_event"; -import { computeAreaName } from "../../common/entity/compute_area_name"; -import { computeDeviceName } from "../../common/entity/compute_device_name"; -import { computeDomain } from "../../common/entity/compute_domain"; -import { computeEntityName } from "../../common/entity/compute_entity_name"; -import { computeStateName } from "../../common/entity/compute_state_name"; -import { getEntityContext } from "../../common/entity/context/get_entity_context"; -import { isValidEntityId } from "../../common/entity/valid_entity_id"; -import { caseInsensitiveStringCompare } from "../../common/string/compare"; -import { computeRTL } from "../../common/util/compute_rtl"; -import { domainToName } from "../../data/integration"; -import type { HelperDomain } from "../../panels/config/helpers/const"; -import { isHelperDomain } from "../../panels/config/helpers/const"; -import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail"; -import { HaFuse } from "../../resources/fuse"; -import type { HomeAssistant, ValueChangedEvent } from "../../types"; -import "../ha-combo-box"; -import type { HaComboBox } from "../ha-combo-box"; -import "../ha-combo-box-item"; -import "../ha-icon-button"; -import "../ha-svg-icon"; -import "./state-badge"; - -interface EntityComboBoxItem { - // Force empty label to always display empty value by default in the search field - id: string; - label: ""; - primary: string; - secondary?: string; - domain_name?: string; - search_labels?: string[]; - sorting_label?: string; - icon_path?: string; - stateObj?: HassEntity; -} - -export type HaEntityComboBoxEntityFilterFunc = (entity: HassEntity) => boolean; - -const CREATE_ID = "___create-new-entity___"; -const NO_ENTITIES_ID = "___no-entities___"; - -@customElement("ha-entity-combo-box") -export class HaEntityComboBox extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - // eslint-disable-next-line lit/no-native-attributes - @property({ type: Boolean }) public autofocus = false; - - @property({ type: Boolean }) public disabled = false; - - @property({ type: Boolean }) public required = false; - - @property({ type: Boolean, attribute: "allow-custom-entity" }) - public allowCustomEntity; - - @property() public label?: string; - - @property() public value?: string; - - @property() public helper?: string; - - @property({ attribute: false, type: Array }) public createDomains?: string[]; - - /** - * Show entities from specific domains. - * @type {Array} - * @attr include-domains - */ - @property({ type: Array, attribute: "include-domains" }) - public includeDomains?: string[]; - - /** - * Show no entities of these domains. - * @type {Array} - * @attr exclude-domains - */ - @property({ type: Array, attribute: "exclude-domains" }) - public excludeDomains?: string[]; - - /** - * Show only entities of these device classes. - * @type {Array} - * @attr include-device-classes - */ - @property({ type: Array, attribute: "include-device-classes" }) - public includeDeviceClasses?: string[]; - - /** - * Show only entities with these unit of measuments. - * @type {Array} - * @attr include-unit-of-measurement - */ - @property({ type: Array, attribute: "include-unit-of-measurement" }) - public includeUnitOfMeasurement?: string[]; - - /** - * List of allowed entities to show. - * @type {Array} - * @attr include-entities - */ - @property({ type: Array, attribute: "include-entities" }) - public includeEntities?: string[]; - - /** - * List of entities to be excluded. - * @type {Array} - * @attr exclude-entities - */ - @property({ type: Array, attribute: "exclude-entities" }) - public excludeEntities?: string[]; - - @property({ attribute: false }) - public entityFilter?: HaEntityComboBoxEntityFilterFunc; - - @property({ attribute: "hide-clear-icon", type: Boolean }) - public hideClearIcon = false; - - @state() private _opened = false; - - @query("ha-combo-box", true) public comboBox!: HaComboBox; - - public async open() { - await this.updateComplete; - await this.comboBox?.open(); - } - - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } - - private _initialItems = false; - - private _items: EntityComboBoxItem[] = []; - - protected firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this.hass.loadBackendTranslation("title"); - } - - private _rowRenderer: ComboBoxLitRenderer = ( - item, - { index } - ) => { - const showEntityId = this.hass.userData?.showEntityIdPicker; - - return html` - - ${item.icon_path - ? html` - - ` - : html` - - `} - ${item.primary} - ${item.secondary - ? html`${item.secondary}` - : nothing} - ${item.stateObj && showEntityId - ? html` - - ${item.stateObj.entity_id} - - ` - : nothing} - ${item.domain_name && !showEntityId - ? html` -
- ${item.domain_name} -
- ` - : nothing} -
- `; - }; - - private _getItems = memoizeOne( - ( - _opened: boolean, - hass: this["hass"], - includeDomains: this["includeDomains"], - excludeDomains: this["excludeDomains"], - entityFilter: this["entityFilter"], - includeDeviceClasses: this["includeDeviceClasses"], - includeUnitOfMeasurement: this["includeUnitOfMeasurement"], - includeEntities: this["includeEntities"], - excludeEntities: this["excludeEntities"], - createDomains: this["createDomains"] - ): EntityComboBoxItem[] => { - let items: EntityComboBoxItem[] = []; - - let entityIds = Object.keys(hass.states); - - const createItems = createDomains?.length - ? createDomains.map((domain) => { - const primary = hass.localize( - "ui.components.entity.entity-picker.create_helper", - { - domain: isHelperDomain(domain) - ? hass.localize( - `ui.panel.config.helpers.types.${domain as HelperDomain}` - ) - : domainToName(hass.localize, domain), - } - ); - - return { - id: CREATE_ID + domain, - label: "", - primary: primary, - secondary: this.hass.localize( - "ui.components.entity.entity-picker.new_entity" - ), - icon_path: mdiPlus, - } satisfies EntityComboBoxItem; - }) - : []; - - if (!entityIds.length) { - return [ - { - id: NO_ENTITIES_ID, - label: "", - primary: this.hass!.localize( - "ui.components.entity.entity-picker.no_entities" - ), - icon_path: mdiMagnify, - }, - ...createItems, - ]; - } - - if (includeEntities) { - entityIds = entityIds.filter((entityId) => - includeEntities.includes(entityId) - ); - } - - if (excludeEntities) { - entityIds = entityIds.filter( - (entityId) => !excludeEntities.includes(entityId) - ); - } - - if (includeDomains) { - entityIds = entityIds.filter((eid) => - includeDomains.includes(computeDomain(eid)) - ); - } - - if (excludeDomains) { - entityIds = entityIds.filter( - (eid) => !excludeDomains.includes(computeDomain(eid)) - ); - } - - const isRTL = computeRTL(this.hass); - - items = entityIds - .map((entityId) => { - const stateObj = hass!.states[entityId]; - - const { area, device } = getEntityContext(stateObj, hass); - - const friendlyName = computeStateName(stateObj); // Keep this for search - const entityName = computeEntityName(stateObj, hass); - const deviceName = device ? computeDeviceName(device) : undefined; - const areaName = area ? computeAreaName(area) : undefined; - - const domainName = domainToName( - this.hass.localize, - computeDomain(entityId) - ); - - const primary = entityName || deviceName || entityId; - const secondary = [areaName, entityName ? deviceName : undefined] - .filter(Boolean) - .join(isRTL ? " ◂ " : " ▸ "); - - return { - id: entityId, - label: "", - primary: primary, - secondary: secondary, - domain_name: domainName, - sorting_label: [deviceName, entityName].filter(Boolean).join("_"), - search_labels: [ - entityName, - deviceName, - areaName, - domainName, - friendlyName, - entityId, - ].filter(Boolean) as string[], - stateObj: stateObj, - }; - }) - .sort((entityA, entityB) => - caseInsensitiveStringCompare( - entityA.sorting_label!, - entityB.sorting_label!, - this.hass.locale.language - ) - ); - - if (includeDeviceClasses) { - items = items.filter( - (item) => - // We always want to include the entity of the current value - item.id === this.value || - (item.stateObj?.attributes.device_class && - includeDeviceClasses.includes( - item.stateObj.attributes.device_class - )) - ); - } - - if (includeUnitOfMeasurement) { - items = items.filter( - (item) => - // We always want to include the entity of the current value - item.id === this.value || - (item.stateObj?.attributes.unit_of_measurement && - includeUnitOfMeasurement.includes( - item.stateObj.attributes.unit_of_measurement - )) - ); - } - - if (entityFilter) { - items = items.filter( - (item) => - // We always want to include the entity of the current value - item.id === this.value || - (item.stateObj && entityFilter!(item.stateObj)) - ); - } - - if (!items.length) { - return [ - { - id: NO_ENTITIES_ID, - label: "", - primary: this.hass!.localize( - "ui.components.entity.entity-picker.no_match" - ), - icon_path: mdiMagnify, - }, - ...createItems, - ]; - } - - if (createItems?.length) { - items.push(...createItems); - } - - return items; - } - ); - - protected shouldUpdate(changedProps: PropertyValues) { - if ( - changedProps.has("value") || - changedProps.has("label") || - changedProps.has("disabled") - ) { - return true; - } - return !(!changedProps.has("_opened") && this._opened); - } - - public willUpdate(changedProps: PropertyValues) { - if (!this._initialItems || (changedProps.has("_opened") && this._opened)) { - this._items = this._getItems( - this._opened, - this.hass, - this.includeDomains, - this.excludeDomains, - this.entityFilter, - this.includeDeviceClasses, - this.includeUnitOfMeasurement, - this.includeEntities, - this.excludeEntities, - this.createDomains - ); - if (this._initialItems) { - this.comboBox.filteredItems = this._items; - } - this._initialItems = true; - } - - if (changedProps.has("createDomains") && this.createDomains?.length) { - this.hass.loadFragmentTranslation("config"); - } - } - - protected render(): TemplateResult { - return html` - - - `; - } - - private get _value() { - return this.value || ""; - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _valueChanged(ev: ValueChangedEvent) { - ev.stopPropagation(); - // Clear the input field to prevent showing the old value next time - this.comboBox.setTextFieldValue(""); - const newValue = ev.detail.value?.trim(); - - if (newValue && newValue.startsWith(CREATE_ID)) { - const domain = newValue.substring(CREATE_ID.length); - showHelperDetailDialog(this, { - domain, - dialogClosedCallback: (item) => { - if (item.entityId) this._setValue(item.entityId); - }, - }); - return; - } - - if (newValue !== this._value) { - this._setValue(newValue); - } - } - - private _fuseIndex = memoizeOne((states: EntityComboBoxItem[]) => - Fuse.createIndex(["search_labels"], states) - ); - - private _filterChanged(ev: CustomEvent): void { - if (!this._opened) return; - - const target = ev.target as HaComboBox; - const filterString = ev.detail.value.trim().toLowerCase() as string; - - const index = this._fuseIndex(this._items); - const fuse = new HaFuse(this._items, {}, index); - - const results = fuse.multiTermsSearch(filterString); - if (results) { - if (results.length === 0) { - target.filteredItems = [ - { - id: NO_ENTITIES_ID, - label: "", - primary: this.hass!.localize( - "ui.components.entity.entity-picker.no_match" - ), - icon_path: mdiMagnify, - }, - ] as EntityComboBoxItem[]; - } else { - target.filteredItems = results.map((result) => result.item); - } - } else { - target.filteredItems = this._items; - } - } - - private _setValue(value: string | undefined) { - if (!value || !isValidEntityId(value)) { - return; - } - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - }, 0); - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-entity-combo-box": HaEntityComboBox; - } -} diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 40e448bae2..e8055a985b 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -1,34 +1,42 @@ -import { mdiClose, mdiMenuDown, mdiShape } from "@mdi/js"; -import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; -import { - css, - html, - LitElement, - nothing, - type CSSResultGroup, - type PropertyValues, -} from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { mdiPlus, mdiShape } from "@mdi/js"; +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import type { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, nothing, type PropertyValues } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; -import { stopPropagation } from "../../common/dom/stop_propagation"; import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeDeviceName } from "../../common/entity/compute_device_name"; +import { computeDomain } from "../../common/entity/compute_domain"; import { computeEntityName } from "../../common/entity/compute_entity_name"; +import { computeStateName } from "../../common/entity/compute_state_name"; import { getEntityContext } from "../../common/entity/context/get_entity_context"; +import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { computeRTL } from "../../common/util/compute_rtl"; -import { debounce } from "../../common/util/debounce"; +import { domainToName } from "../../data/integration"; +import { + isHelperDomain, + type HelperDomain, +} from "../../panels/config/helpers/const"; +import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail"; import type { HomeAssistant } from "../../types"; import "../ha-combo-box-item"; -import "../ha-icon-button"; -import type { HaMdListItem } from "../ha-md-list-item"; +import "../ha-generic-picker"; +import type { HaGenericPicker } from "../ha-generic-picker"; +import type { PickerComboBoxItem } from "../ha-picker-combo-box"; +import type { PickerValueRenderer } from "../ha-picker-field"; import "../ha-svg-icon"; -import "./ha-entity-combo-box"; -import type { - HaEntityComboBox, - HaEntityComboBoxEntityFilterFunc, -} from "./ha-entity-combo-box"; import "./state-badge"; +interface EntityComboBoxItem extends PickerComboBoxItem { + domain_name?: string; + stateObj?: HassEntity; +} + +export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; + +const CREATE_ID = "___create-new-entity___"; + @customElement("ha-entity-picker") export class HaEntityPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -51,6 +59,9 @@ export class HaEntityPicker extends LitElement { @property() public placeholder?: string; + @property({ type: String, attribute: "search-label" }) + public searchLabel?: string; + @property({ attribute: false, type: Array }) public createDomains?: string[]; /** @@ -102,16 +113,12 @@ export class HaEntityPicker extends LitElement { public excludeEntities?: string[]; @property({ attribute: false }) - public entityFilter?: HaEntityComboBoxEntityFilterFunc; + public entityFilter?: HaEntityPickerEntityFilterFunc; @property({ attribute: "hide-clear-icon", type: Boolean }) public hideClearIcon = false; - @query("#anchor") private _anchor?: HaMdListItem; - - @query("#input") private _input?: HaEntityComboBox; - - @state() private _opened = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; protected firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); @@ -119,39 +126,19 @@ export class HaEntityPicker extends LitElement { this.hass.loadBackendTranslation("title"); } - private _renderContent() { - const entityId = this.value || ""; - - if (!this.value) { - return html` - ${this.placeholder ?? - this.hass.localize( - "ui.components.entity.entity-picker.placeholder" - )} - - `; - } + private _valueRenderer: PickerValueRenderer = (value) => { + const entityId = value || ""; const stateObj = this.hass.states[entityId]; - const showClearIcon = - !this.required && !this.disabled && !this.hideClearIcon; - if (!stateObj) { return html` - + ${entityId} - ${showClearIcon - ? html`` - : nothing} - `; } @@ -176,169 +163,282 @@ export class HaEntityPicker extends LitElement { > ${primary} ${secondary} - ${showClearIcon - ? html`` - : nothing} - `; - } + }; + + private _rowRenderer: ComboBoxLitRenderer = ( + item, + { index } + ) => { + const showEntityId = this.hass.userData?.showEntityIdPicker; - protected render() { return html` - ${this.label ? html`` : nothing} -
- ${!this._opened - ? html` - ${this._renderContent()} - ` - : html``} - ${this._renderHelper()} -
+ + ${item.icon_path + ? html` + + ` + : html` + + `} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${item.stateObj && showEntityId + ? html` + + ${item.stateObj.entity_id} + + ` + : nothing} + ${item.domain_name && !showEntityId + ? html` +
+ ${item.domain_name} +
+ ` + : nothing} +
`; - } + }; - private _renderHelper() { - return this.helper - ? html`${this.helper}` - : nothing; - } + private _getAdditionalItems = () => + this._getCreateItems(this.hass.localize, this.createDomains); - private _clear(e) { - e.stopPropagation(); - this.value = undefined; - fireEvent(this, "value-changed", { value: undefined }); - fireEvent(this, "change"); - } + private _getCreateItems = memoizeOne( + ( + localize: this["hass"]["localize"], + createDomains: this["createDomains"] + ) => { + if (!createDomains?.length) { + return []; + } - private async _showPicker() { - if (this.disabled) { - return; + return createDomains.map((domain) => { + const primary = localize( + "ui.components.entity.entity-picker.create_helper", + { + domain: isHelperDomain(domain) + ? localize( + `ui.panel.config.helpers.types.${domain as HelperDomain}` + ) + : domainToName(localize, domain), + } + ); + + return { + id: CREATE_ID + domain, + primary: primary, + secondary: localize("ui.components.entity.entity-picker.new_entity"), + icon_path: mdiPlus, + } satisfies EntityComboBoxItem; + }); } - this._opened = true; - await this.updateComplete; - this._input?.focus(); - this._input?.open(); - } - - // Multiple calls to _openedChanged can be triggered in quick succession - // when the menu is opened - private _debounceOpenedChanged = debounce( - (ev) => this._openedChanged(ev), - 10 ); - private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { - const opened = ev.detail.value; - if (this._opened && !opened) { - this._opened = false; - await this.updateComplete; - this._anchor?.focus(); + private _getItems = () => + this._getEntities( + this.hass, + this.includeDomains, + this.excludeDomains, + this.entityFilter, + this.includeDeviceClasses, + this.includeUnitOfMeasurement, + this.includeEntities, + this.excludeEntities + ); + + private _getEntities = memoizeOne( + ( + hass: this["hass"], + includeDomains: this["includeDomains"], + excludeDomains: this["excludeDomains"], + entityFilter: this["entityFilter"], + includeDeviceClasses: this["includeDeviceClasses"], + includeUnitOfMeasurement: this["includeUnitOfMeasurement"], + includeEntities: this["includeEntities"], + excludeEntities: this["excludeEntities"] + ): EntityComboBoxItem[] => { + let items: EntityComboBoxItem[] = []; + + let entityIds = Object.keys(hass.states); + + if (includeEntities) { + entityIds = entityIds.filter((entityId) => + includeEntities.includes(entityId) + ); + } + + if (excludeEntities) { + entityIds = entityIds.filter( + (entityId) => !excludeEntities.includes(entityId) + ); + } + + if (includeDomains) { + entityIds = entityIds.filter((eid) => + includeDomains.includes(computeDomain(eid)) + ); + } + + if (excludeDomains) { + entityIds = entityIds.filter( + (eid) => !excludeDomains.includes(computeDomain(eid)) + ); + } + + const isRTL = computeRTL(this.hass); + + items = entityIds.map((entityId) => { + const stateObj = hass!.states[entityId]; + + const { area, device } = getEntityContext(stateObj, hass); + + const friendlyName = computeStateName(stateObj); // Keep this for search + const entityName = computeEntityName(stateObj, hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const domainName = domainToName( + this.hass.localize, + computeDomain(entityId) + ); + + const primary = entityName || deviceName || entityId; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + return { + id: entityId, + primary: primary, + secondary: secondary, + domain_name: domainName, + sorting_label: [deviceName, entityName].filter(Boolean).join("_"), + search_labels: [ + entityName, + deviceName, + areaName, + domainName, + friendlyName, + entityId, + ].filter(Boolean) as string[], + stateObj: stateObj, + }; + }); + + if (includeDeviceClasses) { + items = items.filter( + (item) => + // We always want to include the entity of the current value + item.id === this.value || + (item.stateObj?.attributes.device_class && + includeDeviceClasses.includes( + item.stateObj.attributes.device_class + )) + ); + } + + if (includeUnitOfMeasurement) { + items = items.filter( + (item) => + // We always want to include the entity of the current value + item.id === this.value || + (item.stateObj?.attributes.unit_of_measurement && + includeUnitOfMeasurement.includes( + item.stateObj.attributes.unit_of_measurement + )) + ); + } + + if (entityFilter) { + items = items.filter( + (item) => + // We always want to include the entity of the current value + item.id === this.value || + (item.stateObj && entityFilter!(item.stateObj)) + ); + } + + return items; } + ); + + protected render() { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.entity.entity-picker.placeholder"); + const notFoundLabel = this.hass.localize( + "ui.components.entity.entity-picker.no_match" + ); + + return html` + + + `; } - static get styles(): CSSResultGroup { - return [ - css` - mwc-menu-surface { - --mdc-menu-min-width: 100%; - } - .container { - position: relative; - display: block; - } - ha-combo-box-item { - background-color: var(--mdc-text-field-fill-color, whitesmoke); - border-radius: 4px; - border-end-end-radius: 0; - border-end-start-radius: 0; - --md-list-item-one-line-container-height: 56px; - --md-list-item-two-line-container-height: 56px; - --md-list-item-top-space: 8px; - --md-list-item-bottom-space: 8px; - --md-list-item-leading-space: 8px; - --md-list-item-trailing-space: 8px; - --ha-md-list-item-gap: 8px; - /* Remove the default focus ring */ - --md-focus-ring-width: 0px; - --md-focus-ring-duration: 0s; - } + public async open() { + this._picker?.open(); + } - /* Add Similar focus style as the text field */ - ha-combo-box-item:after { - display: block; - content: ""; - position: absolute; - pointer-events: none; - bottom: 0; - left: 0; - right: 0; - height: 1px; - width: 100%; - background-color: var( - --mdc-text-field-idle-line-color, - rgba(0, 0, 0, 0.42) - ); - transform: - height 180ms ease-in-out, - background-color 180ms ease-in-out; - } + private _valueChanged(ev) { + ev.stopPropagation(); + const value = ev.detail.value; - ha-combo-box-item:focus:after { - height: 2px; - background-color: var(--mdc-theme-primary); - } + if (!value) { + this._setValue(undefined); + return; + } - ha-combo-box-item ha-svg-icon[slot="start"] { - margin: 0 4px; - } - .clear { - margin: 0 -8px; - --mdc-icon-button-size: 32px; - --mdc-icon-size: 20px; - } - .edit { - --mdc-icon-size: 20px; - width: 32px; - } - label { - display: block; - margin: 0 0 8px; - } - .placeholder { - color: var(--secondary-text-color); - padding: 0 8px; - } - `, - ]; + if (value.startsWith(CREATE_ID)) { + const domain = value.substring(CREATE_ID.length); + + showHelperDetailDialog(this, { + domain, + dialogClosedCallback: (item) => { + if (item.entityId) this._setValue(item.entityId); + }, + }); + return; + } + + if (!isValidEntityId(value)) { + return; + } + + this._setValue(value); + } + + private _setValue(value: string | undefined) { + this.value = value; + + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); } } diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index f0dbdd0872..b3c9145055 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -28,11 +28,11 @@ import { import type { HomeAssistant } from "../../types"; import "../ha-combo-box-item"; import "../ha-icon-button"; +import "../ha-input-helper-text"; import type { HaMdListItem } from "../ha-md-list-item"; import "../ha-svg-icon"; -import "./ha-entity-combo-box"; -import type { HaEntityComboBox } from "./ha-entity-combo-box"; import "./ha-statistic-combo-box"; +import type { HaStatisticComboBox } from "./ha-statistic-combo-box"; import "./state-badge"; interface StatisticItem { @@ -116,7 +116,7 @@ export class HaStatisticPicker extends LitElement { @query("#anchor") private _anchor?: HaMdListItem; - @query("#input") private _input?: HaEntityComboBox; + @query("#input") private _input?: HaStatisticComboBox; @state() private _opened = false; diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 5a77caf84a..5cbd57a26e 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -369,7 +369,7 @@ export class HaAreaPicker extends LitElement { ) || [] ); if (filteredItems.length === 0) { - if (!this.noAdd) { + if (this.noAdd) { this.comboBox.filteredItems = [ { area_id: NO_ITEMS_ID, diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts index 93c9348f4b..9804ebe774 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -246,8 +246,8 @@ export class HaComboBox extends LitElement { // delay this so we can handle click event for toggle button before setting _opened setTimeout(() => { this.opened = opened; + fireEvent(this, "opened-changed", { value: ev.detail.value }); }, 0); - fireEvent(this, "opened-changed", { value: ev.detail.value }); if (opened) { const overlay = document.querySelector( diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts new file mode 100644 index 0000000000..38ba4487b1 --- /dev/null +++ b/src/components/ha-generic-picker.ts @@ -0,0 +1,174 @@ +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; +import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import type { HomeAssistant } from "../types"; +import "./ha-combo-box-item"; +import "./ha-icon-button"; +import "./ha-input-helper-text"; +import "./ha-picker-combo-box"; +import type { + HaPickerComboBox, + PickerComboBoxItem, +} from "./ha-picker-combo-box"; +import "./ha-picker-field"; +import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field"; +import "./ha-svg-icon"; + +@customElement("ha-generic-picker") +export class HaGenericPicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) public autofocus = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property({ type: Boolean, attribute: "allow-custom-value" }) + public allowCustomValue; + + @property() public label?: string; + + @property() public value?: string; + + @property() public helper?: string; + + @property() public placeholder?: string; + + @property({ type: String, attribute: "search-label" }) + public searchLabel?: string; + + @property({ attribute: "hide-clear-icon", type: Boolean }) + public hideClearIcon = false; + + @property({ attribute: false, type: Array }) + public getItems?: () => PickerComboBoxItem[]; + + @property({ attribute: false, type: Array }) + public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; + + @property({ attribute: false }) + public rowRenderer?: ComboBoxLitRenderer; + + @property({ attribute: false }) + public valueRenderer?: PickerValueRenderer; + + @property({ attribute: "not-found-label", type: String }) + public notFoundLabel?: string; + + @query("ha-picker-field") private _field?: HaPickerField; + + @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; + + @state() private _opened = false; + + protected render() { + return html` + ${this.label ? html`` : nothing} +
+ ${!this._opened + ? html` + + + ` + : html` + + `} + ${this._renderHelper()} +
+ `; + } + + private _renderHelper() { + return this.helper + ? html`${this.helper}` + : nothing; + } + + private _valueChanged(ev: CustomEvent) { + ev.stopPropagation(); + const value = ev.detail.value; + if (!value) { + return; + } + fireEvent(this, "value-changed", { value }); + } + + private _clear(e) { + e.stopPropagation(); + this._setValue(undefined); + } + + private _setValue(value: string | undefined) { + this.value = value; + fireEvent(this, "value-changed", { value }); + } + + public async open() { + if (this.disabled) { + return; + } + this._opened = true; + await this.updateComplete; + this._comboBox?.focus(); + this._comboBox?.open(); + } + + private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { + const opened = ev.detail.value; + if (this._opened && !opened) { + this._opened = false; + await this.updateComplete; + this._field?.focus(); + } + } + + static get styles(): CSSResultGroup { + return [ + css` + .container { + position: relative; + display: block; + } + label { + display: block; + margin: 0 0 8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-generic-picker": HaGenericPicker; + } +} diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts new file mode 100644 index 0000000000..95ab8fcbc6 --- /dev/null +++ b/src/components/ha-picker-combo-box.ts @@ -0,0 +1,260 @@ +import { mdiMagnify } from "@mdi/js"; +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import Fuse from "fuse.js"; +import type { PropertyValues, TemplateResult } from "lit"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { caseInsensitiveStringCompare } from "../common/string/compare"; +import type { LocalizeFunc } from "../common/translations/localize"; +import { HaFuse } from "../resources/fuse"; +import type { HomeAssistant, ValueChangedEvent } from "../types"; +import "./ha-combo-box"; +import type { HaComboBox } from "./ha-combo-box"; +import "./ha-combo-box-item"; +import "./ha-icon"; + +export interface PickerComboBoxItem { + id: string; + primary: string; + secondary?: string; + search_labels?: string[]; + sorting_label?: string; + icon_path?: string; + icon?: string; +} + +// Hack to force empty label to always display empty value by default in the search field +export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem { + label: ""; +} + +const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___"; + +const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer = ( + item +) => html` + + ${item.icon + ? html`` + : item.icon_path + ? html`` + : nothing} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + +`; + +@customElement("ha-picker-combo-box") +export class HaPickerComboBox extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) public autofocus = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property({ type: Boolean, attribute: "allow-custom-value" }) + public allowCustomValue; + + @property() public label?: string; + + @property() public value?: string; + + @property() public helper?: string; + + @property({ attribute: false, type: Array }) + public getItems?: () => PickerComboBoxItem[]; + + @property({ attribute: false, type: Array }) + public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; + + @property({ attribute: false }) + public rowRenderer?: ComboBoxLitRenderer; + + @property({ attribute: "hide-clear-icon", type: Boolean }) + public hideClearIcon = false; + + @property({ attribute: "not-found-label", type: String }) + public notFoundLabel?: string; + + @state() private _opened = false; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + private _initialItems = false; + + private _items: PickerComboBoxItemWithLabel[] = []; + + private _defaultNotFoundItem = memoizeOne( + ( + label: this["notFoundLabel"], + localize: LocalizeFunc + ): PickerComboBoxItemWithLabel => ({ + id: NO_MATCHING_ITEMS_FOUND_ID, + primary: label || localize("ui.components.combo-box.no_match"), + icon_path: mdiMagnify, + label: "", + }) + ); + + private _getAdditionalItems = (searchString?: string) => { + const items = this.getAdditionalItems?.(searchString) || []; + + return items.map((item) => ({ + ...item, + label: "", + })); + }; + + private _getItems = (): PickerComboBoxItemWithLabel[] => { + const items = this.getItems ? this.getItems() : []; + + const sortedItems = items + .map((item) => ({ + ...item, + label: "", + })) + .sort((entityA, entityB) => + caseInsensitiveStringCompare( + entityA.sorting_label!, + entityB.sorting_label!, + this.hass.locale.language + ) + ); + + if (!sortedItems.length) { + sortedItems.push( + this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) + ); + } + + const additionalItems = this._getAdditionalItems(); + sortedItems.push(...additionalItems); + return sortedItems; + }; + + protected shouldUpdate(changedProps: PropertyValues) { + if ( + changedProps.has("value") || + changedProps.has("label") || + changedProps.has("disabled") + ) { + return true; + } + return !(!changedProps.has("_opened") && this._opened); + } + + public willUpdate(changedProps: PropertyValues) { + if (changedProps.has("_opened") && this._opened) { + this._items = this._getItems(); + if (this._initialItems) { + this.comboBox.filteredItems = this._items; + } + this._initialItems = true; + } + } + + protected render(): TemplateResult { + return html` + + + `; + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + if (ev.detail.value !== this._opened) { + this._opened = ev.detail.value; + fireEvent(this, "opened-changed", { value: this._opened }); + } + } + + private _valueChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + // Clear the input field to prevent showing the old value next time + this.comboBox.setTextFieldValue(""); + const newValue = ev.detail.value?.trim(); + + if (newValue !== this._value) { + this._setValue(newValue); + } + } + + private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => + Fuse.createIndex(["search_labels"], states) + ); + + private _filterChanged(ev: CustomEvent): void { + if (!this._opened) return; + + const target = ev.target as HaComboBox; + const searchString = ev.detail.value.trim() as string; + + const index = this._fuseIndex(this._items); + const fuse = new HaFuse(this._items, {}, index); + + const results = fuse.multiTermsSearch(searchString); + if (results) { + const items = results.map((result) => result.item); + if (items.length === 0) { + items.push( + this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) + ); + } + const additionalItems = this._getAdditionalItems(searchString); + items.push(...additionalItems); + target.filteredItems = items; + } else { + target.filteredItems = this._items; + } + } + + private _setValue(value: string | undefined) { + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + }, 0); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-picker-combo-box": HaPickerComboBox; + } +} diff --git a/src/components/ha-picker-field.ts b/src/components/ha-picker-field.ts new file mode 100644 index 0000000000..62663d2dfd --- /dev/null +++ b/src/components/ha-picker-field.ts @@ -0,0 +1,156 @@ +import { mdiClose, mdiMenuDown } from "@mdi/js"; +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type TemplateResult, +} from "lit"; +import { customElement, property, query } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import "./ha-combo-box-item"; +import type { HaComboBoxItem } from "./ha-combo-box-item"; +import "./ha-icon-button"; + +declare global { + interface HASSDomEvents { + clear: undefined; + } +} + +export type PickerValueRenderer = (value: string) => TemplateResult<1>; + +@customElement("ha-picker-field") +export class HaPickerField extends LitElement { + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property() public value?: string; + + @property() public helper?: string; + + @property() public placeholder?: string; + + @property({ attribute: "hide-clear-icon", type: Boolean }) + public hideClearIcon = false; + + @property({ attribute: false }) + public valueRenderer?: PickerValueRenderer; + + @query("ha-combo-box-item", true) public item!: HaComboBoxItem; + + public async focus() { + await this.updateComplete; + await this.item?.focus(); + } + + protected render() { + const showClearIcon = + !!this.value && !this.required && !this.disabled && !this.hideClearIcon; + + return html` + + ${this.value + ? this.valueRenderer + ? this.valueRenderer(this.value) + : html`${this.value}` + : html` + + ${this.placeholder} + + `} + ${showClearIcon + ? html` + + ` + : nothing} + + + `; + } + + private _clear(e) { + e.stopPropagation(); + fireEvent(this, "clear"); + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-combo-box-item { + background-color: var(--mdc-text-field-fill-color, whitesmoke); + border-radius: 4px; + border-end-end-radius: 0; + border-end-start-radius: 0; + --md-list-item-one-line-container-height: 56px; + --md-list-item-two-line-container-height: 56px; + --md-list-item-top-space: 8px; + --md-list-item-bottom-space: 8px; + --md-list-item-leading-space: 8px; + --md-list-item-trailing-space: 8px; + --ha-md-list-item-gap: 8px; + /* Remove the default focus ring */ + --md-focus-ring-width: 0px; + --md-focus-ring-duration: 0s; + } + + /* Add Similar focus style as the text field */ + ha-combo-box-item:after { + display: block; + content: ""; + position: absolute; + pointer-events: none; + bottom: 0; + left: 0; + right: 0; + height: 1px; + width: 100%; + background-color: var( + --mdc-text-field-idle-line-color, + rgba(0, 0, 0, 0.42) + ); + transform: + height 180ms ease-in-out, + background-color 180ms ease-in-out; + } + + ha-combo-box-item:focus:after { + height: 2px; + background-color: var(--mdc-theme-primary); + } + + .clear { + margin: 0 -8px; + --mdc-icon-button-size: 32px; + --mdc-icon-size: 20px; + } + .arrow { + --mdc-icon-size: 20px; + width: 32px; + } + + .placeholder { + color: var(--secondary-text-color); + padding: 0 8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-picker-field": HaPickerField; + } +} diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 68324deacf..20fd210484 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -39,12 +39,13 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin"; import type { HomeAssistant } from "../types"; import "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; -import "./entity/ha-entity-combo-box"; -import type { HaEntityComboBoxEntityFilterFunc } from "./entity/ha-entity-combo-box"; +import "./entity/ha-entity-picker"; +import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker"; import "./ha-area-floor-picker"; import { floorDefaultIconPath } from "./ha-floor-icon"; import "./ha-icon-button"; import "./ha-input-helper-text"; +import "./ha-label-picker"; import "./ha-svg-icon"; import "./ha-tooltip"; @@ -80,7 +81,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { public deviceFilter?: HaDevicePickerDeviceFilterFunc; @property({ attribute: false }) - public entityFilter?: HaEntityComboBoxEntityFilterFunc; + public entityFilter?: HaEntityPickerEntityFilterFunc; @property({ type: Boolean, reflect: true }) public disabled = false; @@ -384,12 +385,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { if (!this._addMode) { return nothing; } + return html`${this._addMode === "area_id" ? html` @@ -408,6 +409,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .excludeAreas=${ensureArray(this.value?.area_id)} .excludeFloors=${ensureArray(this.value?.floor_id)} @value-changed=${this._targetPicked} + @opened-changed=${this._openedChanged} @click=${this._preventDefault} > ` @@ -426,6 +428,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .includeDomains=${this.includeDomains} .excludeDevices=${ensureArray(this.value?.device_id)} @value-changed=${this._targetPicked} + @opened-changed=${this._openedChanged} @click=${this._preventDefault} > ` @@ -445,15 +448,19 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .includeDomains=${this.includeDomains} .excludeLabels=${ensureArray(this.value?.label_id)} @value-changed=${this._targetPicked} + @opened-changed=${this._openedChanged} @click=${this._preventDefault} > ` : html` - + > `}`; + > `; } private _targetPicked(ev) { @@ -839,7 +847,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { mwc-menu-surface { --mdc-menu-min-width: 100%; } - ha-entity-combo-box, + ha-entity-picker, ha-device-picker, ha-area-floor-picker { display: block; diff --git a/src/data/logbook.ts b/src/data/logbook.ts index 5a9d4b65c2..d57e27fd26 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -8,9 +8,9 @@ import { computeDomain } from "../common/entity/compute_domain"; import { computeStateDomain } from "../common/entity/compute_state_domain"; import { autoCaseNoun } from "../common/translations/auto_case_noun"; import type { LocalizeFunc } from "../common/translations/localize"; +import type { HaEntityPickerEntityFilterFunc } from "../components/entity/ha-entity-picker"; import type { HomeAssistant } from "../types"; import { UNAVAILABLE, UNKNOWN } from "./entity"; -import type { HaEntityComboBoxEntityFilterFunc } from "../components/entity/ha-entity-combo-box"; const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; export const CONTINUOUS_DOMAINS = ["counter", "proximity", "sensor", "zone"]; @@ -322,8 +322,9 @@ export const localizeStateMessage = ( }); }; -export const filterLogbookCompatibleEntities: HaEntityComboBoxEntityFilterFunc = - (entity) => - computeStateDomain(entity) !== "sensor" || - (entity.attributes.unit_of_measurement === undefined && - entity.attributes.state_class === undefined); +export const filterLogbookCompatibleEntities: HaEntityPickerEntityFilterFunc = ( + entity +) => + computeStateDomain(entity) !== "sensor" || + (entity.attributes.unit_of_measurement === undefined && + entity.attributes.state_class === undefined); diff --git a/src/panels/lovelace/components/hui-entity-editor.ts b/src/panels/lovelace/components/hui-entity-editor.ts index b05baf94a0..7e77ffa041 100644 --- a/src/panels/lovelace/components/hui-entity-editor.ts +++ b/src/panels/lovelace/components/hui-entity-editor.ts @@ -3,9 +3,11 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; import { fireEvent } from "../../../common/dom/fire_event"; -import type { HaEntityComboBoxEntityFilterFunc } from "../../../components/entity/ha-entity-combo-box"; import "../../../components/entity/ha-entity-picker"; -import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker"; +import type { + HaEntityPicker, + HaEntityPickerEntityFilterFunc, +} from "../../../components/entity/ha-entity-picker"; import "../../../components/ha-icon-button"; import "../../../components/ha-sortable"; import type { HomeAssistant } from "../../../types"; @@ -18,7 +20,7 @@ export class HuiEntityEditor extends LitElement { @property({ attribute: false }) public entities?: EntityConfig[]; @property({ attribute: false }) - public entityFilter?: HaEntityComboBoxEntityFilterFunc; + public entityFilter?: HaEntityPickerEntityFilterFunc; @property() public label?: string; diff --git a/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts index ce852a172b..330518c93e 100644 --- a/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts @@ -8,8 +8,8 @@ import { fireEvent } from "../../../../common/dom/fire_event"; import { preventDefault } from "../../../../common/dom/prevent_default"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { computeStateName } from "../../../../common/entity/compute_state_name"; -import type { HaEntityComboBox } from "../../../../components/entity/ha-entity-combo-box"; import "../../../../components/entity/ha-entity-picker"; +import type { HaEntityPicker } from "../../../../components/entity/ha-entity-picker"; import "../../../../components/ha-button"; import "../../../../components/ha-icon-button"; import "../../../../components/ha-sortable"; @@ -33,7 +33,7 @@ export class HuiHeadingBadgesEditor extends LitElement { @query(".add-container", true) private _addContainer?: HTMLDivElement; - @query("ha-entity-combo-box") private _entityCombobox?: HaEntityComboBox; + @query("ha-entity-picker") private _entityPicker?: HaEntityPicker; @state() private _addMode = false; @@ -144,16 +144,19 @@ export class HuiHeadingBadgesEditor extends LitElement { @opened-changed=${this._openedChanged} @input=${stopPropagation} > - + > `; } @@ -167,8 +170,8 @@ export class HuiHeadingBadgesEditor extends LitElement { if (!this._addMode) { return; } - await this._entityCombobox?.focus(); - await this._entityCombobox?.open(); + await this._entityPicker?.focus(); + await this._entityPicker?.open(); this._opened = true; } diff --git a/src/resources/fuse.ts b/src/resources/fuse.ts index b27b5b4ab6..390dc0fa52 100644 --- a/src/resources/fuse.ts +++ b/src/resources/fuse.ts @@ -50,7 +50,7 @@ export class HaFuse extends Fuse { search: string, options?: FuseSearchOptions ): FuseResult[] | null { - const terms = search.split(" "); + const terms = search.toLowerCase().split(" "); // @ts-expect-error options is not part of the Fuse type const { minMatchCharLength } = this.options as IFuseOptions; diff --git a/src/translations/en.json b/src/translations/en.json index f8166bdb31..ecfa46a26e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1150,6 +1150,9 @@ }, "form-optional-actions": { "add": "Add interaction" + }, + "combo-box": { + "no_match": "No matching items found" } }, "dialogs": {