From 53a0b311de3d7c52ce05042c6c126138bb4313b0 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 30 Apr 2025 10:40:56 +0200 Subject: [PATCH] Use list item for statistic picker (#25228) * Use list item for statistic picker * Use item interface * Fix icon --- src/components/entity/ha-entities-picker.ts | 21 +- src/components/entity/ha-entity-picker.ts | 4 +- .../entity/ha-statistic-combo-box.ts | 482 +++++++++++++ src/components/entity/ha-statistic-picker.ts | 670 ++++++++---------- src/components/entity/ha-statistics-picker.ts | 16 +- .../ha-selector/ha-selector-entity.ts | 2 +- .../hui-statistics-graph-card-editor.ts | 4 +- src/translations/en.json | 1 + 8 files changed, 807 insertions(+), 393 deletions(-) create mode 100644 src/components/entity/ha-statistic-combo-box.ts diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index 16625aa81a..68c3ed549f 100644 --- a/src/components/entity/ha-entities-picker.ts +++ b/src/components/entity/ha-entities-picker.ts @@ -8,7 +8,7 @@ import type { HaEntityComboBoxEntityFilterFunc } from "./ha-entity-combo-box"; import "./ha-entity-picker"; @customElement("ha-entities-picker") -class HaEntitiesPickerLight extends LitElement { +class HaEntitiesPicker extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @property({ type: Array }) public value?: string[]; @@ -17,6 +17,10 @@ class HaEntitiesPickerLight extends LitElement { @property({ type: Boolean }) public required = false; + @property() public label?: string; + + @property() public placeholder?: string; + @property() public helper?: string; /** @@ -67,11 +71,6 @@ class HaEntitiesPickerLight extends LitElement { @property({ type: Array, attribute: "exclude-entities" }) public excludeEntities?: string[]; - @property({ attribute: "picked-entity-label" }) - public pickedEntityLabel?: string; - - @property({ attribute: "pick-entity-label" }) public pickEntityLabel?: string; - @property({ attribute: false }) public entityFilter?: HaEntityComboBoxEntityFilterFunc; @@ -84,6 +83,7 @@ class HaEntitiesPickerLight extends LitElement { const currentEntities = this._currentEntities; return html` + ${this.label ? html`` : nothing} ${currentEntities.map( (entityId) => html`
@@ -99,7 +99,6 @@ class HaEntitiesPickerLight extends LitElement { .includeUnitOfMeasurement=${this.includeUnitOfMeasurement} .entityFilter=${this.entityFilter} .value=${entityId} - .label=${this.pickedEntityLabel} .disabled=${this.disabled} .createDomains=${this.createDomains} @value-changed=${this._entityChanged} @@ -121,7 +120,7 @@ class HaEntitiesPickerLight extends LitElement { .includeDeviceClasses=${this.includeDeviceClasses} .includeUnitOfMeasurement=${this.includeUnitOfMeasurement} .entityFilter=${this.entityFilter} - .label=${this.pickEntityLabel} + .placeholder=${this.placeholder} .helper=${this.helper} .disabled=${this.disabled} .createDomains=${this.createDomains} @@ -198,11 +197,15 @@ class HaEntitiesPickerLight extends LitElement { div { margin-top: 8px; } + label { + display: block; + margin: 0 0 8px; + } `; } declare global { interface HTMLElementTagNameMap { - "ha-entities-picker": HaEntitiesPickerLight; + "ha-entities-picker": HaEntitiesPicker; } } diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index c8f8b5c378..0cba69c515 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -180,7 +180,7 @@ export class HaEntityPicker extends LitElement { protected render() { return html` - ${this.label ? html`

${this.label}

` : nothing} + ${this.label ? html`` : nothing}
${!this._opened ? html` = ( + item, + { index } + ) => html` + + ${!item.state + ? html` + + ` + : html` + + `} + + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${item.id && item.show_entity_id + ? html` + + ${item.id} + + ` + : nothing} + + `; + + private _getItems = memoizeOne( + ( + _opened: boolean, + hass: this["hass"], + statisticIds: StatisticsMetaData[], + includeStatisticsUnitOfMeasurement?: string | string[], + includeUnitClass?: string | string[], + includeDeviceClass?: string | string[], + entitiesOnly?: boolean, + excludeStatistics?: string[], + value?: string + ): StatisticItem[] => { + if (!statisticIds.length) { + return [ + { + id: "", + label: "", + primary: this.hass.localize( + "ui.components.statistic-picker.no_statistics" + ), + }, + ]; + } + + if (includeStatisticsUnitOfMeasurement) { + const includeUnits: (string | null)[] = ensureArray( + includeStatisticsUnitOfMeasurement + ); + statisticIds = statisticIds.filter((meta) => + includeUnits.includes(meta.statistics_unit_of_measurement) + ); + } + if (includeUnitClass) { + const includeUnitClasses: (string | null)[] = + ensureArray(includeUnitClass); + statisticIds = statisticIds.filter((meta) => + includeUnitClasses.includes(meta.unit_class) + ); + } + if (includeDeviceClass) { + const includeDeviceClasses: (string | null)[] = + ensureArray(includeDeviceClass); + statisticIds = statisticIds.filter((meta) => { + const stateObj = this.hass.states[meta.statistic_id]; + if (!stateObj) { + return true; + } + return includeDeviceClasses.includes( + stateObj.attributes.device_class || "" + ); + }); + } + + const isRTL = computeRTL(this.hass); + + const output: StatisticItem[] = []; + statisticIds.forEach((meta) => { + if ( + excludeStatistics && + meta.statistic_id !== value && + excludeStatistics.includes(meta.statistic_id) + ) { + return; + } + const stateObj = this.hass.states[meta.statistic_id]; + + if (!stateObj) { + if (!entitiesOnly) { + const id = meta.statistic_id; + const label = getStatisticLabel(this.hass, meta.statistic_id, meta); + const type = + meta.statistic_id.includes(":") && + !meta.statistic_id.includes(".") + ? "external" + : "no_state"; + + if (type === "no_state") { + output.push({ + id, + primary: label, + secondary: this.hass.localize( + "ui.components.statistic-picker.no_state" + ), + label: "", + type, + sorting_label: label, + iconPath: mdiShape, + }); + } else if (type === "external") { + const domain = id.split(":")[0]; + const domainName = domainToName(this.hass.localize, domain); + output.push({ + id, + primary: label, + secondary: domainName, + label: "", + type, + sorting_label: label, + iconPath: mdiChartLine, + }); + } + } + return; + } + const id = meta.statistic_id; + + 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 || id; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + output.push({ + id, + primary, + secondary, + label: "", + state: stateObj, + type: "entity", + sorting_label: [deviceName, entityName].join("_"), + entity_name: entityName || deviceName, + area_name: areaName, + device_name: deviceName, + friendly_name: friendlyName, + show_entity_id: hass.userData?.showEntityIdPicker, + }); + }); + + if (!output.length) { + return [ + { + id: "", + primary: this.hass.localize( + "ui.components.statistic-picker.no_match" + ), + label: "", + }, + ]; + } + + if (output.length > 1) { + output.sort((a, b) => { + const aPrefix = TYPE_ORDER.indexOf(a.type || "no_state"); + const bPrefix = TYPE_ORDER.indexOf(b.type || "no_state"); + + return caseInsensitiveStringCompare( + `${aPrefix}_${a.sorting_label || ""}`, + `${bPrefix}_${b.sorting_label || ""}`, + this.hass.locale.language + ); + }); + } + + output.push({ + id: "__missing", + primary: this.hass.localize( + "ui.components.statistic-picker.missing_entity" + ), + label: "", + }); + + return output; + } + ); + + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + 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.hasUpdated && !this.statisticIds) || + changedProps.has("statisticTypes") + ) { + this._getStatisticIds(); + } + + if ( + this.statisticIds && + (!this._initialItems || (changedProps.has("_opened") && this._opened)) + ) { + this._items = this._getItems( + this._opened, + this.hass, + this.statisticIds!, + this.includeStatisticsUnitOfMeasurement, + this.includeUnitClass, + this.includeDeviceClass, + this.entitiesOnly, + this.excludeStatistics, + this.value + ); + if (this._initialItems) { + this.comboBox.filteredItems = this._items; + } + this._initialItems = true; + } + } + + protected render(): TemplateResult | typeof nothing { + if (this._items.length === 0) { + return nothing; + } + + return html` + + `; + } + + private async _getStatisticIds() { + this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes); + } + + private get _value() { + return this.value || ""; + } + + private _statisticChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + if (newValue === "__missing") { + newValue = ""; + } + + if (newValue !== this._value) { + this._setValue(newValue); + } + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private _fuseIndex = memoizeOne((states: StatisticItem[]) => + Fuse.createIndex( + [ + "entity_name", + "device_name", + "area_name", + "friendly_name", // for backwards compatibility + "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) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-statistic-combo-box": HaStatisticComboBox; + } +} diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index 760bf202d1..f0dbdd0872 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -1,65 +1,66 @@ -import { mdiChartLine } from "@mdi/js"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import Fuse from "fuse.js"; +import { mdiChartLine, mdiClose, mdiMenuDown, mdiShape } from "@mdi/js"; +import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { html, LitElement, nothing } from "lit"; +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type PropertyValues, +} from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; -import { ensureArray } from "../../common/array/ensure-array"; 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 { 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 { debounce } from "../../common/util/debounce"; import { domainToName } from "../../data/integration"; -import type { StatisticsMetaData } from "../../data/recorder"; -import { getStatisticIds, getStatisticLabel } from "../../data/recorder"; -import { HaFuse } from "../../resources/fuse"; -import type { HomeAssistant, ValueChangedEvent } from "../../types"; -import "../ha-combo-box"; -import type { HaComboBox } from "../ha-combo-box"; +import { + getStatisticIds, + getStatisticLabel, + type StatisticsMetaData, +} from "../../data/recorder"; +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 } from "./ha-entity-combo-box"; +import "./ha-statistic-combo-box"; import "./state-badge"; -type StatisticItemType = "entity" | "external" | "no_state"; - interface StatisticItem { - id: string; - label: string; primary: string; secondary?: string; - show_entity_id?: boolean; - entity_name?: string; - area_name?: string; - device_name?: string; - friendly_name?: string; - sorting_label?: string; - state?: HassEntity; - type?: StatisticItemType; iconPath?: string; + stateObj?: HassEntity; } -const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[]; - -const ENTITY_ID_STYLE = styleMap({ - fontFamily: "var(--code-font-family, monospace)", - fontSize: "11px", -}); - @customElement("ha-statistic-picker") export class HaStatisticPicker 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() public label?: string; @property() public value?: string; + @property() public helper?: string; + + @property() public placeholder?: string; + @property({ attribute: "statistic-types" }) public statisticTypes?: "mean" | "sum"; @@ -69,8 +70,6 @@ export class HaStatisticPicker extends LitElement { @property({ attribute: false, type: Array }) public statisticIds?: StatisticsMetaData[]; - @property({ type: Boolean }) public disabled = false; - /** * Show only statistics natively stored with these units of measurements. * @type {Array} @@ -112,251 +111,15 @@ export class HaStatisticPicker extends LitElement { @property({ type: Array, attribute: "exclude-statistics" }) public excludeStatistics?: string[]; - @property({ attribute: false }) public helpMissingEntityUrl = - "/more-info/statistics/"; + @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-combo-box", true) public comboBox!: HaComboBox; - - private _initialItems = false; - - private _items: StatisticItem[] = []; - - protected firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this.hass.loadBackendTranslation("title"); - } - - private _rowRenderer: ComboBoxLitRenderer = ( - item, - { index } - ) => html` - - ${!item.state - ? html`` - : html` - - `} - - ${item.primary} - ${item.secondary - ? html`${item.secondary}` - : nothing} - ${item.id && item.show_entity_id - ? html` - - ${item.id} - - ` - : nothing} - - `; - - private _getItems = memoizeOne( - ( - _opened: boolean, - hass: this["hass"], - statisticIds: StatisticsMetaData[], - includeStatisticsUnitOfMeasurement?: string | string[], - includeUnitClass?: string | string[], - includeDeviceClass?: string | string[], - entitiesOnly?: boolean, - excludeStatistics?: string[], - value?: string - ): StatisticItem[] => { - if (!statisticIds.length) { - return [ - { - id: "", - label: this.hass.localize( - "ui.components.statistic-picker.no_statistics" - ), - primary: this.hass.localize( - "ui.components.statistic-picker.no_statistics" - ), - }, - ]; - } - - if (includeStatisticsUnitOfMeasurement) { - const includeUnits: (string | null)[] = ensureArray( - includeStatisticsUnitOfMeasurement - ); - statisticIds = statisticIds.filter((meta) => - includeUnits.includes(meta.statistics_unit_of_measurement) - ); - } - if (includeUnitClass) { - const includeUnitClasses: (string | null)[] = - ensureArray(includeUnitClass); - statisticIds = statisticIds.filter((meta) => - includeUnitClasses.includes(meta.unit_class) - ); - } - if (includeDeviceClass) { - const includeDeviceClasses: (string | null)[] = - ensureArray(includeDeviceClass); - statisticIds = statisticIds.filter((meta) => { - const stateObj = this.hass.states[meta.statistic_id]; - if (!stateObj) { - return true; - } - return includeDeviceClasses.includes( - stateObj.attributes.device_class || "" - ); - }); - } - - const isRTL = computeRTL(this.hass); - - const output: StatisticItem[] = []; - statisticIds.forEach((meta) => { - if ( - excludeStatistics && - meta.statistic_id !== value && - excludeStatistics.includes(meta.statistic_id) - ) { - return; - } - const entityState = this.hass.states[meta.statistic_id]; - if (!entityState) { - if (!entitiesOnly) { - const id = meta.statistic_id; - const label = getStatisticLabel(this.hass, meta.statistic_id, meta); - const type = - meta.statistic_id.includes(":") && - !meta.statistic_id.includes(".") - ? "external" - : "no_state"; - - if (type === "no_state") { - output.push({ - id, - primary: label, - secondary: this.hass.localize( - "ui.components.statistic-picker.no_state" - ), - label, - type, - sorting_label: label, - }); - } else if (type === "external") { - const domain = id.split(":")[0]; - const domainName = domainToName(this.hass.localize, domain); - output.push({ - id, - primary: label, - secondary: domainName, - label, - type, - sorting_label: label, - iconPath: mdiChartLine, - }); - } - } - return; - } - const id = meta.statistic_id; - - const { area, device } = getEntityContext(entityState, hass); - - const friendlyName = computeStateName(entityState); // Keep this for search - const entityName = computeEntityName(entityState, hass); - const deviceName = device ? computeDeviceName(device) : undefined; - const areaName = area ? computeAreaName(area) : undefined; - - const primary = entityName || deviceName || id; - const secondary = [areaName, entityName ? deviceName : undefined] - .filter(Boolean) - .join(isRTL ? " ◂ " : " ▸ "); - - output.push({ - id, - primary, - secondary, - label: friendlyName, - state: entityState, - type: "entity", - sorting_label: [deviceName, entityName].join("_"), - entity_name: entityName || deviceName, - area_name: areaName, - device_name: deviceName, - friendly_name: friendlyName, - show_entity_id: hass.userData?.showEntityIdPicker, - }); - }); - - if (!output.length) { - return [ - { - id: "", - primary: this.hass.localize( - "ui.components.statistic-picker.no_match" - ), - label: this.hass.localize( - "ui.components.statistic-picker.no_match" - ), - }, - ]; - } - - if (output.length > 1) { - output.sort((a, b) => { - const aPrefix = TYPE_ORDER.indexOf(a.type || "no_state"); - const bPrefix = TYPE_ORDER.indexOf(b.type || "no_state"); - - return caseInsensitiveStringCompare( - `${aPrefix}_${a.sorting_label || ""}`, - `${bPrefix}_${b.sorting_label || ""}`, - this.hass.locale.language - ); - }); - } - - output.push({ - id: "__missing", - primary: this.hass.localize( - "ui.components.statistic-picker.missing_entity" - ), - label: this.hass.localize( - "ui.components.statistic-picker.missing_entity" - ), - }); - - return output; - } - ); - - public async open() { - await this.updateComplete; - await this.comboBox?.open(); - } - - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } - - 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.hasUpdated && !this.statisticIds) || @@ -364,117 +127,278 @@ export class HaStatisticPicker extends LitElement { ) { this._getStatisticIds(); } - - if ( - this.statisticIds && - (!this._initialItems || (changedProps.has("_opened") && this._opened)) - ) { - this._items = this._getItems( - this._opened, - this.hass, - this.statisticIds!, - this.includeStatisticsUnitOfMeasurement, - this.includeUnitClass, - this.includeDeviceClass, - this.entitiesOnly, - this.excludeStatistics, - this.value - ); - if (this._initialItems) { - this.comboBox.filteredItems = this._items; - } - this._initialItems = true; - } - } - - protected render(): TemplateResult | typeof nothing { - if (this._items.length === 0) { - return nothing; - } - - return html` - - `; } private async _getStatisticIds() { this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes); } - private get _value() { - return this.value || ""; - } - - private _statisticChanged(ev: ValueChangedEvent) { - ev.stopPropagation(); - let newValue = ev.detail.value; - if (newValue === "__missing") { - newValue = ""; + private _statisticMetaData = memoizeOne( + (statisticId: string, statisticIds: StatisticsMetaData[]) => { + if (!statisticIds) { + return undefined; + } + return statisticIds.find( + (statistic) => statistic.statistic_id === statisticId + ); } - - if (newValue !== this._value) { - this._setValue(newValue); - } - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _fuseIndex = memoizeOne((states: StatisticItem[]) => - Fuse.createIndex( - [ - "label", - "entity_name", - "device_name", - "area_name", - "friendly_name", // for backwards compatibility - "id", // for technical search - ], - states - ) ); - private _filterChanged(ev: CustomEvent): void { - if (!this._opened) return; + private _renderContent() { + const statisticId = this.value || ""; - const target = ev.target as HaComboBox; - const filterString = ev.detail.value.trim().toLowerCase() as string; + if (!this.value) { + return html` + ${this.placeholder ?? + this.hass.localize( + "ui.components.statistic-picker.placeholder" + )} + + `; + } - const index = this._fuseIndex(this._items); - const fuse = new HaFuse(this._items, {}, index); + const item = this._computeItem(statisticId); - const results = fuse.multiTermsSearch(filterString); + const showClearIcon = + !this.required && !this.disabled && !this.hideClearIcon; - if (results) { - target.filteredItems = results.map((result) => result.item); - } else { - target.filteredItems = this._items; + return html` + ${item.stateObj + ? html` + + ` + : item.iconPath + ? html`` + : nothing} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${showClearIcon + ? html`` + : nothing} + + `; + } + + private _computeItem(statisticId: string): StatisticItem { + const stateObj = this.hass.states[statisticId]; + + if (stateObj) { + 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 || statisticId; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + return { + primary, + secondary, + stateObj, + }; + } + + const statistic = this.statisticIds + ? this._statisticMetaData(statisticId, this.statisticIds) + : undefined; + + if (statistic) { + const type = + statisticId.includes(":") && !statisticId.includes(".") + ? "external" + : "no_state"; + + if (type === "external") { + const label = getStatisticLabel(this.hass, statisticId, statistic); + const domain = statisticId.split(":")[0]; + const domainName = domainToName(this.hass.localize, domain); + + return { + primary: label, + secondary: domainName, + iconPath: mdiChartLine, + }; + } + } + + return { + primary: statisticId, + iconPath: mdiShape, + }; + } + + protected render() { + return html` + ${this.label ? html`` : nothing} +
+ ${!this._opened + ? html` + + ${this._renderContent()} + + ` + : html` + + `} + ${this._renderHelper()} +
+ `; + } + + private _renderHelper() { + return this.helper + ? html`${this.helper}` + : nothing; + } + + private _clear(e) { + e.stopPropagation(); + this.value = undefined; + fireEvent(this, "value-changed", { value: undefined }); + fireEvent(this, "change"); + } + + private async _showPicker() { + if (this.disabled) { + return; + } + 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 _setValue(value: string) { - this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); + static get styles(): CSSResultGroup { + return [ + css` + .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/entity/ha-statistics-picker.ts b/src/components/entity/ha-statistics-picker.ts index d731b2ec20..e259a222e9 100644 --- a/src/components/entity/ha-statistics-picker.ts +++ b/src/components/entity/ha-statistics-picker.ts @@ -16,11 +16,11 @@ class HaStatisticsPicker extends LitElement { @property({ attribute: "statistic-types" }) public statisticTypes?: "mean" | "sum"; - @property({ attribute: "picked-statistic-label" }) - public pickedStatisticLabel?: string; + @property({ type: String }) + public label?: string; - @property({ attribute: "pick-statistic-label" }) - public pickStatisticLabel?: string; + @property({ type: String }) + public placeholder?: string; @property({ type: Boolean, attribute: "allow-custom-entity" }) public allowCustomEntity; @@ -82,6 +82,7 @@ class HaStatisticsPicker extends LitElement { : this.statisticTypes; return html` + ${this.label ? html`` : nothing} ${repeat( this._currentStatistics, (statisticId) => statisticId, @@ -96,7 +97,6 @@ class HaStatisticsPicker extends LitElement { .value=${statisticId} .statisticTypes=${includeStatisticTypesCurrent} .statisticIds=${this.statisticIds} - .label=${this.pickedStatisticLabel} .excludeStatistics=${this.value} .allowCustomEntity=${this.allowCustomEntity} @value-changed=${this._statisticChanged} @@ -113,7 +113,7 @@ class HaStatisticsPicker extends LitElement { .includeDeviceClass=${this.includeDeviceClass} .statisticTypes=${this.statisticTypes} .statisticIds=${this.statisticIds} - .label=${this.pickStatisticLabel} + .placeholder=${this.placeholder} .excludeStatistics=${this.value} .allowCustomEntity=${this.allowCustomEntity} @value-changed=${this._addStatistic} @@ -181,6 +181,10 @@ class HaStatisticsPicker extends LitElement { width: 100%; margin-top: 8px; } + label { + display: block; + margin-bottom: 0 0 8px; + } `; } diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index 1aae4adf78..df3a7eab76 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -76,10 +76,10 @@ export class HaEntitySelector extends LitElement { } return html` - ${this.label ? html`` : ""}