diff --git a/src/panels/lovelace/cards/hui-heading-card.ts b/src/panels/lovelace/cards/hui-heading-card.ts index f8553d4669..2cd9b0190f 100644 --- a/src/panels/lovelace/cards/hui-heading-card.ts +++ b/src/panels/lovelace/cards/hui-heading-card.ts @@ -153,7 +153,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard { flex-direction: row; justify-content: space-between; align-items: center; - overflow: hidden; + overflow: visible; gap: var(--ha-space-2); } .content:hover ha-icon-next { diff --git a/src/panels/lovelace/create-element/create-heading-badge-element.ts b/src/panels/lovelace/create-element/create-heading-badge-element.ts index 282359295a..e50b8c427f 100644 --- a/src/panels/lovelace/create-element/create-heading-badge-element.ts +++ b/src/panels/lovelace/create-element/create-heading-badge-element.ts @@ -1,3 +1,4 @@ +import "../heading-badges/hui-button-heading-badge"; import "../heading-badges/hui-entity-heading-badge"; import { @@ -6,7 +7,7 @@ import { } from "./create-element-base"; import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; -const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]); +const ALWAYS_LOADED_TYPES = new Set(["error", "entity", "button"]); export const createHeadingBadgeElement = (config: LovelaceHeadingBadgeConfig) => createLovelaceElement( diff --git a/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts index 4054d87ea0..7c1035552d 100644 --- a/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts @@ -1,21 +1,33 @@ -import "@material/mwc-menu/mwc-menu-surface"; -import { mdiDelete, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js"; +import { + mdiDelete, + mdiDragHorizontalVariant, + mdiPencil, + mdiPlus, +} from "@mdi/js"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; import { repeat } from "lit/directives/repeat"; import { fireEvent } from "../../../../common/dom/fire_event"; -import { preventDefault } from "../../../../common/dom/prevent_default"; -import "../../../../components/entity/ha-entity-picker"; -import type { HaEntityPicker } from "../../../../components/entity/ha-entity-picker"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display"; +import { computeRTL } from "../../../../common/util/compute_rtl"; import "../../../../components/ha-button"; +import "../../../../components/ha-button-menu"; import "../../../../components/ha-icon-button"; +import "../../../../components/ha-list-item"; import "../../../../components/ha-sortable"; import "../../../../components/ha-svg-icon"; import type { HomeAssistant } from "../../../../types"; +import { getHeadingBadgeElementClass } from "../../create-element/create-heading-badge-element"; import type { + ButtonHeadingBadgeConfig, EntityHeadingBadgeConfig, LovelaceHeadingBadgeConfig, } from "../../heading-badges/types"; +import { nextRender } from "../../../../common/util/render-status"; + +const UI_BADGE_TYPES = ["entity", "button"] as const; declare global { interface HASSDomEvents { @@ -41,8 +53,12 @@ export class HuiHeadingBadgesEditor extends LitElement { return this._badgesKeys.get(badge)!; } - private _createValueChangedHandler(index: number) { - return (ev: CustomEvent) => this._valueChanged(ev, index); + private _getBadgeTypeLabel(type: string): string { + return ( + this.hass.localize( + `ui.panel.lovelace.editor.heading-badges.types.${type}.label` + ) || type + ); } protected render() { @@ -51,120 +67,186 @@ export class HuiHeadingBadgesEditor extends LitElement { } return html` - ${this.badges + ${this.badges?.length ? html` -
+
${repeat( - this.badges, + this.badges.filter(Boolean), (badge) => this._getKey(badge), - (badge, index) => { - const type = badge.type ?? "entity"; - const isEntityBadge = - type === "entity" && "entity" in badge; - const entityBadge = isEntityBadge - ? (badge as EntityHeadingBadgeConfig) - : undefined; - return html` -
-
- -
- ${isEntityBadge && entityBadge - ? html` - - ` - : html` -
- ${type} -
- `} - - -
- `; - } + (badge, index) => this._renderBadgeItem(badge, index) )}
` : nothing} -
- + + + + ${this.hass.localize(`ui.panel.lovelace.editor.heading-badges.add`)} + + ${UI_BADGE_TYPES.map( + (type) => html` + + ${this._getBadgeTypeLabel(type)} + + ` + )} + + `; + } + + private _renderBadgeItem(badge: LovelaceHeadingBadgeConfig, index: number) { + const type = badge.type ?? "entity"; + const entityBadge = badge as EntityHeadingBadgeConfig; + const isWarning = + type === "entity" && + (!entityBadge.entity || !this.hass.states[entityBadge.entity]); + + return html` +
+
+ +
+ ${type === "entity" + ? this._renderEntityBadge(entityBadge) + : type === "button" + ? this._renderButtonBadge(badge as ButtonHeadingBadgeConfig) + : this._renderUnknownBadge(type)} + +
`; } - private _entityPicked(ev: CustomEvent): void { - ev.stopPropagation(); - if (!ev.detail.value) { - return; + private _renderEntityBadge(badge: EntityHeadingBadgeConfig) { + const entityId = badge.entity; + const stateObj = entityId ? this.hass.states[entityId] : undefined; + + if (!entityId) { + return html` +
+
+ ${this._getBadgeTypeLabel("entity")} + ${this.hass.localize( + "ui.panel.lovelace.editor.heading-badges.no_entity" + )} +
+
+ `; } - const newEntity: LovelaceHeadingBadgeConfig = { - type: "entity", - entity: ev.detail.value, - }; - const newBadges = [...(this.badges || []), newEntity]; - (ev.target as HaEntityPicker).value = undefined; - fireEvent(this, "heading-badges-changed", { badges: newBadges }); + + if (!stateObj) { + return html` +
+
+ ${entityId} + ${this.hass.localize( + "ui.panel.lovelace.editor.heading-badges.entity_not_found" + )} +
+
+ `; + } + + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); + + const isRTL = computeRTL(this.hass); + + const primary = entityName || deviceName || entityId; + const secondary = [entityName ? deviceName : undefined, areaName] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + return html` +
+
+ ${primary} + ${secondary + ? html`${secondary}` + : nothing} +
+
+ `; } - private _valueChanged(ev: CustomEvent, index: number): void { - ev.stopPropagation(); - const value = ev.detail.value; - const newBadges = [...(this.badges || [])]; + private _renderButtonBadge(badge: ButtonHeadingBadgeConfig) { + return html` +
+
+ ${this._getBadgeTypeLabel("button")} + ${badge.text + ? html`${badge.text}` + : nothing} +
+
+ `; + } - if (!value) { - newBadges.splice(index, 1); + private _renderUnknownBadge(type: string) { + return html` +
+
+ ${type} +
+
+ `; + } + + private async _addBadge(ev: CustomEvent): Promise { + const index = ev.detail.index as number; + + if (index == null) return; + + const type = UI_BADGE_TYPES[index]; + if (!type) return; + + const elClass = await getHeadingBadgeElementClass(type); + + let newBadge: LovelaceHeadingBadgeConfig; + if (elClass && elClass.getStubConfig) { + newBadge = elClass.getStubConfig(this.hass); } else { - newBadges[index] = { - ...newBadges[index], - entity: value, - }; + newBadge = { type } as LovelaceHeadingBadgeConfig; } + const newBadges = [...(this.badges || []), newBadge]; + fireEvent(this, "heading-badges-changed", { badges: newBadges }); + + await nextRender(); + // Open the editor for the new badge + fireEvent(this, "edit-heading-badge", { index: newBadges.length - 1 }); } private _badgeMoved(ev: CustomEvent): void { @@ -177,7 +259,7 @@ export class HuiHeadingBadgesEditor extends LitElement { fireEvent(this, "heading-badges-changed", { badges: newBadges }); } - private _removeEntity(ev: CustomEvent): void { + private _removeBadge(ev: CustomEvent): void { const index = (ev.currentTarget as any).index; const newBadges = [...(this.badges || [])]; @@ -198,11 +280,12 @@ export class HuiHeadingBadgesEditor extends LitElement { display: flex !important; flex-direction: column; } - ha-button { + + ha-button-menu { margin-top: var(--ha-space-2); } - .entities { + .badges { display: flex; flex-direction: column; gap: var(--ha-space-2); @@ -212,6 +295,7 @@ export class HuiHeadingBadgesEditor extends LitElement { display: flex; align-items: center; } + .badge .handle { cursor: move; /* fallback if grab cursor is unsupported */ cursor: grab; @@ -220,13 +304,14 @@ export class HuiHeadingBadgesEditor extends LitElement { padding-inline-start: initial; direction: var(--direction); } + .badge .handle > * { pointer-events: none; } .badge-content { - height: 60px; - font-size: var(--ha-font-size-l); + height: var(--ha-space-12); + font-size: var(--ha-font-size-m); display: flex; align-items: center; justify-content: space-between; @@ -238,15 +323,9 @@ export class HuiHeadingBadgesEditor extends LitElement { flex-direction: column; } - .badge ha-entity-picker { - flex-grow: 1; - min-width: 0; - margin-top: 0; - } - .remove-icon, .edit-icon { - --mdc-icon-button-size: 36px; + --mdc-icon-button-size: var(--ha-space-9); color: var(--secondary-text-color); } @@ -255,24 +334,19 @@ export class HuiHeadingBadgesEditor extends LitElement { color: var(--secondary-text-color); } + .badge.warning { + background-color: var(--ha-color-fill-warning-quiet-resting); + border-radius: var(--ha-border-radius-sm); + overflow: hidden; + } + + .badge.warning .secondary { + color: var(--ha-color-on-warning-normal); + } + li[divider] { border-bottom-color: var(--divider-color); } - - .add-container { - position: relative; - width: 100%; - margin-top: var(--ha-space-2); - } - - mwc-menu-surface { - --mdc-menu-min-width: 100%; - } - - ha-entity-picker { - display: block; - width: 100%; - } `; } 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 92f72e1b99..20c35db94c 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 @@ -139,9 +139,7 @@ export class HuiHeadingCardEditor

- ${this.hass!.localize( - "ui.panel.lovelace.editor.card.heading.entities" - )} + ${this.hass!.localize("ui.panel.lovelace.editor.card.heading.badges")}

+ [ + { + name: "text", + selector: { text: {} }, + }, + { + name: "", + type: "grid", + schema: [ + { + name: "icon", + selector: { icon: {} }, + }, + { + name: "color", + selector: { + ui_color: {}, + }, + }, + ], + }, + { + name: "interactions", + type: "expandable", + flatten: true, + iconPath: mdiGestureTap, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "none", + }, + }, + }, + { + name: "", + type: "optional_actions", + flatten: true, + schema: (["hold_action", "double_tap_action"] as const).map( + (action) => ({ + name: action, + selector: { + ui_action: { + default_action: "none" as const, + }, + }, + }) + ), + }, + ], + }, + ] as const satisfies readonly HaFormSchema[] + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const schema = this._schema(); + + const conditions = this._config.visibility ?? []; + + return html` + + + +

+ ${this.hass!.localize( + "ui.panel.lovelace.editor.card.heading.button_config.visibility" + )} +

+
+

+ ${this.hass.localize( + "ui.panel.lovelace.editor.card.heading.button_config.visibility_explanation" + )} +

+ + +
+
+ `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const config = ev.detail.value as ButtonHeadingBadgeConfig; + + fireEvent(this, "config-changed", { config }); + } + + private _conditionChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const conditions = ev.detail.value as Condition[]; + + const newConfig: ButtonHeadingBadgeConfig = { + ...this._config, + visibility: conditions, + }; + if (newConfig.visibility?.length === 0) { + delete newConfig.visibility; + } + + fireEvent(this, "config-changed", { config: newConfig }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "text": + case "color": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.heading.button_config.${schema.name}` + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + } + }; + + static get styles() { + return [ + configElementStyle, + css` + .container { + display: flex; + flex-direction: column; + } + ha-form { + display: block; + margin-bottom: 24px; + } + .intro { + margin: 0; + color: var(--secondary-text-color); + margin-bottom: 8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-button-heading-badge-editor": HuiButtonHeadingBadgeEditor; + } +} diff --git a/src/panels/lovelace/heading-badges/hui-button-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-button-heading-badge.ts new file mode 100644 index 0000000000..bffb00172c --- /dev/null +++ b/src/panels/lovelace/heading-badges/hui-button-heading-badge.ts @@ -0,0 +1,143 @@ +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { classMap } from "lit/directives/class-map"; +import { computeCssColor } from "../../../common/color/compute-color"; +import "../../../components/ha-control-button"; +import "../../../components/ha-icon"; +import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; +import type { HomeAssistant } from "../../../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { handleAction } from "../common/handle-action"; +import { hasAction } from "../common/has-action"; +import type { + LovelaceHeadingBadge, + LovelaceHeadingBadgeEditor, +} from "../types"; +import type { ButtonHeadingBadgeConfig } from "./types"; + +const DEFAULT_ACTIONS: Pick< + ButtonHeadingBadgeConfig, + "tap_action" | "hold_action" | "double_tap_action" +> = { + tap_action: { action: "none" }, + hold_action: { action: "none" }, + double_tap_action: { action: "none" }, +}; + +@customElement("hui-button-heading-badge") +export class HuiButtonHeadingBadge + extends LitElement + implements LovelaceHeadingBadge +{ + public static async getConfigElement(): Promise { + await import("../editor/heading-badge-editor/hui-button-heading-badge-editor"); + return document.createElement("hui-button-heading-badge-editor"); + } + + public static getStubConfig(): ButtonHeadingBadgeConfig { + return { + type: "button", + icon: "mdi:gesture-tap-button", + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: ButtonHeadingBadgeConfig; + + @property({ type: Boolean }) public preview = false; + + public setConfig(config: ButtonHeadingBadgeConfig): void { + this._config = { + ...DEFAULT_ACTIONS, + ...config, + }; + } + + get hasAction() { + return ( + hasAction(this._config?.tap_action) || + hasAction(this._config?.hold_action) || + hasAction(this._config?.double_tap_action) + ); + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const config = this._config; + + const color = config.color ? computeCssColor(config.color) : undefined; + + const style = { "--color": color }; + + return html` + + + ${config.icon + ? html`` + : nothing} + ${config.text + ? html`${config.text}` + : nothing} + + + `; + } + + static styles = css` + ha-control-button { + --control-button-border-radius: var( + --ha-heading-badge-border-radius, + var(--ha-border-radius-pill) + ); + --control-button-padding: 0; + --mdc-icon-size: var(--ha-heading-badge-icon-size, 14px); + width: auto; + height: var(--ha-heading-badge-size, 26px); + min-width: var(--ha-heading-badge-size, 26px); + font-size: var(--ha-font-size-s); + } + ha-control-button.with-text { + --control-button-padding: 0 var(--ha-space-2); + } + ha-control-button.colored { + --control-button-icon-color: var(--color); + --control-button-background-color: var(--color); + --control-button-focus-color: var(--color); + --ha-ripple-color: var(--color); + } + .content { + display: flex; + flex-direction: row; + align-items: center; + white-space: nowrap; + } + .text { + padding: 0 var(--ha-space-1); + line-height: 1; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-button-heading-badge": HuiButtonHeadingBadge; + } +} diff --git a/src/panels/lovelace/heading-badges/hui-entity-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-entity-heading-badge.ts index 2ee2f2ee8b..048f2651e3 100644 --- a/src/panels/lovelace/heading-badges/hui-entity-heading-badge.ts +++ b/src/panels/lovelace/heading-badges/hui-entity-heading-badge.ts @@ -16,6 +16,7 @@ import "../../../state-display/state-display"; import type { HomeAssistant } from "../../../types"; import { actionHandler } from "../common/directives/action-handler-directive"; import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name"; +import { findEntities } from "../common/find-entities"; 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"; @@ -43,6 +44,24 @@ export class HuiEntityHeadingBadge return document.createElement("hui-heading-entity-editor"); } + public static getStubConfig(hass: HomeAssistant): EntityHeadingBadgeConfig { + const includeDomains = ["sensor", "light", "switch"]; + const maxEntities = 1; + const entities = Object.keys(hass.states); + const foundEntities = findEntities( + hass, + maxEntities, + entities, + [], + includeDomains + ); + + return { + type: "entity", + entity: foundEntities[0] || "", + }; + } + @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config?: EntityHeadingBadgeConfig; diff --git a/src/panels/lovelace/heading-badges/types.ts b/src/panels/lovelace/heading-badges/types.ts index 77fc4949ac..6303e9b3d9 100644 --- a/src/panels/lovelace/heading-badges/types.ts +++ b/src/panels/lovelace/heading-badges/types.ts @@ -26,3 +26,13 @@ export interface EntityHeadingBadgeConfig extends LovelaceHeadingBadgeConfig { hold_action?: ActionConfig; double_tap_action?: ActionConfig; } + +export interface ButtonHeadingBadgeConfig extends LovelaceHeadingBadgeConfig { + type: "button"; + text?: string; + icon?: string; + color?: string; + tap_action?: ActionConfig; + hold_action?: ActionConfig; + double_tap_action?: ActionConfig; +} diff --git a/src/translations/en.json b/src/translations/en.json index 7fd27e0981..07e3d3c9b5 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8480,7 +8480,7 @@ "title": "Title", "subtitle": "Subtitle" }, - "entities": "Entities", + "badges": "Badges", "entity_config": { "color": "[%key:ui::panel::lovelace::editor::card::tile::color%]", "color_helper": "[%key:ui::panel::lovelace::editor::card::tile::color_helper%]", @@ -8495,6 +8495,12 @@ "state": "[%key:ui::panel::lovelace::editor::badge::entity::displayed_elements_options::state%]" } }, + "button_config": { + "text": "Text", + "color": "Color", + "visibility": "Visibility", + "visibility_explanation": "The button will be shown when ALL conditions below are fulfilled. If no conditions are set, the button will always be shown." + }, "default_heading": "Kitchen" }, "map": { @@ -8772,6 +8778,11 @@ "remove": "Remove entity", "form-label": "Edit entity" }, + "badges": { + "name": "Badges", + "edit": "Edit badge", + "remove": "Remove badge" + }, "features": { "name": "Features", "not_compatible": "Not compatible", @@ -9026,6 +9037,19 @@ } } }, + "heading-badges": { + "add": "Add badge", + "no_entity": "No entity selected", + "entity_not_found": "Entity not found", + "types": { + "entity": { + "label": "Entity" + }, + "button": { + "label": "Button" + } + } + }, "strategy": { "original-states": { "areas": "Areas to display",