mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-24 09:46:36 +00:00
Introduce tile card (#14085)
This commit is contained in:
parent
a475b06d49
commit
dec8883f2a
44
src/common/color/compute-color.ts
Normal file
44
src/common/color/compute-color.ts
Normal 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;
|
||||
}
|
54
src/components/tile/ha-tile-icon.ts
Normal file
54
src/components/tile/ha-tile-icon.ts
Normal 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;
|
||||
}
|
||||
}
|
59
src/components/tile/ha-tile-info.ts
Normal file
59
src/components/tile/ha-tile-info.ts
Normal 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;
|
||||
}
|
||||
}
|
210
src/panels/lovelace/cards/hui-tile-card.ts
Normal file
210
src/panels/lovelace/cards/hui-tile-card.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -93,6 +93,10 @@ export const coreCards: Card[] = [
|
||||
type: "area",
|
||||
showElement: true,
|
||||
},
|
||||
{
|
||||
type: "tile",
|
||||
showElement: true,
|
||||
},
|
||||
{
|
||||
type: "conditional",
|
||||
},
|
||||
|
@ -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);
|
||||
|
@ -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 = {
|
||||
|
@ -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."
|
||||
|
Loading…
x
Reference in New Issue
Block a user