From dec8883f2a2a9a0e477d9a0fd62a63a883bc86eb Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 17 Oct 2022 11:43:49 +0200 Subject: [PATCH] Introduce tile card (#14085) --- src/common/color/compute-color.ts | 44 ++++ src/components/tile/ha-tile-icon.ts | 54 +++++ src/components/tile/ha-tile-info.ts | 59 +++++ src/panels/lovelace/cards/hui-tile-card.ts | 210 ++++++++++++++++++ src/panels/lovelace/cards/types.ts | 9 + .../create-element/create-card-element.ts | 2 + .../config-elements/hui-tile-card-editor.ts | 153 +++++++++++++ src/panels/lovelace/editor/lovelace-cards.ts | 4 + src/resources/ha-style.ts | 1 + src/resources/styles.ts | 1 + src/translations/en.json | 9 + 11 files changed, 546 insertions(+) create mode 100644 src/common/color/compute-color.ts create mode 100644 src/components/tile/ha-tile-icon.ts create mode 100644 src/components/tile/ha-tile-info.ts create mode 100644 src/panels/lovelace/cards/hui-tile-card.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts diff --git a/src/common/color/compute-color.ts b/src/common/color/compute-color.ts new file mode 100644 index 0000000000..0a97b7aad4 --- /dev/null +++ b/src/common/color/compute-color.ts @@ -0,0 +1,44 @@ +import { hex2rgb } from "./convert-color"; + +export const THEME_COLORS = new Set(["primary", "accent", "disabled"]); + +export const COLORS = new Map([ + ["red", "#f44336"], + ["pink", "#e91e63"], + ["purple", "#9b27b0"], + ["deep-purple", "#683ab7"], + ["indigo", "#3f51b5"], + ["blue", "#2194f3"], + ["light-blue", "#2196f3"], + ["cyan", "#03a8f4"], + ["teal", "#009688"], + ["green", "#4caf50"], + ["light-green", "#8bc34a"], + ["lime", "#ccdc39"], + ["yellow", "#ffeb3b"], + ["amber", "#ffc107"], + ["orange", "#ff9800"], + ["deep-orange", "#ff5722"], + ["brown", "#795548"], + ["grey", "#9e9e9e"], + ["blue-grey", "#607d8b"], + ["black", "#000000"], + ["white", "ffffff"], +]); + +export function computeRgbColor(color: string): string { + if (THEME_COLORS.has(color)) { + return `var(--rgb-${color}-color)`; + } + if (COLORS.has(color)) { + return hex2rgb(COLORS.get(color)!).join(", "); + } + if (color.startsWith("#")) { + try { + return hex2rgb(color).join(", "); + } catch (err) { + return ""; + } + } + return color; +} diff --git a/src/components/tile/ha-tile-icon.ts b/src/components/tile/ha-tile-icon.ts new file mode 100644 index 0000000000..e730035e93 --- /dev/null +++ b/src/components/tile/ha-tile-icon.ts @@ -0,0 +1,54 @@ +import { CSSResultGroup, html, css, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../ha-icon"; +import "../ha-svg-icon"; + +@customElement("ha-tile-icon") +export class HaTileIcon extends LitElement { + @property() public iconPath?: string; + + @property() public icon?: string; + + protected render(): TemplateResult { + return html` +
+ ${this.icon + ? html`` + : html``} +
+ `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + --icon-color: rgb(var(--color)); + --shape-color: rgba(var(--color), 0.2); + --mdc-icon-size: 24px; + } + .shape { + position: relative; + width: 40px; + height: 40px; + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--shape-color); + transition: background-color 180ms ease-in-out, color 180ms ease-in-out; + } + .shape ha-icon, + .shape ha-svg-icon { + display: flex; + color: var(--icon-color); + transition: color 180ms ease-in-out; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-tile-icon": HaTileIcon; + } +} diff --git a/src/components/tile/ha-tile-info.ts b/src/components/tile/ha-tile-info.ts new file mode 100644 index 0000000000..f211b5490d --- /dev/null +++ b/src/components/tile/ha-tile-info.ts @@ -0,0 +1,59 @@ +import { CSSResultGroup, html, css, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../ha-icon"; +import "../ha-svg-icon"; + +@customElement("ha-tile-info") +export class HaTileInfo extends LitElement { + @property() public primary?: string; + + @property() public secondary?: string; + + protected render(): TemplateResult { + return html` +
+ ${this.primary} + ${this.secondary + ? html`${this.secondary}` + : null} +
+ `; + } + + static get styles(): CSSResultGroup { + return css` + .info { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + } + span { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + } + .primary { + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.1px; + color: var(--primary-text-color); + } + .secondary { + font-weight: 400; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.4px; + color: var(--secondary-text-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-tile-info": HaTileInfo; + } +} diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts new file mode 100644 index 0000000000..e9893555b7 --- /dev/null +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -0,0 +1,210 @@ +import { mdiHelp } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { computeRgbColor } from "../../../common/color/compute-color"; +import { DOMAINS_TOGGLE, STATES_OFF } from "../../../common/const"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { computeStateDisplay } from "../../../common/entity/compute_state_display"; +import { stateIconPath } from "../../../common/entity/state_icon_path"; +import "../../../components/ha-card"; +import "../../../components/tile/ha-tile-icon"; +import "../../../components/tile/ha-tile-info"; +import { ActionHandlerEvent } from "../../../data/lovelace"; +import { HomeAssistant } from "../../../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { findEntities } from "../common/find-entities"; +import { handleAction } from "../common/handle-action"; +import { LovelaceCard, LovelaceCardEditor } from "../types"; +import { ThermostatCardConfig, TileCardConfig } from "./types"; + +@customElement("hui-tile-card") +export class HuiTileCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-tile-card-editor"); + return document.createElement("hui-tile-card-editor"); + } + + public static getStubConfig( + hass: HomeAssistant, + entities: string[], + entitiesFallback: string[] + ): TileCardConfig { + const includeDomains = ["sensor", "light", "switch"]; + const maxEntities = 1; + const foundEntities = findEntities( + hass, + maxEntities, + entities, + entitiesFallback, + includeDomains + ); + + return { + type: "tile", + entity: foundEntities[0] || "", + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: TileCardConfig; + + public setConfig(config: ThermostatCardConfig): void { + if (!config.entity) { + throw new Error("Specify an entity"); + } + + const supportToggle = + config.entity && DOMAINS_TOGGLE.has(computeDomain(config.entity)); + + this._config = { + tap_action: { + action: "more-info", + }, + icon_tap_action: { + action: supportToggle ? "toggle" : "more-info", + }, + ...config, + }; + } + + public getCardSize(): number { + return 1; + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + private _handleIconAction() { + const config = { + entity: this._config!.entity, + tap_action: this._config!.icon_tap_action, + }; + handleAction(this, this.hass!, config, "tap"); + } + + render() { + if (!this._config || !this.hass) { + return html``; + } + const entityId = this._config.entity; + const entity = entityId ? this.hass.states[entityId] : undefined; + + if (!entity) { + return html` + +
+ + +
+
+ `; + } + + const icon = this._config.icon || entity.attributes.icon; + const iconPath = stateIconPath(entity); + + const name = this._config.name || entity.attributes.friendly_name; + const stateDisplay = computeStateDisplay( + this.hass.localize, + entity, + this.hass.locale + ); + + const iconStyle = {}; + if (this._config.color && !STATES_OFF.includes(entity.state)) { + iconStyle["--main-color"] = computeRgbColor(this._config.color); + } + + return html` + +
+ + +
+
+ `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + --main-color: var(--rgb-disabled-color); + --tap-padding: 6px; + } + ha-card { + height: 100%; + } + ha-card.disabled { + background: rgba(var(--rgb-disabled-color), 0.1); + } + .tile { + padding: calc(12px - var(--tap-padding)); + display: flex; + flex-direction: row; + align-items: center; + } + ha-tile-icon { + padding: var(--tap-padding); + flex: none; + margin-right: calc(12px - 2 * var(--tap-padding)); + margin-inline-end: calc(12px - 2 * var(--tap-padding)); + margin-inline-start: initial; + direction: var(--direction); + --color: var(--main-color); + transition: transform 180ms ease-in-out; + } + [role="button"] { + cursor: pointer; + } + ha-tile-icon[role="button"]:focus { + outline: none; + } + ha-tile-icon[role="button"]:focus-visible { + transform: scale(1.2); + } + ha-tile-icon[role="button"]:active { + transform: scale(1.2); + } + ha-tile-info { + padding: var(--tap-padding); + flex: 1; + min-width: 0; + min-height: 40px; + border-radius: calc(var(--ha-card-border-radius, 12px) - 2px); + transition: background-color 180ms ease-in-out; + } + ha-tile-info:focus { + outline: none; + } + ha-tile-info:focus-visible { + background-color: rgba(var(--main-color), 0.1); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-tile-card": HuiTileCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 7113ad1d13..ee3a6d68a5 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -472,3 +472,12 @@ export interface EnergyFlowCardConfig extends LovelaceCardConfig { hold_action?: ActionConfig; double_tap_action?: ActionConfig; } + +export interface TileCardConfig extends LovelaceCardConfig { + entity: string; + name?: string; + icon?: string; + color?: string; + tap_action?: ActionConfig; + icon_tap_action?: ActionConfig; +} diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 03f22a9bdd..aaa8de1829 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -10,6 +10,7 @@ import "../cards/hui-light-card"; import "../cards/hui-sensor-card"; import "../cards/hui-thermostat-card"; import "../cards/hui-weather-forecast-card"; +import "../cards/hui-tile-card"; import { createLovelaceElement, getLovelaceElementClass, @@ -27,6 +28,7 @@ const ALWAYS_LOADED_TYPES = new Set([ "sensor", "thermostat", "weather-forecast", + "tile", ]); const LAZY_LOAD_TYPES = { 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 new file mode 100644 index 0000000000..448074f743 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -0,0 +1,153 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { assert, assign, object, optional, string } from "superstruct"; +import { COLORS, THEME_COLORS } from "../../../../common/color/compute-color"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { domainIcon } from "../../../../common/entity/domain_icon"; +import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; +import "../../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import type { TileCardConfig } from "../../cards/types"; +import type { LovelaceCardEditor } from "../../types"; +import { actionConfigStruct } from "../structs/action-struct"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + entity: optional(string()), + name: optional(string()), + icon: optional(string()), + color: optional(string()), + tap_action: optional(actionConfigStruct), + icon_tap_action: optional(actionConfigStruct), + }) +); + +@customElement("hui-tile-card-editor") +export class HuiTileCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: TileCardConfig; + + public setConfig(config: TileCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + } + + private _schema = memoizeOne( + (entity: string, icon?: string, entityState?: HassEntity) => + [ + { name: "entity", selector: { entity: {} } }, + { name: "name", selector: { text: {} } }, + { + name: "icon", + selector: { + icon: { + placeholder: icon || entityState?.attributes.icon, + fallbackPath: + !icon && !entityState?.attributes.icon && entityState + ? domainIcon(computeDomain(entity), entityState) + : undefined, + }, + }, + }, + { + name: "color", + selector: { + select: { + options: [ + { + label: "Default", + value: "default", + }, + ...[ + ...Array.from(THEME_COLORS), + ...Array.from(COLORS.keys()), + ].map((color) => ({ + label: capitalizeFirstLetter(color), + value: color, + })), + ], + }, + }, + }, + { + name: "tap_action", + selector: { + "ui-action": {}, + }, + }, + { + name: "icon_tap_action", + selector: { + "ui-action": {}, + }, + }, + ] as const + ); + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const entity = this.hass.states[this._config.entity ?? ""]; + + const schema = this._schema(this._config.entity, this._config.icon, entity); + + const data = { + color: "default", + ...this._config, + }; + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + const config = { + ...ev.detail.value, + }; + if (ev.detail.value.color === "default") { + config.color = undefined; + } + fireEvent(this, "config-changed", { config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "color": + case "icon_tap_action": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.tile.${schema.name}` + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-tile-card-editor": HuiTileCardEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index df23758f13..3f3addb715 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -93,6 +93,10 @@ export const coreCards: Card[] = [ type: "area", showElement: true, }, + { + type: "tile", + showElement: true, + }, { type: "conditional", }, diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index dffa7a2663..5a903b26bd 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -111,6 +111,7 @@ documentContainer.innerHTML = ` --rgb-secondary-text-color: 114, 114, 114; --rgb-text-primary-color: 255, 255, 255; --rgb-card-background-color: 255, 255, 255; + --rgb-disabled-color: 189, 189, 189; /* input components */ --input-idle-line-color: rgba(0, 0, 0, 0.42); diff --git a/src/resources/styles.ts b/src/resources/styles.ts index 96af5c42dc..a943e154b2 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -48,6 +48,7 @@ export const darkStyles = { "energy-grid-return-color": "#a280db", "map-filter": "invert(.9) hue-rotate(170deg) brightness(1.5) contrast(1.2) saturate(.3)", + "rgb-disabled-color": "111, 111, 111", }; export const derivedStyles = { diff --git a/src/translations/en.json b/src/translations/en.json index 38f543adc8..4803743a96 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -243,6 +243,9 @@ "finish": "finish" } }, + "tile": { + "not_found": "Entity not found" + }, "vacuum": { "actions": { "resume_cleaning": "Resume cleaning", @@ -4120,6 +4123,12 @@ "name": "Thermostat", "description": "The Thermostat card gives control of your climate entity. Allowing you to change the temperature and mode of the entity." }, + "tile": { + "name": "Tile", + "description": "The tile card gives you a quick overview of your entity. The card allow you to toggle the entity, show the more info dialog or custom actions.", + "color": "Color", + "icon_tap_action": "Icon tap action" + }, "vertical-stack": { "name": "Vertical Stack", "description": "The Vertical Stack card allows you to group multiple cards so they always sit in the same column."