Introduce tile card (#14085)

This commit is contained in:
Paul Bottein 2022-10-17 11:43:49 +02:00 committed by GitHub
parent a475b06d49
commit dec8883f2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 546 additions and 0 deletions

View File

@ -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;
}

View File

@ -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`
<div class="shape">
${this.icon
? html`<ha-icon .icon=${this.icon}></ha-icon>`
: html`<ha-svg-icon .path=${this.iconPath}></ha-svg-icon>`}
</div>
`;
}
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;
}
}

View File

@ -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`
<div class="info">
<span class="primary">${this.primary}</span>
${this.secondary
? html`<span class="secondary">${this.secondary}</span>`
: null}
</div>
`;
}
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;
}
}

View File

@ -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<LovelaceCardEditor> {
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`
<ha-card class="disabled">
<div class="tile">
<ha-tile-icon .iconPath=${mdiHelp}></ha-tile-icon>
<ha-tile-info
.primary=${entityId}
secondary=${this.hass.localize("ui.card.tile.not_found")}
></ha-tile-info>
</div>
</ha-card>
`;
}
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`
<ha-card style=${styleMap(iconStyle)}>
<div class="tile">
<ha-tile-icon
.icon=${icon}
.iconPath=${iconPath}
role="button"
tabindex="0"
@action=${this._handleIconAction}
.actionHandler=${actionHandler()}
></ha-tile-icon>
<ha-tile-info
.primary=${name}
.secondary=${stateDisplay}
role="button"
tabindex="0"
@action=${this._handleAction}
.actionHandler=${actionHandler()}
></ha-tile-info>
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -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;
}

View File

@ -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 = {

View File

@ -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`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
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<ReturnType<typeof this._schema>>
) => {
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;
}
}

View File

@ -93,6 +93,10 @@ export const coreCards: Card[] = [
type: "area",
showElement: true,
},
{
type: "tile",
showElement: true,
},
{
type: "conditional",
},

View File

@ -111,6 +111,7 @@ documentContainer.innerHTML = `<custom-style>
--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);

View File

@ -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 = {

View File

@ -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."