diff --git a/landing-page/src/entrypoint.js b/landing-page/src/entrypoint.js index 4bf2a33b14..208000e053 100644 --- a/landing-page/src/entrypoint.js +++ b/landing-page/src/entrypoint.js @@ -1,3 +1,3 @@ import "./ha-landing-page"; -import("../../src/resources/ha-style"); +import("../../src/resources/append-ha-style"); diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index c847538cb2..16625aa81a 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 HaEntitiesPickerLight extends LitElement { @@ -73,7 +73,7 @@ class HaEntitiesPickerLight extends LitElement { @property({ attribute: "pick-entity-label" }) public pickEntityLabel?: string; @property({ attribute: false }) - public entityFilter?: HaEntityPickerEntityFilterFunc; + public entityFilter?: HaEntityComboBoxEntityFilterFunc; @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 new file mode 100644 index 0000000000..8deb6abc29 --- /dev/null +++ b/src/components/entity/ha-entity-combo-box.ts @@ -0,0 +1,522 @@ +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 { styleMap } from "lit/directives/style-map"; +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"; + +const FAKE_ENTITY: HassEntity = { + entity_id: "", + state: "", + last_changed: "", + last_updated: "", + context: { id: "", user_id: null, parent_id: null }, + attributes: {}, +}; + +interface EntityComboBoxItem extends HassEntity { + // Force empty label to always display empty value by default in the search field + label: ""; + primary: string; + secondary?: string; + translated_domain?: string; + show_entity_id?: boolean; + entity_name?: string; + area_name?: string; + device_name?: string; + friendly_name?: string; + sorting_label?: string; + icon_path?: string; +} + +export type HaEntityComboBoxEntityFilterFunc = (entity: HassEntity) => boolean; + +const CREATE_ID = "___create-new-entity___"; + +const DOMAIN_STYLE = styleMap({ + fontSize: "var(--ha-font-size-s)", + fontWeight: "var(--ha-font-weight-normal)", + lineHeight: "var(--ha-line-height-normal)", + alignSelf: "flex-end", + maxWidth: "30%", + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", +}); + +const ENTITY_ID_STYLE = styleMap({ + fontFamily: "var(--code-font-family, monospace)", + fontSize: "var(--ha-font-size-xs)", +}); + +@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 } + ) => html` + + ${item.icon_path + ? html`` + : html` + + `} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${item.entity_id && item.show_entity_id + ? html`${item.entity_id}` + : nothing} + ${item.translated_domain && !item.show_entity_id + ? html`
+ ${item.translated_domain} +
` + : 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 states: 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 { + ...FAKE_ENTITY, + label: "", + entity_id: CREATE_ID + domain, + primary: primary, + secondary: this.hass.localize( + "ui.components.entity.entity-picker.new_entity" + ), + icon_path: mdiPlus, + } satisfies EntityComboBoxItem; + }) + : []; + + if (!entityIds.length) { + return [ + { + ...FAKE_ENTITY, + 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); + + states = 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 primary = entityName || deviceName || entityId; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + const translatedDomain = domainToName( + this.hass.localize, + computeDomain(entityId) + ); + + return { + ...hass!.states[entityId], + label: "", + primary: primary, + secondary: + secondary || + this.hass.localize("ui.components.device-picker.no_area"), + translated_domain: translatedDomain, + sorting_label: [deviceName, entityName].filter(Boolean).join("-"), + entity_name: entityName || deviceName, + area_name: areaName, + device_name: deviceName, + friendly_name: friendlyName, + show_entity_id: hass.userData?.showEntityIdPicker, + }; + }) + .sort((entityA, entityB) => + caseInsensitiveStringCompare( + entityA.sorting_label!, + entityB.sorting_label!, + this.hass.locale.language + ) + ); + + if (includeDeviceClasses) { + states = states.filter( + (stateObj) => + // We always want to include the entity of the current value + stateObj.entity_id === this.value || + (stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class)) + ); + } + + if (includeUnitOfMeasurement) { + states = states.filter( + (stateObj) => + // We always want to include the entity of the current value + stateObj.entity_id === this.value || + (stateObj.attributes.unit_of_measurement && + includeUnitOfMeasurement.includes( + stateObj.attributes.unit_of_measurement + )) + ); + } + + if (entityFilter) { + states = states.filter( + (stateObj) => + // We always want to include the entity of the current value + stateObj.entity_id === this.value || entityFilter!(stateObj) + ); + } + + if (!states.length) { + return [ + { + ...FAKE_ENTITY, + label: "", + primary: this.hass!.localize( + "ui.components.entity.entity-picker.no_match" + ), + icon_path: mdiMagnify, + }, + ...createItems, + ]; + } + + if (createItems?.length) { + states.push(...createItems); + } + + return states; + } + ); + + 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( + [ + "entity_name", + "device_name", + "area_name", + "translated_domain", + "friendly_name", // for backwards compatibility + "entity_id", // for technical search + ], + 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) { + 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 85b908e188..c8f8b5c378 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -1,77 +1,27 @@ -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 { mdiClose, mdiMenuDown, mdiShape } from "@mdi/js"; +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 { styleMap } from "lit/directives/style-map"; -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 { 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 { debounce } from "../../common/util/debounce"; +import type { HomeAssistant } from "../../types"; import "../ha-combo-box-item"; import "../ha-icon-button"; +import type { HaMdListItem } from "../ha-md-list-item"; import "../ha-svg-icon"; +import "./ha-entity-combo-box"; +import type { + HaEntityComboBox, + HaEntityComboBoxEntityFilterFunc, +} from "./ha-entity-combo-box"; import "./state-badge"; -const FAKE_ENTITY: HassEntity = { - entity_id: "", - state: "", - last_changed: "", - last_updated: "", - context: { id: "", user_id: null, parent_id: null }, - attributes: {}, -}; - -interface EntityPickerItem extends HassEntity { - label: string; - primary: string; - secondary?: string; - translated_domain?: string; - show_entity_id?: boolean; - entity_name?: string; - area_name?: string; - device_name?: string; - friendly_name?: string; - sorting_label?: string; - icon_path?: string; -} - -export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; - -const CREATE_ID = "___create-new-entity___"; - -const DOMAIN_STYLE = styleMap({ - fontSize: "12px", - fontWeight: "400", - lineHeight: "18px", - alignSelf: "flex-end", - maxWidth: "30%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", -}); - -const ENTITY_ID_STYLE = styleMap({ - fontFamily: "var(--code-font-family, monospace)", - fontSize: "11px", -}); - @customElement("ha-entity-picker") export class HaEntityPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -92,6 +42,8 @@ export class HaEntityPicker extends LitElement { @property() public helper?: string; + @property() public placeholder?: string; + @property({ attribute: false, type: Array }) public createDomains?: string[]; /** @@ -143,381 +95,240 @@ export class HaEntityPicker extends LitElement { public excludeEntities?: string[]; @property({ attribute: false }) - public entityFilter?: HaEntityPickerEntityFilterFunc; + public entityFilter?: HaEntityComboBoxEntityFilterFunc; @property({ attribute: "hide-clear-icon", type: Boolean }) public hideClearIcon = false; - @property({ attribute: "item-label-path" }) public itemLabelPath = "label"; + @query("#anchor") private _anchor?: HaMdListItem; + + @query("#input") private _input?: HaEntityComboBox; @state() private _opened = false; - @query("ha-combo-box", true) public comboBox!: HaComboBox; + private _renderContent() { + const entityId = this.value || ""; - 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: EntityPickerItem[] = []; - - protected firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this.hass.loadBackendTranslation("title"); - } - - private _rowRenderer: ComboBoxLitRenderer = ( - item, - { index } - ) => html` - - ${item.icon_path - ? html`` - : html` - - `} - - ${item.primary} - ${item.secondary - ? html`${item.secondary}` - : nothing} - ${item.entity_id && item.show_entity_id - ? html`${item.entity_id}` - : nothing} - ${item.translated_domain && !item.show_entity_id - ? html`
- ${item.translated_domain} -
` - : 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"] - ): EntityPickerItem[] => { - let states: EntityPickerItem[] = []; - - if (!hass) { - return []; - } - 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 { - ...FAKE_ENTITY, - entity_id: CREATE_ID + domain, - primary: primary, - label: primary, - secondary: this.hass.localize( - "ui.components.entity.entity-picker.new_entity" - ), - icon_path: mdiPlus, - }; - }) - : []; - - if (!entityIds.length) { - return [ - { - ...FAKE_ENTITY, - primary: this.hass!.localize( - "ui.components.entity.entity-picker.no_entities" - ), - label: 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); - - states = 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 primary = entityName || deviceName || entityId; - const secondary = [areaName, entityName ? deviceName : undefined] - .filter(Boolean) - .join(isRTL ? " ◂ " : " ▸ "); - - const translatedDomain = domainToName( - this.hass.localize, - computeDomain(entityId) - ); - - return { - ...hass!.states[entityId], - primary: primary, - secondary: - secondary || - this.hass.localize("ui.components.device-picker.no_area"), - label: friendlyName, - translated_domain: translatedDomain, - sorting_label: [deviceName, entityName].filter(Boolean).join("-"), - entity_name: entityName || deviceName, - area_name: areaName, - device_name: deviceName, - friendly_name: friendlyName, - show_entity_id: hass.userData?.showEntityIdPicker, - }; - }) - .sort((entityA, entityB) => - caseInsensitiveStringCompare( - entityA.sorting_label!, - entityB.sorting_label!, - this.hass.locale.language - ) - ); - - if (includeDeviceClasses) { - states = states.filter( - (stateObj) => - // We always want to include the entity of the current value - stateObj.entity_id === this.value || - (stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class)) - ); - } - - if (includeUnitOfMeasurement) { - states = states.filter( - (stateObj) => - // We always want to include the entity of the current value - stateObj.entity_id === this.value || - (stateObj.attributes.unit_of_measurement && - includeUnitOfMeasurement.includes( - stateObj.attributes.unit_of_measurement - )) - ); - } - - if (entityFilter) { - states = states.filter( - (stateObj) => - // We always want to include the entity of the current value - stateObj.entity_id === this.value || entityFilter!(stateObj) - ); - } - - if (!states.length) { - return [ - { - ...FAKE_ENTITY, - primary: this.hass!.localize( - "ui.components.entity.entity-picker.no_match" - ), - label: this.hass!.localize( - "ui.components.entity.entity-picker.no_match" - ), - icon_path: mdiMagnify, - }, - ...createItems, - ]; - } - - if (createItems?.length) { - states.push(...createItems); - } - - return states; - } - ); - - 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 (!this.value) { + return html` + ${this.placeholder ?? + this.hass.localize( + "ui.components.entity.entity-picker.placeholder" + )} + + `; } - if (changedProps.has("createDomains") && this.createDomains?.length) { - this.hass.loadFragmentTranslation("config"); - } - } + const stateObj = this.hass.states[entityId]; + + const showClearIcon = + !this.required && !this.disabled && !this.hideClearIcon; + + if (!stateObj) { + return html` + + ${entityId} + ${showClearIcon + ? html`` + : nothing} + + `; + } + + const { area, device } = getEntityContext(stateObj, this.hass); + + const entityName = computeEntityName(stateObj, this.hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const isRTL = computeRTL(this.hass); + + const primary = entityName || deviceName || entityId; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); - protected render(): TemplateResult { return html` - - + .stateObj=${stateObj} + slot="start" + > + ${primary} + + ${secondary || + this.hass.localize("ui.components.device-picker.no_area")} + + ${showClearIcon + ? html`` + : nothing} + `; } - private get _value() { - return this.value || ""; + protected render() { + return html` + ${this.label ? html`

${this.label}

` : nothing} +
+ ${!this._opened + ? html` + ${this._renderContent()} + ` + : html``} + ${this._renderHelper()} +
+ `; } - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; + private _renderHelper() { + return this.helper + ? html`${this.helper}` + : nothing; } - private _valueChanged(ev: ValueChangedEvent) { - ev.stopPropagation(); - const newValue = ev.detail.value?.trim(); + private _clear(e) { + e.stopPropagation(); + this.value = undefined; + fireEvent(this, "value-changed", { value: undefined }); + fireEvent(this, "change"); + } - 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); - }, - }); + private async _showPicker() { + if (this.disabled) { return; } - - if (newValue !== this._value) { - this._setValue(newValue); - } + this._opened = true; + await this.updateComplete; + this._input?.focus(); + this._input?.open(); } - private _fuseIndex = memoizeOne((states: EntityPickerItem[]) => - Fuse.createIndex( - [ - "entity_name", - "device_name", - "area_name", - "translated_domain", - "friendly_name", // for backwards compatibility - "entity_id", // for technical search - ], - states - ) + // Multiple calls to _openedChanged can be triggered in quick succession + // when the menu is opened + private _debounceOpenedChanged = debounce( + (ev) => this._openedChanged(ev), + 10 ); - 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) { - target.filteredItems = results.map((result) => result.item); - } else { - target.filteredItems = this._items; + private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { + const opened = ev.detail.value; + if (this._opened && !opened) { + this._opened = false; + await this.updateComplete; + this._anchor?.focus(); } } - private _setValue(value: string | undefined) { - this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); + 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; + } + + /* 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); + } + + 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; + } + `, + ]; } } diff --git a/src/components/ha-combo-box-item.ts b/src/components/ha-combo-box-item.ts index ce8cc4b8eb..ddbf56da26 100644 --- a/src/components/ha-combo-box-item.ts +++ b/src/components/ha-combo-box-item.ts @@ -17,6 +17,9 @@ export class HaComboBoxItem extends HaMdListItem { :host([border-top]) md-item { border-top: 1px solid var(--divider-color); } + [slot="start"] { + --state-icon-color: var(--secondary-text-color); + } [slot="headline"] { line-height: 22px; font-size: 14px; diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts index 543fc801ee..93c9348f4b 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -147,6 +147,10 @@ export class HaComboBox extends LitElement { this._comboBox.value = value; } + public setTextFieldValue(value: string) { + this._inputElement.value = value; + } + protected render(): TemplateResult { return html` diff --git a/src/components/ha-md-list-item.ts b/src/components/ha-md-list-item.ts index 702dc08a21..2440c57805 100644 --- a/src/components/ha-md-list-item.ts +++ b/src/components/ha-md-list-item.ts @@ -17,6 +17,7 @@ export const haMdListStyles = [ md-item { overflow: var(--md-item-overflow, hidden); align-items: var(--md-item-align-items, center); + gap: var(--ha-md-list-item-gap, 16px); } `, ]; diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index a3296fbe65..68324deacf 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -39,8 +39,8 @@ 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-picker"; -import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker"; +import "./entity/ha-entity-combo-box"; +import type { HaEntityComboBoxEntityFilterFunc } from "./entity/ha-entity-combo-box"; import "./ha-area-floor-picker"; import { floorDefaultIconPath } from "./ha-floor-icon"; import "./ha-icon-button"; @@ -80,7 +80,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { public deviceFilter?: HaDevicePickerDeviceFilterFunc; @property({ attribute: false }) - public entityFilter?: HaEntityPickerEntityFilterFunc; + public entityFilter?: HaEntityComboBoxEntityFilterFunc; @property({ type: Boolean, reflect: true }) public disabled = false; @@ -449,7 +449,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { > ` : html` - + > `}`; } @@ -839,7 +839,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { mwc-menu-surface { --mdc-menu-min-width: 100%; } - ha-entity-picker, + ha-entity-combo-box, ha-device-picker, ha-area-floor-picker { display: block; diff --git a/src/data/logbook.ts b/src/data/logbook.ts index d57e27fd26..5a9d4b65c2 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,9 +322,8 @@ export const localizeStateMessage = ( }); }; -export const filterLogbookCompatibleEntities: HaEntityPickerEntityFilterFunc = ( - entity -) => - computeStateDomain(entity) !== "sensor" || - (entity.attributes.unit_of_measurement === undefined && - entity.attributes.state_class === undefined); +export const filterLogbookCompatibleEntities: HaEntityComboBoxEntityFilterFunc = + (entity) => + computeStateDomain(entity) !== "sensor" || + (entity.attributes.unit_of_measurement === undefined && + entity.attributes.state_class === undefined); diff --git a/src/panels/developer-tools/state/developer-tools-state.ts b/src/panels/developer-tools/state/developer-tools-state.ts index e9eeead8f4..888442a12e 100644 --- a/src/panels/developer-tools/state/developer-tools-state.ts +++ b/src/panels/developer-tools/state/developer-tools-state.ts @@ -1,5 +1,6 @@ import { mdiClipboardTextMultipleOutline, + mdiContentCopy, mdiInformationOutline, mdiRefresh, } from "@mdi/js"; @@ -19,13 +20,13 @@ import { storage } from "../../../common/decorators/storage"; import { fireEvent } from "../../../common/dom/fire_event"; import { escapeRegExp } from "../../../common/string/escape_regexp"; import { copyToClipboard } from "../../../common/util/copy-clipboard"; -import { isMobileClient } from "../../../util/is_mobile"; import "../../../components/entity/ha-entity-picker"; import "../../../components/ha-alert"; import "../../../components/ha-button"; import "../../../components/ha-checkbox"; import "../../../components/ha-expansion-panel"; import "../../../components/ha-icon-button"; +import "../../../components/ha-input-helper-text"; import "../../../components/ha-svg-icon"; import "../../../components/ha-tip"; import "../../../components/ha-yaml-editor"; @@ -34,7 +35,7 @@ import "../../../components/search-input"; import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; -import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog"; +import { showToast } from "../../../util/toast"; @customElement("developer-tools-state") class HaPanelDevState extends LitElement { @@ -128,18 +129,20 @@ class HaPanelDevState extends LitElement { .value=${this._entityId} @value-changed=${this._entityIdChanged} allow-custom-entity - item-label-path="entity_id" > - ${this.hass.enableShortcuts && !isMobileClient - ? html`${this.hass.localize("ui.tips.key_e_tip", { - keyboard_shortcut: html`${this.hass.localize("ui.tips.keyboard_shortcut")}`, - })}` + ${this._entityId + ? html` +
+ ${this._entityId} + +
+ ` : nothing} - + > `; } @@ -168,8 +167,8 @@ export class HuiHeadingBadgesEditor extends LitElement { if (!this._addMode) { return; } - await this._entityPicker?.focus(); - await this._entityPicker?.open(); + await this._entityCombobox?.focus(); + await this._entityCombobox?.open(); this._opened = true; } diff --git a/src/translations/en.json b/src/translations/en.json index 52ac9408b8..17b88b5192 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -566,6 +566,7 @@ "no_match": "No matching entities found", "show_entities": "Show entities", "new_entity": "Create a new entity", + "placeholder": "Select an entity", "create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper." }, "entity-attribute-picker": {