diff --git a/src/components/ha-heading-badge.ts b/src/components/ha-heading-badge.ts new file mode 100644 index 0000000000..adfa5dd79a --- /dev/null +++ b/src/components/ha-heading-badge.ts @@ -0,0 +1,58 @@ +import { css, CSSResultGroup, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; + +type HeadingBadgeType = "text" | "button"; + +@customElement("ha-heading-badge") +export class HaBadge extends LitElement { + @property() public type: HeadingBadgeType = "text"; + + protected render() { + return html` +
+ + +
+ `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + color: var(--secondary-text-color); + } + [role="button"] { + cursor: pointer; + } + .heading-badge { + display: flex; + flex-direction: row; + white-space: nowrap; + align-items: center; + gap: 3px; + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.1px; + --mdc-icon-size: 14px; + } + ::slotted([slot="icon"]) { + --ha-icon-display: block; + color: var(--icon-color, inherit); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-heading-badge": HaBadge; + } +} diff --git a/src/panels/lovelace/cards/heading/hui-heading-entity.ts b/src/panels/lovelace/cards/heading/hui-heading-entity.ts deleted file mode 100644 index 8b12950e7f..0000000000 --- a/src/panels/lovelace/cards/heading/hui-heading-entity.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { HassEntity } from "home-assistant-js-websocket"; -import { - CSSResultGroup, - LitElement, - PropertyValues, - css, - html, - nothing, -} from "lit"; -import { customElement, property } from "lit/decorators"; -import { ifDefined } from "lit/directives/if-defined"; -import { styleMap } from "lit/directives/style-map"; -import memoizeOne from "memoize-one"; -import { computeCssColor } from "../../../../common/color/compute-color"; -import { - hsv2rgb, - rgb2hex, - rgb2hsv, -} from "../../../../common/color/convert-color"; -import { MediaQueriesListener } from "../../../../common/dom/media_query"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { stateActive } from "../../../../common/entity/state_active"; -import { stateColorCss } from "../../../../common/entity/state_color"; -import "../../../../components/ha-card"; -import "../../../../components/ha-icon"; -import "../../../../components/ha-icon-next"; -import "../../../../components/ha-state-icon"; -import { ActionHandlerEvent } from "../../../../data/lovelace/action_handler"; -import "../../../../state-display/state-display"; -import { HomeAssistant } from "../../../../types"; -import { actionHandler } from "../../common/directives/action-handler-directive"; -import { handleAction } from "../../common/handle-action"; -import { hasAction } from "../../common/has-action"; -import { - attachConditionMediaQueriesListeners, - checkConditionsMet, -} from "../../common/validate-condition"; -import { DEFAULT_CONFIG } from "../../editor/heading-entity/hui-heading-entity-editor"; -import type { HeadingEntityConfig } from "../types"; - -@customElement("hui-heading-entity") -export class HuiHeadingEntity extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property({ attribute: false }) public config!: HeadingEntityConfig | string; - - @property({ type: Boolean }) public preview = false; - - private _listeners: MediaQueriesListener[] = []; - - private _handleAction(ev: ActionHandlerEvent) { - const config: HeadingEntityConfig = { - tap_action: { - action: "none", - }, - ...this._config(this.config), - }; - handleAction(this, this.hass!, config, ev.detail.action!); - } - - private _config = memoizeOne( - (configOrString: HeadingEntityConfig | string): HeadingEntityConfig => { - const config = - typeof configOrString === "string" - ? { entity: configOrString } - : configOrString; - - return { - ...DEFAULT_CONFIG, - tap_action: { - action: "none", - }, - ...config, - }; - } - ); - - public disconnectedCallback() { - super.disconnectedCallback(); - this._clearMediaQueries(); - } - - public connectedCallback() { - super.connectedCallback(); - this._listenMediaQueries(); - this._updateVisibility(); - } - - protected update(changedProps: PropertyValues): void { - super.update(changedProps); - if (changedProps.has("hass") || changedProps.has("preview")) { - this._updateVisibility(); - } - } - - private _updateVisibility(forceVisible?: boolean) { - const config = this._config(this.config); - const visible = - forceVisible || - this.preview || - !config.visibility || - checkConditionsMet(config.visibility, this.hass); - this.toggleAttribute("hidden", !visible); - } - - private _clearMediaQueries() { - this._listeners.forEach((unsub) => unsub()); - this._listeners = []; - } - - private _listenMediaQueries() { - const config = this._config(this.config); - if (!config?.visibility) { - return; - } - const conditions = config.visibility; - const hasOnlyMediaQuery = - conditions.length === 1 && - conditions[0].condition === "screen" && - !!conditions[0].media_query; - - this._listeners = attachConditionMediaQueriesListeners( - config.visibility, - (matches) => { - this._updateVisibility(hasOnlyMediaQuery && matches); - } - ); - } - - private _computeStateColor = memoizeOne( - (entity: HassEntity, color?: string) => { - if (!color || color === "none") { - return undefined; - } - - if (color === "state") { - // Use light color if the light support rgb - if ( - computeDomain(entity.entity_id) === "light" && - entity.attributes.rgb_color - ) { - const hsvColor = rgb2hsv(entity.attributes.rgb_color); - - // Modify the real rgb color for better contrast - if (hsvColor[1] < 0.4) { - // Special case for very light color (e.g: white) - if (hsvColor[1] < 0.1) { - hsvColor[2] = 225; - } else { - hsvColor[1] = 0.4; - } - } - return rgb2hex(hsv2rgb(hsvColor)); - } - // Fallback to state color - return stateColorCss(entity); - } - - if (color) { - // Use custom color if active - return stateActive(entity) ? computeCssColor(color) : undefined; - } - return color; - } - ); - - protected render() { - const config = this._config(this.config); - - const stateObj = this.hass!.states[config.entity]; - - if (!stateObj) { - return nothing; - } - - const color = this._computeStateColor(stateObj, config.color); - - const actionable = hasAction(config.tap_action); - - const style = { - "--color": color, - }; - - return html` -
- ${config.show_icon - ? html` - - ` - : nothing} - ${config.show_state - ? html` - - ` - : nothing} -
- `; - } - - static get styles(): CSSResultGroup { - return css` - [role="button"] { - cursor: pointer; - } - .entity { - display: flex; - flex-direction: row; - white-space: nowrap; - align-items: center; - gap: 3px; - color: var(--secondary-text-color); - font-family: Roboto; - font-size: 14px; - font-style: normal; - font-weight: 500; - line-height: 20px; /* 142.857% */ - letter-spacing: 0.1px; - --mdc-icon-size: 14px; - --state-inactive-color: initial; - } - .entity ha-state-icon { - --ha-icon-display: block; - color: var(--color); - } - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-heading-entity": HuiHeadingEntity; - } -} diff --git a/src/panels/lovelace/cards/hui-heading-card.ts b/src/panels/lovelace/cards/hui-heading-card.ts index 63bad9a61f..ee972ed134 100644 --- a/src/panels/lovelace/cards/hui-heading-card.ts +++ b/src/panels/lovelace/cards/hui-heading-card.ts @@ -11,14 +11,25 @@ import { HomeAssistant } from "../../../types"; import { actionHandler } from "../common/directives/action-handler-directive"; import { handleAction } from "../common/handle-action"; import { hasAction } from "../common/has-action"; +import "../heading-badges/hui-heading-badge"; import type { LovelaceCard, LovelaceCardEditor, LovelaceLayoutOptions, } from "../types"; -import "./heading/hui-heading-entity"; import type { HeadingCardConfig } from "./types"; +export const migrateHeadingCardConfig = ( + config: HeadingCardConfig +): HeadingCardConfig => { + const newConfig = { ...config }; + if (newConfig.entities) { + newConfig.badges = [...(newConfig.badges || []), ...newConfig.entities]; + delete newConfig.entities; + } + return newConfig; +}; + @customElement("hui-heading-card") export class HuiHeadingCard extends LitElement implements LovelaceCard { public static async getConfigElement(): Promise { @@ -45,7 +56,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard { tap_action: { action: "none", }, - ...config, + ...migrateHeadingCardConfig(config), }; } @@ -73,6 +84,8 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard { const style = this._config.heading_style || "title"; + const badges = this._config.badges; + return html`
@@ -91,17 +104,17 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard { : nothing} ${actionable ? html`` : nothing}
- ${this._config.entities?.length + ${badges?.length ? html` -
- ${this._config.entities.map( +
+ ${badges.map( (config) => html` - - + ` )}
@@ -150,7 +163,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard { .container .content:not(:has(p)) { min-width: fit-content; } - .container .entities { + .container .badges { flex: 0 0; } .content { @@ -186,7 +199,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard { font-weight: 500; line-height: 20px; } - .entities { + .badges { display: flex; flex-direction: row; align-items: center; diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index e3d3257346..3c21617dfe 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -16,6 +16,7 @@ import { LovelaceRowConfig, } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; export type AlarmPanelCardConfigState = | "arm_away" @@ -503,21 +504,12 @@ export interface TileCardConfig extends LovelaceCardConfig { features?: LovelaceCardFeatureConfig[]; } -export interface HeadingEntityConfig { - entity: string; - state_content?: string | string[]; - icon?: string; - show_state?: boolean; - show_icon?: boolean; - color?: string; - tap_action?: ActionConfig; - visibility?: Condition[]; -} - export interface HeadingCardConfig extends LovelaceCardConfig { heading_style?: "title" | "subtitle"; heading?: string; icon?: string; tap_action?: ActionConfig; - entities?: (string | HeadingEntityConfig)[]; + badges?: LovelaceHeadingBadgeConfig[]; + /** @deprecated Use `badges` instead */ + entities?: LovelaceHeadingBadgeConfig[]; } diff --git a/src/panels/lovelace/create-element/create-element-base.ts b/src/panels/lovelace/create-element/create-element-base.ts index 33aaecb0af..7346e63b62 100644 --- a/src/panels/lovelace/create-element/create-element-base.ts +++ b/src/panels/lovelace/create-element/create-element-base.ts @@ -16,6 +16,7 @@ import type { ErrorCardConfig } from "../cards/types"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; import { LovelaceRow, LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; import { LovelaceBadge, LovelaceBadgeConstructor, @@ -26,6 +27,8 @@ import { LovelaceElementConstructor, LovelaceHeaderFooter, LovelaceHeaderFooterConstructor, + LovelaceHeadingBadge, + LovelaceHeadingBadgeConstructor, LovelaceRowConstructor, } from "../types"; @@ -72,6 +75,11 @@ interface CreateElementConfigTypes { element: LovelaceSectionElement; constructor: unknown; }; + "heading-badge": { + config: LovelaceHeadingBadgeConfig; + element: LovelaceHeadingBadge; + constructor: LovelaceHeadingBadgeConstructor; + }; } export const createErrorCardElement = (config: ErrorCardConfig) => { @@ -102,6 +110,20 @@ export const createErrorBadgeElement = (config: ErrorCardConfig) => { return el; }; +export const createErrorHeadingBadgeElement = (config: ErrorCardConfig) => { + const el = document.createElement("hui-error-heading-badge"); + if (customElements.get("hui-error-heading-badge")) { + el.setConfig(config); + } else { + import("../heading-badges/hui-error-heading-badge"); + customElements.whenDefined("hui-error-heading-badge").then(() => { + customElements.upgrade(el); + el.setConfig(config); + }); + } + return el; +}; + export const createErrorCardConfig = (error, origConfig) => ({ type: "error", error, @@ -114,6 +136,12 @@ export const createErrorBadgeConfig = (error, origConfig) => ({ origConfig, }); +export const createErrorHeadingBadgeConfig = (error, origConfig) => ({ + type: "error", + error, + origConfig, +}); + const _createElement = ( tag: string, config: CreateElementConfigTypes[T]["config"] @@ -134,6 +162,11 @@ const _createErrorElement = ( if (tagSuffix === "badge") { return createErrorBadgeElement(createErrorBadgeConfig(error, config)); } + if (tagSuffix === "heading-badge") { + return createErrorHeadingBadgeElement( + createErrorHeadingBadgeConfig(error, config) + ); + } return createErrorCardElement(createErrorCardConfig(error, config)); }; diff --git a/src/panels/lovelace/create-element/create-heading-badge-element.ts b/src/panels/lovelace/create-element/create-heading-badge-element.ts new file mode 100644 index 0000000000..e45bb6f14f --- /dev/null +++ b/src/panels/lovelace/create-element/create-heading-badge-element.ts @@ -0,0 +1,22 @@ +import "../heading-badges/hui-entity-heading-badge"; + +import { + createLovelaceElement, + getLovelaceElementClass, +} from "./create-element-base"; +import { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; + +const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]); + +export const createHeadingBadgeElement = (config: LovelaceHeadingBadgeConfig) => + createLovelaceElement( + "heading-badge", + config, + ALWAYS_LOADED_TYPES, + undefined, + undefined, + "entity" + ); + +export const getHeadingBadgeElementClass = (type: string) => + getLovelaceElementClass(type, "heading-badge", ALWAYS_LOADED_TYPES); diff --git a/src/panels/lovelace/editor/config-elements/hui-entities-editor.ts b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts similarity index 67% rename from src/panels/lovelace/editor/config-elements/hui-entities-editor.ts rename to src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts index e47abf3280..c102f3d37e 100644 --- a/src/panels/lovelace/editor/config-elements/hui-entities-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts @@ -14,23 +14,21 @@ import "../../../../components/ha-list-item"; import "../../../../components/ha-sortable"; import "../../../../components/ha-svg-icon"; import { HomeAssistant } from "../../../../types"; - -type EntityConfig = { - entity: string; -}; +import { LovelaceHeadingBadgeConfig } from "../../heading-badges/types"; declare global { interface HASSDomEvents { - "edit-entity": { index: number }; + "edit-heading-badge": { index: number }; + "heading-badges-changed": { badges: LovelaceHeadingBadgeConfig[] }; } } -@customElement("hui-entities-editor") -export class HuiEntitiesEditor extends LitElement { +@customElement("hui-heading-badges-editor") +export class HuiHeadingBadgesEditor extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) - public entities?: EntityConfig[]; + public badges?: LovelaceHeadingBadgeConfig[]; @query(".add-container", true) private _addContainer?: HTMLDivElement; @@ -40,14 +38,30 @@ export class HuiEntitiesEditor extends LitElement { private _opened = false; - private _entitiesKeys = new WeakMap(); + private _badgesKeys = new WeakMap(); - private _getKey(entity: EntityConfig) { - if (!this._entitiesKeys.has(entity)) { - this._entitiesKeys.set(entity, Math.random().toString()); + private _getKey(badge: LovelaceHeadingBadgeConfig) { + if (!this._badgesKeys.has(badge)) { + this._badgesKeys.set(badge, Math.random().toString()); } - return this._entitiesKeys.get(entity)!; + return this._badgesKeys.get(badge)!; + } + + private _computeBadgeLabel(badge: LovelaceHeadingBadgeConfig) { + const type = badge.type ?? "entity"; + + if (type === "entity") { + const entityId = "entity" in badge ? (badge.entity as string) : undefined; + const stateObj = entityId ? this.hass.states[entityId] : undefined; + return ( + (stateObj && stateObj.attributes.friendly_name) || + entityId || + type || + "Unknown badge" + ); + } + return type; } protected render() { @@ -56,46 +70,35 @@ export class HuiEntitiesEditor extends LitElement { } return html` - ${this.entities + ${this.badges ? html`
${repeat( - this.entities, - (entityConf) => this._getKey(entityConf), - (entityConf, index) => { - const editable = true; - - const entityId = entityConf.entity; - const stateObj = this.hass.states[entityId]; - const name = stateObj - ? stateObj.attributes.friendly_name - : undefined; + this.badges, + (badge) => this._getKey(badge), + (badge, index) => { + const label = this._computeBadgeLabel(badge); return html` -
+
-
- ${name || entityId} +
+ ${label}
- ${editable - ? html` - - ` - : nothing} + * { + .badge .handle > * { pointer-events: none; } - .entity-content { + .badge-content { height: 60px; font-size: 16px; display: flex; @@ -252,7 +258,7 @@ export class HuiEntitiesEditor extends LitElement { flex-grow: 1; } - .entity-content div { + .badge-content div { display: flex; flex-direction: column; } @@ -291,6 +297,6 @@ export class HuiEntitiesEditor extends LitElement { declare global { interface HTMLElementTagNameMap { - "hui-entities-editor": HuiEntitiesEditor; + "hui-heading-badges-editor": HuiHeadingBadgesEditor; } } diff --git a/src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts index 727a4223d7..69c89e6bc8 100644 --- a/src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts @@ -22,15 +22,19 @@ import type { } from "../../../../components/ha-form/types"; import "../../../../components/ha-svg-icon"; import type { HomeAssistant } from "../../../../types"; -import type { HeadingCardConfig, HeadingEntityConfig } from "../../cards/types"; +import { migrateHeadingCardConfig } from "../../cards/hui-heading-card"; +import type { HeadingCardConfig } from "../../cards/types"; import { UiAction } from "../../components/hui-action-editor"; +import { + EntityHeadingBadgeConfig, + LovelaceHeadingBadgeConfig, +} from "../../heading-badges/types"; import type { LovelaceCardEditor } from "../../types"; -import { processEditorEntities } from "../process-editor-entities"; import { actionConfigStruct } from "../structs/action-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; -import { configElementStyle } from "./config-elements-style"; -import "./hui-entities-editor"; import { EditSubElementEvent } from "../types"; +import { configElementStyle } from "./config-elements-style"; +import "./hui-heading-badges-editor"; const actions: UiAction[] = ["navigate", "url", "perform-action", "none"]; @@ -41,7 +45,7 @@ const cardConfigStruct = assign( heading: optional(string()), icon: optional(string()), tap_action: optional(actionConfigStruct), - entities: optional(array(any())), + badges: optional(array(any())), }) ); @@ -55,8 +59,8 @@ export class HuiHeadingCardEditor @state() private _config?: HeadingCardConfig; public setConfig(config: HeadingCardConfig): void { - assert(config, cardConfigStruct); - this._config = config; + this._config = migrateHeadingCardConfig(config); + assert(this._config, cardConfigStruct); } private _schema = memoizeOne( @@ -103,8 +107,9 @@ export class HuiHeadingCardEditor ] as const satisfies readonly HaFormSchema[] ); - private _entities = memoizeOne((entities: HeadingCardConfig["entities"]) => - processEditorEntities(entities || []) + private _badges = memoizeOne( + (badges: HeadingCardConfig["badges"]): LovelaceHeadingBadgeConfig[] => + badges || [] ); protected render() { @@ -138,19 +143,19 @@ export class HuiHeadingCardEditor )}
- - +
`; } - private _entitiesChanged(ev: CustomEvent): void { + private _badgesChanged(ev: CustomEvent): void { ev.stopPropagation(); if (!this._config || !this.hass) { return; @@ -158,7 +163,7 @@ export class HuiHeadingCardEditor const config = { ...this._config, - entities: ev.detail.entities as HeadingEntityConfig[], + badges: ev.detail.badges as LovelaceHeadingBadgeConfig[], }; fireEvent(this, "config-changed", { config }); @@ -175,22 +180,22 @@ export class HuiHeadingCardEditor fireEvent(this, "config-changed", { config }); } - private _editEntity(ev: HASSDomEvent<{ index: number }>): void { + private _editBadge(ev: HASSDomEvent<{ index: number }>): void { ev.stopPropagation(); const index = ev.detail.index; - const config = this._config!.entities![index!]; + const config = this._badges(this._config!.badges)[index]; fireEvent(this, "edit-sub-element", { config: config, - saveConfig: (newConfig) => this._updateEntity(index, newConfig), - type: "heading-entity", - } as EditSubElementEvent); + saveConfig: (newConfig) => this._updateBadge(index, newConfig), + type: "heading-badge", + } as EditSubElementEvent); } - private _updateEntity(index: number, entity: HeadingEntityConfig) { - const entities = this._config!.entities!.concat(); - entities[index] = entity; - const config = { ...this._config!, entities }; + private _updateBadge(index: number, entity: EntityHeadingBadgeConfig) { + const badges = this._config!.badges!.concat(); + badges[index] = entity; + const config = { ...this._config!, badges }; fireEvent(this, "config-changed", { config: config, }); diff --git a/src/panels/lovelace/editor/heading-entity/hui-heading-entity-editor.ts b/src/panels/lovelace/editor/heading-badge-editor/hui-entity-heading-badge-editor.ts similarity index 92% rename from src/panels/lovelace/editor/heading-entity/hui-heading-entity-editor.ts rename to src/panels/lovelace/editor/heading-badge-editor/hui-entity-heading-badge-editor.ts index 6d0c4f3d51..b1446d4a12 100644 --- a/src/panels/lovelace/editor/heading-entity/hui-heading-entity-editor.ts +++ b/src/panels/lovelace/editor/heading-badge-editor/hui-entity-heading-badge-editor.ts @@ -21,19 +21,21 @@ import type { SchemaUnion, } from "../../../../components/ha-form/types"; import type { HomeAssistant } from "../../../../types"; -import type { HeadingEntityConfig } from "../../cards/types"; import { Condition } from "../../common/validate-condition"; +import { EntityHeadingBadgeConfig } from "../../heading-badges/types"; import type { LovelaceGenericElementEditor } from "../../types"; import "../conditions/ha-card-conditions-editor"; import { configElementStyle } from "../config-elements/config-elements-style"; import { actionConfigStruct } from "../structs/action-struct"; -export const DEFAULT_CONFIG: Partial = { +export const DEFAULT_CONFIG: Partial = { + type: "entity", show_state: true, show_icon: true, }; const entityConfigStruct = object({ + type: optional(string()), entity: string(), icon: optional(string()), state_content: optional(union([string(), array(string())])), @@ -44,7 +46,7 @@ const entityConfigStruct = object({ visibility: optional(array(any())), }); -type FormData = HeadingEntityConfig & { +type FormData = EntityHeadingBadgeConfig & { displayed_elements?: string[]; }; @@ -57,9 +59,9 @@ export class HuiHeadingEntityEditor @property({ type: Boolean }) public preview = false; - @state() private _config?: HeadingEntityConfig; + @state() private _config?: EntityHeadingBadgeConfig; - public setConfig(config: HeadingEntityConfig): void { + public setConfig(config: EntityHeadingBadgeConfig): void { assert(config, entityConfigStruct); this._config = { ...DEFAULT_CONFIG, @@ -150,12 +152,14 @@ export class HuiHeadingEntityEditor ] as const satisfies readonly HaFormSchema[] ); - private _displayedElements = memoizeOne((config: HeadingEntityConfig) => { - const elements: string[] = []; - if (config.show_state) elements.push("state"); - if (config.show_icon) elements.push("icon"); - return elements; - }); + private _displayedElements = memoizeOne( + (config: EntityHeadingBadgeConfig) => { + const elements: string[] = []; + if (config.show_state) elements.push("state"); + if (config.show_icon) elements.push("icon"); + return elements; + } + ); protected render() { if (!this.hass || !this._config) { @@ -228,7 +232,7 @@ export class HuiHeadingEntityEditor const conditions = ev.detail.value as Condition[]; - const newConfig: HeadingEntityConfig = { + const newConfig: EntityHeadingBadgeConfig = { ...this._config, visibility: conditions, }; diff --git a/src/panels/lovelace/editor/heading-badge-editor/hui-heading-badge-element-editor.ts b/src/panels/lovelace/editor/heading-badge-editor/hui-heading-badge-element-editor.ts new file mode 100644 index 0000000000..dadef34f8b --- /dev/null +++ b/src/panels/lovelace/editor/heading-badge-editor/hui-heading-badge-element-editor.ts @@ -0,0 +1,42 @@ +import { customElement } from "lit/decorators"; +import { getHeadingBadgeElementClass } from "../../create-element/create-heading-badge-element"; +import type { EntityHeadingBadgeConfig } from "../../heading-badges/types"; +import { LovelaceConfigForm, LovelaceHeadingBadgeEditor } from "../../types"; +import { HuiTypedElementEditor } from "../hui-typed-element-editor"; + +@customElement("hui-heading-badge-element-editor") +export class HuiHeadingEntityElementEditor extends HuiTypedElementEditor { + protected get configElementType(): string | undefined { + return this.value?.type || "entity"; + } + + protected async getConfigElement(): Promise< + LovelaceHeadingBadgeEditor | undefined + > { + const elClass = await getHeadingBadgeElementClass(this.configElementType!); + + // Check if a GUI editor exists + if (elClass && elClass.getConfigElement) { + return elClass.getConfigElement(); + } + + return undefined; + } + + protected async getConfigForm(): Promise { + const elClass = await getHeadingBadgeElementClass(this.configElementType!); + + // Check if a schema exists + if (elClass && elClass.getConfigForm) { + return elClass.getConfigForm(); + } + + return undefined; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-heading-badge-element-editor": HuiHeadingEntityElementEditor; + } +} diff --git a/src/panels/lovelace/editor/heading-entity/hui-heading-entity-element-editor.ts b/src/panels/lovelace/editor/heading-entity/hui-heading-entity-element-editor.ts deleted file mode 100644 index ada27a39d2..0000000000 --- a/src/panels/lovelace/editor/heading-entity/hui-heading-entity-element-editor.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { customElement } from "lit/decorators"; -import { HeadingEntityConfig } from "../../cards/types"; -import { HuiElementEditor } from "../hui-element-editor"; -import type { HuiHeadingEntityEditor } from "./hui-heading-entity-editor"; - -@customElement("hui-heading-entity-element-editor") -export class HuiHeadingEntityElementEditor extends HuiElementEditor { - protected async getConfigElement(): Promise< - HuiHeadingEntityEditor | undefined - > { - await import("./hui-heading-entity-editor"); - return document.createElement("hui-heading-entity-editor"); - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-heading-entity-element-editor": HuiHeadingEntityElementEditor; - } -} diff --git a/src/panels/lovelace/editor/hui-sub-element-editor.ts b/src/panels/lovelace/editor/hui-sub-element-editor.ts index 1202d598a3..0d01341abc 100644 --- a/src/panels/lovelace/editor/hui-sub-element-editor.ts +++ b/src/panels/lovelace/editor/hui-sub-element-editor.ts @@ -15,7 +15,7 @@ import type { HomeAssistant } from "../../../types"; import "./entity-row-editor/hui-row-element-editor"; import "./feature-editor/hui-card-feature-element-editor"; import "./header-footer-editor/hui-header-footer-element-editor"; -import "./heading-entity/hui-heading-entity-element-editor"; +import "./heading-badge-editor/hui-heading-badge-element-editor"; import type { HuiElementEditor } from "./hui-element-editor"; import "./picture-element-editor/hui-picture-element-element-editor"; import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types"; @@ -132,16 +132,16 @@ export class HuiSubElementEditor extends LitElement { @GUImode-changed=${this._handleGUIModeChanged} > `; - case "heading-entity": + case "heading-badge": return html` - + > `; default: return nothing; diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index a409f00729..c62c7595ab 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -9,7 +9,7 @@ import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceCardFeatureConfig } from "../card-features/types"; import { LovelaceElementConfig } from "../elements/types"; import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; -import { HeadingEntityConfig } from "../cards/types"; +import { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; export interface YamlChangedEvent extends Event { detail: { @@ -97,10 +97,10 @@ export interface SubElementEditorConfig { | LovelaceHeaderFooterConfig | LovelaceCardFeatureConfig | LovelaceElementConfig - | HeadingEntityConfig; + | LovelaceHeadingBadgeConfig; saveElementConfig?: (elementConfig: any) => void; context?: any; - type: "header" | "footer" | "row" | "feature" | "element" | "heading-entity"; + type: "header" | "footer" | "row" | "feature" | "element" | "heading-badge"; } export interface EditSubElementEvent { diff --git a/src/panels/lovelace/heading-badges/hui-entity-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-entity-heading-badge.ts new file mode 100644 index 0000000000..b6084a846d --- /dev/null +++ b/src/panels/lovelace/heading-badges/hui-entity-heading-badge.ts @@ -0,0 +1,177 @@ +import { mdiAlertCircle } from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; +import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { stateActive } from "../../../common/entity/state_active"; +import { stateColorCss } from "../../../common/entity/state_color"; +import "../../../components/ha-heading-badge"; +import "../../../components/ha-state-icon"; +import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; +import "../../../state-display/state-display"; +import { HomeAssistant } from "../../../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { handleAction } from "../common/handle-action"; +import { hasAction } from "../common/has-action"; +import { DEFAULT_CONFIG } from "../editor/heading-badge-editor/hui-entity-heading-badge-editor"; +import { LovelaceHeadingBadge, LovelaceHeadingBadgeEditor } from "../types"; +import { EntityHeadingBadgeConfig } from "./types"; + +@customElement("hui-entity-heading-badge") +export class HuiEntityHeadingBadge + extends LitElement + implements LovelaceHeadingBadge +{ + public static async getConfigElement(): Promise { + await import( + "../editor/heading-badge-editor/hui-entity-heading-badge-editor" + ); + return document.createElement("hui-heading-entity-editor"); + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: EntityHeadingBadgeConfig; + + @property({ type: Boolean }) public preview = false; + + public setConfig(config): void { + this._config = { + ...DEFAULT_CONFIG, + tap_action: { + action: "none", + }, + ...config, + }; + } + + private _handleAction(ev: ActionHandlerEvent) { + const config: EntityHeadingBadgeConfig = { + tap_action: { + action: "none", + }, + ...this._config!, + }; + handleAction(this, this.hass!, config, ev.detail.action!); + } + + private _computeStateColor = memoizeOne( + (entity: HassEntity, color?: string) => { + if (!color || color === "none") { + return undefined; + } + + if (color === "state") { + // Use light color if the light support rgb + if ( + computeDomain(entity.entity_id) === "light" && + entity.attributes.rgb_color + ) { + const hsvColor = rgb2hsv(entity.attributes.rgb_color); + + // Modify the real rgb color for better contrast + if (hsvColor[1] < 0.4) { + // Special case for very light color (e.g: white) + if (hsvColor[1] < 0.1) { + hsvColor[2] = 225; + } else { + hsvColor[1] = 0.4; + } + } + return rgb2hex(hsv2rgb(hsvColor)); + } + // Fallback to state color + return stateColorCss(entity); + } + + if (color) { + // Use custom color if active + return stateActive(entity) ? computeCssColor(color) : undefined; + } + return color; + } + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const config = this._config; + + const entityId = config.entity; + const stateObj = this.hass!.states[entityId]; + + if (!stateObj) { + return html` + + + - + + `; + } + + const color = this._computeStateColor(stateObj, config.color); + + const style = { + "--icon-color": color, + }; + + return html` + + ${config.show_icon + ? html` + + ` + : nothing} + ${config.show_state + ? html` + + ` + : nothing} + + `; + } + + static get styles(): CSSResultGroup { + return css` + [role="button"] { + cursor: pointer; + } + ha-heading-badge { + --state-inactive-color: initial; + } + ha-heading-badge.error { + --icon-color: var(--red-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-entity-heading-badge": HuiEntityHeadingBadge; + } +} diff --git a/src/panels/lovelace/heading-badges/hui-error-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-error-heading-badge.ts new file mode 100644 index 0000000000..9210592763 --- /dev/null +++ b/src/panels/lovelace/heading-badges/hui-error-heading-badge.ts @@ -0,0 +1,94 @@ +import { mdiAlertCircle } from "@mdi/js"; +import { dump } from "js-yaml"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, state } from "lit/decorators"; +import "../../../components/ha-badge"; +import "../../../components/ha-svg-icon"; +import { HomeAssistant } from "../../../types"; +import { showAlertDialog } from "../custom-card-helpers"; +import { LovelaceBadge } from "../types"; +import { ErrorBadgeConfig } from "./types"; + +export const createErrorHeadingBadgeElement = (config) => { + const el = document.createElement("hui-error-heading-badge"); + el.setConfig(config); + return el; +}; + +export const createErrorHeadingBadgeConfig = (error) => ({ + type: "error", + error, +}); + +@customElement("hui-error-heading-badge") +export class HuiErrorHeadingBadge extends LitElement implements LovelaceBadge { + public hass?: HomeAssistant; + + @state() private _config?: ErrorBadgeConfig; + + public setConfig(config: ErrorBadgeConfig): void { + this._config = config; + } + + private _viewDetail() { + let dumped: string | undefined; + + if (this._config!.origConfig) { + try { + dumped = dump(this._config!.origConfig); + } catch (err: any) { + dumped = `[Error dumping ${this._config!.origConfig}]`; + } + } + + showAlertDialog(this, { + title: this._config?.error, + warning: true, + text: dumped ? html`
${dumped}
` : "", + }); + } + + protected render() { + if (!this._config) { + return nothing; + } + + return html` + + + ${this._config.error} + + `; + } + + static get styles(): CSSResultGroup { + return css` + ha-heading-badge { + --icon-color: var(--error-color); + color: var(--error-color); + } + .content { + max-width: 70px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + pre { + font-family: var(--code-font-family, monospace); + white-space: break-spaces; + user-select: text; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-error-heading-badge": HuiErrorHeadingBadge; + } +} diff --git a/src/panels/lovelace/heading-badges/hui-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-heading-badge.ts new file mode 100644 index 0000000000..92c5b04ca8 --- /dev/null +++ b/src/panels/lovelace/heading-badges/hui-heading-badge.ts @@ -0,0 +1,202 @@ +import { PropertyValues, ReactiveElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { MediaQueriesListener } from "../../../common/dom/media_query"; +import "../../../components/ha-svg-icon"; +import type { HomeAssistant } from "../../../types"; +import { + attachConditionMediaQueriesListeners, + checkConditionsMet, +} from "../common/validate-condition"; +import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element"; +import type { LovelaceHeadingBadge } from "../types"; +import { LovelaceHeadingBadgeConfig } from "./types"; + +declare global { + interface HASSDomEvents { + "heading-badge-visibility-changed": { value: boolean }; + "heading-badge-updated": undefined; + } +} + +@customElement("hui-heading-badge") +export class HuiHeadingBadge extends ReactiveElement { + @property({ type: Boolean }) public preview = false; + + @property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig; + + @property({ attribute: false }) public hass?: HomeAssistant; + + private _elementConfig?: LovelaceHeadingBadgeConfig; + + public load() { + if (!this.config) { + throw new Error("Cannot build heading badge without config"); + } + this._loadElement(this.config); + } + + private _element?: LovelaceHeadingBadge; + + private _listeners: MediaQueriesListener[] = []; + + protected createRenderRoot() { + return this; + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._clearMediaQueries(); + } + + public connectedCallback() { + super.connectedCallback(); + this._listenMediaQueries(); + this._updateVisibility(); + } + + private _updateElement(config: LovelaceHeadingBadgeConfig) { + if (!this._element) { + return; + } + this._element.setConfig(config); + this._elementConfig = config; + fireEvent(this, "heading-badge-updated"); + } + + private _loadElement(config: LovelaceHeadingBadgeConfig) { + this._element = createHeadingBadgeElement(config); + this._elementConfig = config; + if (this.hass) { + this._element.hass = this.hass; + } + this._element.addEventListener( + "ll-upgrade", + (ev: Event) => { + ev.stopPropagation(); + if (this.hass) { + this._element!.hass = this.hass; + } + fireEvent(this, "heading-badge-updated"); + }, + { once: true } + ); + this._element.addEventListener( + "ll-rebuild", + (ev: Event) => { + ev.stopPropagation(); + this._loadElement(config); + fireEvent(this, "heading-badge-updated"); + }, + { once: true } + ); + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this._updateVisibility(); + } + + protected willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + + if (!this._element) { + this.load(); + } + } + + protected update(changedProps: PropertyValues) { + super.update(changedProps); + + if (this._element) { + if (changedProps.has("config")) { + const elementConfig = this._elementConfig; + if (this.config !== elementConfig && this.config) { + const typeChanged = this.config?.type !== elementConfig?.type; + if (typeChanged) { + this._loadElement(this.config); + } else { + this._updateElement(this.config); + } + } + } + if (changedProps.has("hass")) { + try { + if (this.hass) { + this._element.hass = this.hass; + } + } catch (e: any) { + this._element = undefined; + this._elementConfig = undefined; + } + } + } + + if (changedProps.has("hass") || changedProps.has("preview")) { + this._updateVisibility(); + } + } + + private _clearMediaQueries() { + this._listeners.forEach((unsub) => unsub()); + this._listeners = []; + } + + private _listenMediaQueries() { + this._clearMediaQueries(); + if (!this.config?.visibility) { + return; + } + const conditions = this.config.visibility; + const hasOnlyMediaQuery = + conditions.length === 1 && + conditions[0].condition === "screen" && + !!conditions[0].media_query; + + this._listeners = attachConditionMediaQueriesListeners( + this.config.visibility, + (matches) => { + this._updateVisibility(hasOnlyMediaQuery && matches); + } + ); + } + + private _updateVisibility(forceVisible?: boolean) { + if (!this._element || !this.hass) { + return; + } + + if (this._element.hidden) { + this._setElementVisibility(false); + return; + } + + const visible = + forceVisible || + this.preview || + !this.config?.visibility || + checkConditionsMet(this.config.visibility, this.hass); + this._setElementVisibility(visible); + } + + private _setElementVisibility(visible: boolean) { + if (!this._element) return; + + if (this.hidden !== !visible) { + this.style.setProperty("display", visible ? "" : "none"); + this.toggleAttribute("hidden", !visible); + fireEvent(this, "heading-badge-visibility-changed", { value: visible }); + } + + if (!visible && this._element.parentElement) { + this.removeChild(this._element); + } else if (visible && !this._element.parentElement) { + this.appendChild(this._element); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-heading-badge": HuiHeadingBadge; + } +} diff --git a/src/panels/lovelace/heading-badges/types.ts b/src/panels/lovelace/heading-badges/types.ts new file mode 100644 index 0000000000..ac9a4a10cd --- /dev/null +++ b/src/panels/lovelace/heading-badges/types.ts @@ -0,0 +1,25 @@ +import { ActionConfig } from "../../../data/lovelace/config/action"; +import { Condition } from "../common/validate-condition"; + +export type LovelaceHeadingBadgeConfig = { + type?: string; + [key: string]: any; + visibility?: Condition[]; +}; + +export interface ErrorBadgeConfig extends LovelaceHeadingBadgeConfig { + type: string; + error: string; + origConfig: LovelaceHeadingBadgeConfig; +} + +export interface EntityHeadingBadgeConfig extends LovelaceHeadingBadgeConfig { + type?: "entity"; + entity: string; + state_content?: string | string[]; + icon?: string; + show_state?: boolean; + show_icon?: boolean; + color?: string; + tap_action?: ActionConfig; +} diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index ca9ae97654..7a30f3e1f8 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -13,6 +13,7 @@ import { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types"; import { LovelaceHeaderFooterConfig } from "./header-footer/types"; import { LovelaceCardFeatureConfig } from "./card-features/types"; import { LovelaceElement, LovelaceElementConfig } from "./elements/types"; +import { LovelaceHeadingBadgeConfig } from "./heading-badges/types"; declare global { // eslint-disable-next-line @@ -178,3 +179,27 @@ export interface LovelaceCardFeatureEditor extends LovelaceGenericElementEditor { setConfig(config: LovelaceCardFeatureConfig): void; } + +export interface LovelaceHeadingBadge extends HTMLElement { + hass?: HomeAssistant; + preview?: boolean; + setConfig(config: LovelaceHeadingBadgeConfig); +} + +export interface LovelaceHeadingBadgeConstructor + extends Constructor { + getStubConfig?: ( + hass: HomeAssistant, + stateObj?: HassEntity + ) => LovelaceHeadingBadgeConfig; + getConfigElement?: () => LovelaceHeadingBadgeEditor; + getConfigForm?: () => { + schema: HaFormSchema[]; + assertConfig?: (config: LovelaceCardConfig) => void; + }; +} + +export interface LovelaceHeadingBadgeEditor + extends LovelaceGenericElementEditor { + setConfig(config: LovelaceHeadingBadgeConfig): void; +} diff --git a/src/translations/en.json b/src/translations/en.json index ee8a3f1539..e68d165f77 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6403,7 +6403,7 @@ "row": "Entity row editor", "feature": "Feature editor", "element": "Element editor", - "heading-entity": "Entity editor", + "heading-badge": "Heading badge editor", "element_type": "{type} element editor" } }