diff --git a/src/components/ha-selector/ha-selector-select.ts b/src/components/ha-selector/ha-selector-select.ts index aec4aa5ad853..b443013ff820 100644 --- a/src/components/ha-selector/ha-selector-select.ts +++ b/src/components/ha-selector/ha-selector-select.ts @@ -1,12 +1,16 @@ import "@material/mwc-list/mwc-list-item"; -import { mdiClose } from "@mdi/js"; -import { css, html, LitElement } from "lit"; +import { mdiClose, mdiDrag } from "@mdi/js"; +import { LitElement, PropertyValues, css, html, nothing } from "lit"; import { customElement, property, query } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { SortableEvent } from "sortablejs"; import { ensureArray } from "../../common/array/ensure-array"; import { fireEvent } from "../../common/dom/fire_event"; import { stopPropagation } from "../../common/dom/stop_propagation"; import { caseInsensitiveStringCompare } from "../../common/string/compare"; import type { SelectOption, SelectSelector } from "../../data/selector"; +import { sortableStyles } from "../../resources/ha-sortable-style"; +import { SortableInstance } from "../../resources/sortable"; import type { HomeAssistant } from "../../types"; import "../ha-checkbox"; import "../ha-chip"; @@ -38,6 +42,68 @@ export class HaSelectSelector extends LitElement { @query("ha-combo-box", true) private comboBox!: HaComboBox; + private _sortable?: SortableInstance; + + protected updated(changedProps: PropertyValues): void { + if (changedProps.has("value") || changedProps.has("selector")) { + const sortableNeeded = + this.selector.select?.multiple && + this.selector.select.reorder && + this.value?.length; + if (!this._sortable && sortableNeeded) { + this._createSortable(); + } else if (this._sortable && !sortableNeeded) { + this._destroySortable(); + } + } + } + + private async _createSortable() { + const Sortable = (await import("../../resources/sortable")).default; + this._sortable = new Sortable( + this.shadowRoot!.querySelector("ha-chip-set")!, + { + animation: 150, + fallbackClass: "sortable-fallback", + draggable: "ha-chip", + onChoose: (evt: SortableEvent) => { + (evt.item as any).placeholder = + document.createComment("sort-placeholder"); + evt.item.after((evt.item as any).placeholder); + }, + onEnd: (evt: SortableEvent) => { + // put back in original location + if ((evt.item as any).placeholder) { + (evt.item as any).placeholder.replaceWith(evt.item); + delete (evt.item as any).placeholder; + } + this._dragged(evt); + }, + } + ); + } + + private _dragged(ev: SortableEvent): void { + if (ev.oldIndex === ev.newIndex) return; + this._move(ev.oldIndex!, ev.newIndex!); + } + + private _move(index: number, newIndex: number) { + const value = this.value as string[]; + const newValue = value.concat(); + const element = newValue.splice(index, 1)[0]; + newValue.splice(newIndex, 0, element); + this.value = newValue; + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + private _destroySortable() { + this._sortable?.destroy(); + this._sortable = undefined; + } + private _filter = ""; protected render() { @@ -71,7 +137,11 @@ export class HaSelectSelector extends LitElement { ); } - if (!this.selector.select?.custom_value && this._mode === "list") { + if ( + !this.selector.select?.custom_value && + !this.selector.select?.reorder && + this._mode === "list" + ) { if (!this.selector.select?.multiple) { return html`
@@ -124,23 +194,39 @@ export class HaSelectSelector extends LitElement { return html` ${value?.length - ? html` - ${value.map( - (item, idx) => html` - - ${options.find((option) => option.value === item)?.label || - item} - - - ` - )} - ` - : ""} + ? html` + + ${repeat( + value, + (item) => item, + (item, idx) => html` + + ${this.selector.select?.reorder + ? html` + + ` + : nothing} + ${options.find((option) => option.value === item) + ?.label || item} + + + ` + )} + + ` + : nothing} > = { vacuum: { battery_level: "%", }, + sensor: { + battery_level: "%", + }, }; diff --git a/src/data/selector.ts b/src/data/selector.ts index a84c7a75b3f3..aaa0a23c0f4a 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -309,6 +309,7 @@ export interface SelectSelector { options: readonly string[] | readonly SelectOption[]; translation_key?: string; sort?: boolean; + reorder?: boolean; } | null; } diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index 5857f6cb8a0d..3b7db1bf368d 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -21,6 +21,7 @@ import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../../common/array/ensure-array"; import { computeCssColor } from "../../../common/color/compute-color"; import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; import { DOMAINS_TOGGLE } from "../../../common/const"; @@ -34,15 +35,7 @@ import "../../../components/tile/ha-tile-icon"; import "../../../components/tile/ha-tile-image"; import "../../../components/tile/ha-tile-info"; import { cameraUrlWithWidthHeight } from "../../../data/camera"; -import { - CoverEntity, - computeCoverPositionStateDisplay, -} from "../../../data/cover"; import { isUnavailableState } from "../../../data/entity"; -import { FanEntity, computeFanSpeedStateDisplay } from "../../../data/fan"; -import type { HumidifierEntity } from "../../../data/humidifier"; -import type { ClimateEntity } from "../../../data/climate"; -import type { LightEntity } from "../../../data/light"; import type { ActionHandlerEvent } from "../../../data/lovelace"; import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor"; import { HomeAssistant } from "../../../types"; @@ -181,80 +174,89 @@ export class HuiTileCard extends LitElement implements LovelaceCard { } ); - private _formatState(stateObj: HassEntity): TemplateResult | string { - const domain = computeDomain(stateObj.entity_id); + private _renderStateContent( + stateObj: HassEntity, + stateContent: string | string[] + ) { + const contents = ensureArray(stateContent); + + const values = contents + .map((content) => { + if (content === "state") { + const domain = computeDomain(stateObj.entity_id); + if ( + (stateObj.attributes.device_class === + SENSOR_DEVICE_CLASS_TIMESTAMP || + TIMESTAMP_STATE_DOMAINS.includes(domain)) && + !isUnavailableState(stateObj.state) + ) { + return html` + + `; + } - if ( - (stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP || - TIMESTAMP_STATE_DOMAINS.includes(domain)) && - !isUnavailableState(stateObj.state) - ) { - return html` - - `; - } + return this.hass!.formatEntityState(stateObj); + } + if (content === "last-changed") { + return html` + + `; + } + if (stateObj.attributes[content] == null) { + return undefined; + } + return this.hass!.formatEntityAttributeValue(stateObj, content); + }) + .filter(Boolean); - if (domain === "light" && stateActive(stateObj)) { - const brightness = (stateObj as LightEntity).attributes.brightness; - if (brightness) { - return this.hass!.formatEntityAttributeValue(stateObj, "brightness"); - } + if (!values.length) { + return html`${this.hass!.formatEntityState(stateObj)}`; } - if (domain === "fan") { - const speedStateDisplay = computeFanSpeedStateDisplay( - stateObj as FanEntity, - this.hass! - ); - if (speedStateDisplay) { - return speedStateDisplay; - } + return html` + ${values.map( + (value, index, array) => + html`${value}${index < array.length - 1 ? " ⸱ " : nothing}` + )} + `; + } + + private _renderState(stateObj: HassEntity): TemplateResult | typeof nothing { + const domain = computeDomain(stateObj.entity_id); + const active = stateActive(stateObj); + + if (domain === "light" && active) { + return this._renderStateContent(stateObj, ["brightness"]); } - const stateDisplay = this.hass!.formatEntityState(stateObj); + if (domain === "fan" && active) { + return this._renderStateContent(stateObj, ["percentage"]); + } - if (domain === "cover") { - const positionStateDisplay = computeCoverPositionStateDisplay( - stateObj as CoverEntity, - this.hass! - ); - if (positionStateDisplay) { - return `${stateDisplay} ⸱ ${positionStateDisplay}`; - } + if (domain === "cover" && active) { + return this._renderStateContent(stateObj, ["state", "current_position"]); } - if (domain === "humidifier" && stateActive(stateObj)) { - const humidity = (stateObj as HumidifierEntity).attributes.humidity; - if (humidity) { - const formattedHumidity = this.hass!.formatEntityAttributeValue( - stateObj, - "humidity", - Math.round(humidity) - ); - return `${stateDisplay} ⸱ ${formattedHumidity}`; - } + if (domain === "humidifier") { + return this._renderStateContent(stateObj, ["state", "current_humidity"]); } if (domain === "climate") { - const current_temperature = (stateObj as ClimateEntity).attributes - .current_temperature; - if (current_temperature) { - const formattedCurrentTemperature = - this.hass!.formatEntityAttributeValue( - stateObj, - "current_temperature", - current_temperature - ); - return `${stateDisplay} ⸱ ${formattedCurrentTemperature}`; - } + return this._renderStateContent(stateObj, [ + "state", + "current_temperature", + ]); } - return stateDisplay; + return this._renderStateContent(stateObj, "state"); } @queryAsync("mwc-ripple") private _ripple!: Promise; @@ -323,7 +325,11 @@ export class HuiTileCard extends LitElement implements LovelaceCard { const name = this._config.name || stateObj.attributes.friendly_name; - const localizedState = this._formatState(stateObj); + const localizedState = this._config.hide_state + ? nothing + : this._config.state_content + ? this._renderStateContent(stateObj, this._config.state_content) + : this._renderState(stateObj); const active = stateActive(stateObj); const color = this._computeStateColor(stateObj, this._config.color); diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 6a1737fd97cf..c80896bc145f 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -520,6 +520,8 @@ export interface EnergyFlowCardConfig extends LovelaceCardConfig { export interface TileCardConfig extends LovelaceCardConfig { entity: string; name?: string; + hide_state?: boolean; + state_content?: string | string[]; icon?: string; color?: string; show_entity_picture?: string; diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts index b3ccc5a69219..ebff8fb27df7 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -1,6 +1,6 @@ import { mdiGestureTap, mdiPalette } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing } from "lit"; +import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { @@ -12,11 +12,17 @@ import { object, optional, string, + union, } from "superstruct"; -import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; +import { ensureArray } from "../../../../common/array/ensure-array"; +import { HASSDomEvent, fireEvent } from "../../../../common/dom/fire_event"; +import { formatEntityAttributeNameFunc } from "../../../../common/translations/entity-state"; import { LocalizeFunc } from "../../../../common/translations/localize"; import "../../../../components/ha-form/ha-form"; -import type { SchemaUnion } from "../../../../components/ha-form/types"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; import type { HomeAssistant } from "../../../../types"; import type { TileCardConfig } from "../../cards/types"; import { @@ -31,12 +37,65 @@ import { EditSubElementEvent, SubElementEditorConfig } from "../types"; import { configElementStyle } from "./config-elements-style"; import "./hui-tile-card-features-editor"; +const HIDDEN_ATTRIBUTES = [ + "access_token", + "available_modes", + "code_arm_required", + "code_format", + "color_modes", + "device_class", + "editable", + "effect_list", + "entity_id", + "entity_picture", + "event_types", + "fan_modes", + "fan_speed_list", + "friendly_name", + "frontend_stream_type", + "has_date", + "has_time", + "hvac_modes", + "icon", + "id", + "max_color_temp_kelvin", + "max_mireds", + "max_temp", + "max", + "min_color_temp_kelvin", + "min_mireds", + "min_temp", + "min", + "mode", + "operation_list", + "options", + "percentage_step", + "precipitation_unit", + "preset_modes", + "pressure_unit", + "sound_mode_list", + "source_list", + "state_class", + "step", + "supported_color_modes", + "supported_features", + "swing_modes", + "target_temp_step", + "temperature_unit", + "token", + "unit_of_measurement", + "visibility_unit", + "wind_speed_unit", +]; + const cardConfigStruct = assign( baseLovelaceCardConfig, object({ entity: optional(string()), name: optional(string()), icon: optional(string()), + hide_state: optional(boolean()), + state_content: optional(union([string(), array(string())])), color: optional(string()), show_entity_picture: optional(boolean()), vertical: optional(boolean()), @@ -63,7 +122,12 @@ export class HuiTileCardEditor } private _schema = memoizeOne( - (localize: LocalizeFunc) => + ( + localize: LocalizeFunc, + formatEntityAttributeName: formatEntityAttributeNameFunc, + stateObj: HassEntity | undefined, + hideState: boolean + ) => [ { name: "entity", selector: { entity: {} } }, { @@ -102,9 +166,49 @@ export class HuiTileCardEditor boolean: {}, }, }, - ] as const, + { + name: "hide_state", + selector: { + boolean: {}, + }, + }, + ], }, - ] as const, + ...(!hideState + ? ([ + { + name: "state_content", + selector: { + select: { + mode: "dropdown", + reorder: true, + custom_value: true, + multiple: true, + options: [ + { + label: "State", + value: "state", + }, + { + label: "Last changed", + value: "last-changed", + }, + ...Object.keys(stateObj?.attributes ?? {}) + .filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) + .map((attribute) => ({ + value: attribute, + label: formatEntityAttributeName( + stateObj!, + attribute + ), + })), + ], + }, + }, + }, + ] as const satisfies readonly HaFormSchema[]) + : []), + ], }, { name: "", @@ -124,9 +228,9 @@ export class HuiTileCardEditor ui_action: {}, }, }, - ] as const, + ], }, - ] as const + ] as const satisfies readonly HaFormSchema[] ); private _context = memoizeOne( @@ -142,7 +246,12 @@ export class HuiTileCardEditor | HassEntity | undefined; - const schema = this._schema(this.hass!.localize); + const schema = this._schema( + this.hass!.localize, + this.hass.formatEntityAttributeName, + stateObj, + this._config.hide_state ?? false + ); if (this._subElementEditorConfig) { return html` @@ -157,10 +266,15 @@ export class HuiTileCardEditor `; } + const data = { + ...this._config, + state_content: ensureArray(this._config.state_content), + }; + return html`