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`