Allow different types of heading badges (#22109)

* Allow different type of heading item

* Update editor

* Migrate entities to items

* Rename support for string entity

* Refactor

* Rename to badges and add error state

* Update font weight

* Feedback

* Feedback
This commit is contained in:
Paul Bottein 2024-09-27 12:33:15 +02:00 committed by GitHub
parent 468660d235
commit a92dab46c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 824 additions and 394 deletions

View File

@ -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`
<div
class="heading-badge"
role=${ifDefined(this.type === "button" ? "button" : undefined)}
tabindex=${ifDefined(this.type === "button" ? "0" : undefined)}
>
<slot name="icon"></slot>
<slot></slot>
</div>
`;
}
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;
}
}

View File

@ -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<typeof this>): 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`
<div
class="entity"
@action=${this._handleAction}
.actionHandler=${actionHandler()}
role=${ifDefined(actionable ? "button" : undefined)}
tabindex=${ifDefined(actionable ? "0" : undefined)}
style=${styleMap(style)}
>
${config.show_icon
? html`
<ha-state-icon
.hass=${this.hass}
.icon=${config.icon}
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing}
${config.show_state
? html`
<state-display
.hass=${this.hass}
.stateObj=${stateObj}
.content=${config.state_content}
></state-display>
`
: nothing}
</div>
`;
}
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;
}
}

View File

@ -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<LovelaceCardEditor> {
@ -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`
<ha-card>
<div class="container">
@ -91,17 +104,17 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
: nothing}
${actionable ? html`<ha-icon-next></ha-icon-next>` : nothing}
</div>
${this._config.entities?.length
${badges?.length
? html`
<div class="entities">
${this._config.entities.map(
<div class="badges">
${badges.map(
(config) => html`
<hui-heading-entity
<hui-heading-badge
.config=${config}
.hass=${this.hass}
.preview=${this.preview}
>
</hui-heading-entity>
</hui-heading-badge>
`
)}
</div>
@ -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;

View File

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

View File

@ -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 = <T extends keyof CreateElementConfigTypes>(
tag: string,
config: CreateElementConfigTypes[T]["config"]
@ -134,6 +162,11 @@ const _createErrorElement = <T extends keyof CreateElementConfigTypes>(
if (tagSuffix === "badge") {
return createErrorBadgeElement(createErrorBadgeConfig(error, config));
}
if (tagSuffix === "heading-badge") {
return createErrorHeadingBadgeElement(
createErrorHeadingBadgeConfig(error, config)
);
}
return createErrorCardElement(createErrorCardConfig(error, config));
};

View File

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

View File

@ -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<EntityConfig, string>();
private _badgesKeys = new WeakMap<LovelaceHeadingBadgeConfig, string>();
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`
<ha-sortable
handle-selector=".handle"
@item-moved=${this._entityMoved}
@item-moved=${this._badgeMoved}
>
<div class="entities">
${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`
<div class="entity">
<div class="badge">
<div class="handle">
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div>
<div class="entity-content">
<span>${name || entityId}</span>
<div class="badge-content">
<span>${label}</span>
</div>
${editable
? html`
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.edit`
)}
.path=${mdiPencil}
class="edit-icon"
.index=${index}
@click=${this._editEntity}
.disabled=${!editable}
></ha-icon-button>
`
: nothing}
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.edit`
)}
.path=${mdiPencil}
class="edit-icon"
.index=${index}
@click=${this._editBadge}
></ha-icon-button>
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.remove`
@ -186,34 +189,37 @@ export class HuiEntitiesEditor extends LitElement {
if (!ev.detail.value) {
return;
}
const newEntity: EntityConfig = { entity: ev.detail.value };
const newEntities = (this.entities || []).concat(newEntity);
fireEvent(this, "entities-changed", { entities: newEntities });
const newEntity: LovelaceHeadingBadgeConfig = {
type: "entity",
entity: ev.detail.value,
};
const newBadges = (this.badges || []).concat(newEntity);
fireEvent(this, "heading-badges-changed", { badges: newBadges });
}
private _entityMoved(ev: CustomEvent): void {
private _badgeMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newEntities = (this.entities || []).concat();
const newBadges = (this.badges || []).concat();
newEntities.splice(newIndex, 0, newEntities.splice(oldIndex, 1)[0]);
newBadges.splice(newIndex, 0, newBadges.splice(oldIndex, 1)[0]);
fireEvent(this, "entities-changed", { entities: newEntities });
fireEvent(this, "heading-badges-changed", { badges: newBadges });
}
private _removeEntity(ev: CustomEvent): void {
const index = (ev.currentTarget as any).index;
const newEntities = (this.entities || []).concat();
const newBadges = (this.badges || []).concat();
newEntities.splice(index, 1);
newBadges.splice(index, 1);
fireEvent(this, "entities-changed", { entities: newEntities });
fireEvent(this, "heading-badges-changed", { badges: newBadges });
}
private _editEntity(ev: CustomEvent): void {
private _editBadge(ev: CustomEvent): void {
const index = (ev.currentTarget as any).index;
fireEvent(this, "edit-entity", {
fireEvent(this, "edit-heading-badge", {
index,
});
}
@ -227,11 +233,11 @@ export class HuiEntitiesEditor extends LitElement {
ha-button {
margin-top: 8px;
}
.entity {
.badge {
display: flex;
align-items: center;
}
.entity .handle {
.badge .handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
padding-right: 8px;
@ -239,11 +245,11 @@ export class HuiEntitiesEditor extends LitElement {
padding-inline-start: initial;
direction: var(--direction);
}
.entity .handle > * {
.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;
}
}

View File

@ -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
)}
</h3>
<div class="content">
<hui-entities-editor
<hui-heading-badges-editor
.hass=${this.hass}
.entities=${this._entities(this._config!.entities)}
@entities-changed=${this._entitiesChanged}
@edit-entity=${this._editEntity}
.badges=${this._badges(this._config!.badges)}
@heading-badges-changed=${this._badgesChanged}
@edit-heading-badge=${this._editBadge}
>
</hui-entities-editor>
</hui-heading-badges-editor>
</div>
</ha-expansion-panel>
`;
}
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<HeadingEntityConfig>);
saveConfig: (newConfig) => this._updateBadge(index, newConfig),
type: "heading-badge",
} as EditSubElementEvent<EntityHeadingBadgeConfig>);
}
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,
});

View File

@ -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<HeadingEntityConfig> = {
export const DEFAULT_CONFIG: Partial<EntityHeadingBadgeConfig> = {
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,
};

View File

@ -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<EntityHeadingBadgeConfig> {
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<LovelaceConfigForm | undefined> {
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;
}
}

View File

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

View File

@ -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}
></hui-card-feature-element-editor>
`;
case "heading-entity":
case "heading-badge":
return html`
<hui-heading-entity-element-editor
<hui-heading-badge-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.config.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-heading-entity-element-editor>
></hui-heading-badge-element-editor>
`;
default:
return nothing;

View File

@ -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<T = any, C = any> {

View File

@ -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<LovelaceHeadingBadgeEditor> {
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`
<ha-heading-badge class="error" .title=${entityId}>
<ha-svg-icon
slot="icon"
.hass=${this.hass}
.path=${mdiAlertCircle}
></ha-svg-icon>
-
</ha-heading-badge>
`;
}
const color = this._computeStateColor(stateObj, config.color);
const style = {
"--icon-color": color,
};
return html`
<ha-heading-badge
.type=${hasAction(config.tap_action) ? "button" : "text"}
@action=${this._handleAction}
.actionHandler=${actionHandler()}
style=${styleMap(style)}
>
${config.show_icon
? html`
<ha-state-icon
slot="icon"
.hass=${this.hass}
.icon=${config.icon}
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing}
${config.show_state
? html`
<state-display
.hass=${this.hass}
.stateObj=${stateObj}
.content=${config.state_content}
></state-display>
`
: nothing}
</ha-heading-badge>
`;
}
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;
}
}

View File

@ -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`<pre>${dumped}</pre>` : "",
});
}
protected render() {
if (!this._config) {
return nothing;
}
return html`
<ha-heading-badge
class="error"
@click=${this._viewDetail}
type="button"
.title=${this._config.error}
>
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
<span class="content">${this._config.error}</span>
</ha-heading-badge>
`;
}
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;
}
}

View File

@ -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<typeof this>): void {
super.willUpdate(changedProps);
if (!this._element) {
this.load();
}
}
protected update(changedProps: PropertyValues<typeof this>) {
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;
}
}

View File

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

View File

@ -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<LovelaceHeadingBadge> {
getStubConfig?: (
hass: HomeAssistant,
stateObj?: HassEntity
) => LovelaceHeadingBadgeConfig;
getConfigElement?: () => LovelaceHeadingBadgeEditor;
getConfigForm?: () => {
schema: HaFormSchema[];
assertConfig?: (config: LovelaceCardConfig) => void;
};
}
export interface LovelaceHeadingBadgeEditor
extends LovelaceGenericElementEditor {
setConfig(config: LovelaceHeadingBadgeConfig): void;
}

View File

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