Compare commits

..
28 changed files with 832 additions and 910 deletions

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ runs:
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: ".nvmrc"
+1 -1
View File
@@ -108,7 +108,7 @@
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.3",
"js-yaml": "5.5.0",
"js-yaml": "5.4.3",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
+6 -11
View File
@@ -386,8 +386,8 @@ importers:
specifier: 12.1.3
version: 12.1.3
js-yaml:
specifier: 5.5.0
version: 5.5.0
specifier: 5.4.3
version: 5.4.3
leaflet:
specifier: 1.9.4
version: 1.9.4
@@ -3916,9 +3916,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
[email protected]:
resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==}
[email protected]:
resolution: {integrity: sha512-dgojXfc4SiqmNwe38PnbT3zJasrz7g62dLAPD+VFT5RJb8W7LGRqw2IFd2ES+plnhsp4HYNJmFqMU1tCThdCww==}
engines: {node: '>=14.0.0'}
@@ -5632,8 +5629,8 @@ packages:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
js-yaml@5.5.0:
resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==}
js-yaml@5.4.3:
resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==}
hasBin: true
[email protected]:
@@ -11521,8 +11518,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -13370,9 +13365,9 @@ snapshots:
dependencies:
argparse: 2.0.1
js-yaml@5.5.0:
js-yaml@5.4.3:
dependencies:
argparse: 3.0.3
argparse: 2.0.1
[email protected]:
dependencies:
+1 -1
View File
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
"Unlicense",
@@ -1,82 +0,0 @@
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
const FONT_FAMILY = "Roboto, Noto, sans-serif";
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
const WORD_BREAK =
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
interface FitLabelOptions {
/** Font size the label is measured at, and the largest size returned */
fontSize: number;
width: number;
height: number;
/** Height of the label at `fontSize` before ECharts wraps it */
unwrappedHeight: number;
}
/**
* Largest font size at which an ECharts label with `overflow: "break"`,
* wrapped at `width`, fits in `height` without breaking a word.
*/
export function fitLabelFontSize(
text: string,
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
) {
const font = `${baseFontSize}px ${FONT_FAMILY}`;
const measure = (label: string, wrapWidth?: number) =>
parsePlainText(
label,
{ font, width: wrapWidth, overflow: "break" },
undefined,
undefined
);
// ECharts measures at the final font size, leave room for rounding
const fitWidth = Math.max(width - 1, 1);
const unwrappedContentHeight = measure(text).contentHeight;
const words = text.split(WORD_BREAK).filter(Boolean);
// a label at a smaller font wraps like one at baseFontSize in a wider box
const wrapWidthAt = (fontSize: number) =>
(fitWidth * baseFontSize) / fontSize;
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
const fits = (fontSize: number) => {
const wrapWidth = wrapWidthAt(fontSize);
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
return false;
}
const wrappedHeight =
unwrappedHeight +
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
// exact at maxFontSize when the label doesn't wrap
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
};
let low = 0;
let high = Math.min(baseFontSize, maxFontSize);
if (fits(high)) {
low = high;
} else {
for (let i = 0; i < 10; i++) {
const mid = (low + high) / 2;
if (fits(mid)) {
low = mid;
} else {
high = mid;
}
}
}
// zrender moves a space that doesn't fit to the next line, indenting it.
// Shrinking further only widens the wrap, so the label still fits.
const spaceWidth = measure(" ").contentWidth;
const indents = (fontSize: number) =>
text
.split("\n")
.some((paragraph) =>
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
(line, i) => i > 0 && line.startsWith(" ")
)
);
let fontSize = low;
for (let i = 0; i < 3 && indents(fontSize); i++) {
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
}
return fontSize;
}
+6 -8
View File
@@ -9,7 +9,6 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { fitLabelFontSize } from "./fit-label-font-size";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import "../ha-alert";
@@ -280,14 +279,13 @@ export class HaSankeyChart extends LitElement {
}
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE
);
return {
// labelRect is measured before the width is applied, so unwrapped
fontSize: fitLabelFontSize(params.text, {
fontSize: FONT_SIZE,
width: labelSpace,
height: availableHeight,
unwrappedHeight: params.labelRect.height,
}),
fontSize,
lineHeight: fontSize,
width: labelSpace,
height: params.rect.height,
};
+14 -47
View File
@@ -17,10 +17,7 @@ import { UNIT_KM } from "../../common/const";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import {
findFirstActiveZone,
getEntityLocation,
} from "../../common/entity/get_entity_location";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsVectorMaps } from "../../common/map/base-layer";
import type {
MapCircleHandle,
@@ -69,7 +66,6 @@ import {
statesContext,
uiContext,
} from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import "../ha-tooltip";
@@ -82,6 +78,7 @@ import type {
ThemeMode,
} from "../../types";
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
import { computeZoneTrackerState } from "../../data/zone";
declare global {
// for fire event
@@ -531,19 +528,8 @@ export class HaMap extends ReactiveElement {
} else if (this._loaded && oldStates && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
const entityId = getEntityId(entity);
const stateObj = this._states[entityId];
const inZones: string[] | undefined = stateObj?.attributes.in_zones;
if (
oldStates[entityId] !== stateObj ||
(stateObj &&
["person", "device_tracker"].includes(
computeStateDomain(stateObj)
) &&
Array.isArray(inZones) &&
inZones.some(
(zoneId) => oldStates[zoneId] !== this._states[zoneId]
))
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
) {
this._drawEntities();
autoFitRequired = !this._pauseAutoFit;
@@ -1361,6 +1347,8 @@ export class HaMap extends ReactiveElement {
this._watchRegistry();
const computedStyles = getComputedStyle(this);
// A person's state is "home" for the home zone, the zone name otherwise
const zoneByState: Record<string, string> = {};
this._zonePositions = {};
this._zoneRadii = {};
for (const entity of this.entities) {
@@ -1371,6 +1359,7 @@ export class HaMap extends ReactiveElement {
computeStateDomain(stateObj) === "zone" &&
(this.renderPassive || !stateObj.attributes.passive)
) {
zoneByState[computeZoneTrackerState(stateObj)] = stateObj.entity_id;
if (
typeof stateObj.attributes.latitude === "number" &&
typeof stateObj.attributes.longitude === "number"
@@ -1529,22 +1518,6 @@ export class HaMap extends ReactiveElement {
typeof entity !== "string" && (entity.selected ?? false);
entityMarker.floating = true;
let zoneId: string | undefined;
if (["person", "device_tracker"].includes(computeStateDomain(stateObj))) {
const inZones: string[] | undefined = stateObj.attributes.in_zones;
if (Array.isArray(inZones) && inZones.length > 0) {
const activeZone = findFirstActiveZone(inZones, states);
if (activeZone && this._zonePositions[activeZone.entity_id]) {
zoneId = activeZone.entity_id;
}
} else if (
!Array.isArray(inZones) ||
!["not_home", UNKNOWN, UNAVAILABLE].includes(stateObj.state)
) {
zoneId = this._zoneContaining(position, gpsAccuracy);
}
}
const clusterData: ClusterData = {
entityId,
title,
@@ -1554,7 +1527,11 @@ export class HaMap extends ReactiveElement {
unit: entityMarker.entityUnit ?? "",
color: entityColor,
selected: entityMarker.selected,
zoneId,
zoneId: ["person", "device_tracker"].includes(
computeStateDomain(stateObj)
)
? (zoneByState[stateObj.state] ?? this._zoneContaining(position))
: undefined,
};
const accuracy =
@@ -1614,26 +1591,16 @@ export class HaMap extends ReactiveElement {
);
}
private _zoneContaining(
position: MapLatLng,
gpsAccuracy = 0
): string | undefined {
private _zoneContaining(position: MapLatLng): string | undefined {
let found: string | undefined;
let foundRadius = Infinity;
let foundDistance = Infinity;
for (const [zoneId, radius] of Object.entries(this._zoneRadii)) {
if (this._states[zoneId]?.attributes.passive) {
continue;
}
const distance = distanceMeters(position, this._zonePositions[zoneId]);
if (
distance - gpsAccuracy < radius &&
(radius < foundRadius ||
(radius === foundRadius && distance < foundDistance))
radius < foundRadius &&
distanceMeters(position, this._zonePositions[zoneId]) <= radius
) {
found = zoneId;
foundRadius = radius;
foundDistance = distance;
}
}
return found;
+2 -14
View File
@@ -286,8 +286,6 @@ class HaPanelConfig extends HassRouterPage {
private _cloudStatusRequestId = 0;
private _cloudStatusTimeout?: number;
public connectedCallback() {
super.connectedCallback();
this._listeners.push(
@@ -325,7 +323,6 @@ class HaPanelConfig extends HassRouterPage {
}
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
this._clearCloudStatusTimeout();
entityRegistryByEntityId.clear();
entityRegistryById.clear();
}
@@ -389,11 +386,10 @@ class HaPanelConfig extends HassRouterPage {
}
private async _updateCloudStatus() {
this._clearCloudStatusTimeout();
const requestId = ++this._cloudStatusRequestId;
const status = await fetchCloudStatus(this.hass);
if (requestId !== this._cloudStatusRequestId || !this.isConnected) {
if (requestId !== this._cloudStatusRequestId) {
return;
}
this._cloudStatus = status;
@@ -406,17 +402,9 @@ class HaPanelConfig extends HassRouterPage {
this._cloudStatus.prefs.remote_enabled &&
!this._cloudStatus.remote_connected)
) {
this._cloudStatusTimeout = window.setTimeout(
() => this._updateCloudStatus(),
5000
);
setTimeout(() => this._updateCloudStatus(), 5000);
}
}
private _clearCloudStatusTimeout() {
clearTimeout(this._cloudStatusTimeout);
this._cloudStatusTimeout = undefined;
}
}
declare global {
@@ -1080,8 +1080,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
minmax(min(300px, 100%), 1fr)
);
gap: var(--ha-space-4);
padding: var(--ha-space-6) var(--ha-space-4) 0;
margin: 0;
padding: var(--ha-space-4);
margin: var(--ha-space-2);
}
.empty-message {
margin: auto;
@@ -1166,7 +1166,10 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
position: relative;
}
h1 {
margin: var(--ha-space-6) var(--ha-space-4) 0;
margin-top: 8px;
margin-left: 16px;
margin-inline-start: 16px;
margin-inline-end: initial;
}
ha-dropdown ha-icon-button {
color: var(--primary-text-color);
+43 -10
View File
@@ -26,11 +26,20 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
import { formattersContext } from "../../../data/context";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { hasTileCardAction } from "../cards/tile/tile-card-helpers";
import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
import { hasAction } from "../common/has-action";
import type { LovelaceBadge, LovelaceBadgeEditor } from "../types";
import type { AlertBadgeConfig } from "./types";
export const ALERT_BADGE_DEFAULT_CONFIG: Partial<AlertBadgeConfig> = {
type: "alert",
show_name: true,
show_state: true,
show_icon: true,
};
@customElement("hui-alert-badge")
export class HuiAlertBadge extends LitElement implements LovelaceBadge {
public static async getConfigElement(): Promise<LovelaceBadgeEditor> {
@@ -55,6 +64,10 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
return { type: "alert", entity: entity || "" };
}
public static getDefaultConfig(): Partial<AlertBadgeConfig> {
return ALERT_BADGE_DEFAULT_CONFIG;
}
@state() private _config?: AlertBadgeConfig;
@state()
@@ -87,7 +100,7 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
);
}
this._config = config;
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
}
protected render() {
@@ -115,10 +128,21 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const name = this._formatters.formatEntityName(stateObj, this._config.name);
const { show_name: showName, show_state: showState } = this._config;
const content = showState
? this._formatters.formatEntityState(stateObj)
: showName
? name
: undefined;
return html`
<ha-badge
type="button"
.label=${this._formatters.formatEntityName(stateObj)}
.type=${hasTileCardAction(this._config) ? "button" : "badge"}
.label=${showState && showName ? name : undefined}
.iconOnly=${!content}
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color":
@@ -126,20 +150,29 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: false,
hasDoubleClick: false,
hasHold: hasAction(this._config.hold_action),
hasDoubleClick: hasAction(this._config.double_tap_action),
})}
>
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
${this._formatters.formatEntityState(stateObj)}
${
this._config.show_icon
? html`<ha-state-icon
slot="icon"
.stateObj=${stateObj}
.icon=${this._config.icon}
></ha-state-icon>`
: nothing
}
${content}
</ha-badge>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
if (ev.detail.action === "tap" && this._config) {
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
}
fireEvent(this, "hass-action", {
config: this._config!,
action: ev.detail.action!,
});
}
static styles = css`
+6 -24
View File
@@ -8,15 +8,17 @@ 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 { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-badge";
import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import {
getTileImageUrl,
hasTileCardAction,
} from "../cards/tile/tile-card-helpers";
import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
@@ -99,12 +101,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
}
get hasAction() {
return (
!this._config?.tap_action ||
hasAction(this._config?.tap_action) ||
hasAction(this._config?.hold_action) ||
hasAction(this._config?.double_tap_action)
);
return hasTileCardAction(this._config);
}
private _computeStateColor = memoizeOne(
@@ -138,21 +135,6 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
}
);
private _getImageUrl(stateObj: HassEntity): string | undefined {
const entityPicture =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
if (computeStateDomain(stateObj) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
}
return imageUrl;
}
protected render() {
if (!this._config || !this.hass) {
return nothing;
@@ -195,7 +177,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
const showEntityPicture = this._config.show_entity_picture;
const imageUrl = showEntityPicture
? this._getImageUrl(stateObj)
? getTileImageUrl(this.hass, stateObj, 32)
: undefined;
const label = showState && showName ? name : undefined;
+8
View File
@@ -34,9 +34,17 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig {
export interface AlertBadgeConfig extends LovelaceBadgeConfig {
type: "alert";
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
icon?: string;
alert_color?: string;
clear_color?: string;
pulse?: boolean;
show_name?: boolean;
show_state?: boolean;
show_icon?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}
export interface ShortcutBadgeConfig extends LovelaceBadgeConfig {
+48 -25
View File
@@ -25,11 +25,13 @@ import { formattersContext } from "../../../data/context";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import { hasAction } from "../common/has-action";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
} from "../types";
import { hasTileCardAction } from "./tile/tile-card-helpers";
import { tileCardStyle } from "./tile/tile-card-style";
import type { AlertCardConfig } from "./types";
@@ -84,19 +86,25 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
);
}
this._config = config;
this._config = {
tap_action: { action: "more-info" },
...config,
};
}
public getCardSize(): number {
return 1;
return this._config?.vertical ? 2 : 1;
}
public getGridOptions(): LovelaceGridOptions {
const vertical = Boolean(this._config?.vertical);
return {
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
rows: vertical ? 2 : 1,
min_columns: vertical ? 3 : 6,
min_rows: vertical ? 2 : 1,
};
}
@@ -110,11 +118,6 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
return nothing;
}
const stateDisplay = this._formatters.formatEntityState(stateObj);
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
const active = isAlertActive(stateObj);
const color = active
@@ -124,6 +127,10 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
@@ -136,25 +143,40 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
})}
>
<ha-tile-container
.interactive=${true}
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
.vertical=${Boolean(this._config.vertical)}
.interactive=${hasTileCardAction(this._config)}
.actionHandlerOptions=${{
hasHold: hasAction(this._config.hold_action),
hasDoubleClick: hasAction(this._config.double_tap_action),
}}
@action=${this._handleAction}
>
<ha-tile-icon slot="icon">
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
<ha-state-icon
slot="icon"
.icon=${this._config.icon}
.stateObj=${stateObj}
></ha-state-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary"
>${this._formatters.formatEntityName(stateObj)}</span
>${this._formatters.formatEntityName(
stateObj,
this._config.name
)}</span
>
<span slot="secondary">
${stateDisplay} ·
<ha-relative-time
.datetime=${stateObj.last_changed}
.format=${"short"}
></ha-relative-time>
${areaName ? html` · ${areaName}` : nothing}
</span>
${
this._config.hide_state
? nothing
: html`<span slot="secondary">
${this._formatters.formatEntityState(stateObj)} ·
<ha-relative-time
.datetime=${stateObj.last_changed}
.format=${"short"}
></ha-relative-time>
${areaName ? html` · ${areaName}` : nothing}
</span>`
}
</ha-tile-info>
</ha-tile-container>
</ha-card>
@@ -162,9 +184,10 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
}
private _handleAction(ev: ActionHandlerEvent): void {
if (ev.detail.action === "tap" && this._config) {
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
}
fireEvent(this, "hass-action", {
config: this._config!,
action: ev.detail.action!,
});
}
static styles = [
@@ -140,8 +140,8 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
: undefined;
return html`
<ha-card role="group" aria-labelledby="title">
<p class="title" id="title">${name}</p>
<ha-card>
<p class="title">${name}</p>
<div class="container">
<ha-state-control-humidifier-humidity
style=${styleMap({
@@ -139,8 +139,8 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
: undefined;
return html`
<ha-card role="group" aria-labelledby="title">
<p class="title" id="title">${name}</p>
<ha-card>
<p class="title">${name}</p>
<div class="container">
${
domain === "water_heater"
+17 -60
View File
@@ -8,7 +8,6 @@ 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 { DOMAINS_TOGGLE } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color";
@@ -18,7 +17,6 @@ import "../../../components/tile/ha-tile-badge";
import "../../../components/tile/ha-tile-container";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import { timerJustFinished } from "../../../data/timer";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
@@ -39,18 +37,16 @@ import type {
LovelaceGridOptions,
} from "../types";
import { renderTileBadge } from "./tile/badges/tile-badge";
import {
getTileActionDefaults,
getTileIconActionConfig,
getTileImageUrl,
hasTileCardAction,
hasTileIconAction,
} from "./tile/tile-card-helpers";
import { tileCardStyle } from "./tile/tile-card-style";
import type { TileCardConfig } from "./types";
export const getEntityDefaultTileIconAction = (entityId: string) => {
const domain = computeDomain(entityId);
const supportsIconAction =
DOMAINS_TOGGLE.has(domain) ||
["button", "input_button", "scene"].includes(domain);
return supportsIconAction ? "toggle" : "none";
};
@customElement("hui-tile-card")
export class HuiTileCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -124,12 +120,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
this._config = {
tap_action: {
action: "more-info",
},
icon_tap_action: {
action: getEntityDefaultTileIconAction(config.entity),
},
...getTileActionDefaults(config.entity),
...config,
};
this._featureContext = {
@@ -184,28 +175,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return;
}
const config = {
entity: this._config!.entity,
tap_action: this._config!.icon_tap_action,
hold_action: this._config!.icon_hold_action,
double_tap_action: this._config!.icon_double_tap_action,
};
handleAction(this, this.hass!, config, ev.detail.action!);
}
private _getImageUrl(entity: HassEntity): string | undefined {
const entityPicture =
entity.attributes.entity_picture_local ||
entity.attributes.entity_picture;
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
if (computeDomain(entity.entity_id) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
return imageUrl;
handleAction(
this,
this.hass!,
getTileIconActionConfig(this._config!.entity, this._config!),
ev.detail.action!
);
}
private _computeStateColor = memoizeOne(
@@ -247,24 +222,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
);
private get _hasCardAction() {
return (
!this._config?.tap_action ||
hasAction(this._config?.tap_action) ||
hasAction(this._config?.hold_action) ||
hasAction(this._config?.double_tap_action)
);
}
private get _hasIconAction() {
return (
!this._config?.icon_tap_action ||
hasAction(this._config?.icon_tap_action) ||
hasAction(this._config?.icon_hold_action) ||
hasAction(this._config?.icon_double_tap_action)
);
}
private _featurePosition = memoizeOne((config: TileCardConfig) => {
if (config.vertical) {
return "bottom";
@@ -317,7 +274,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
};
const imageUrl = this._config.show_entity_picture
? this._getImageUrl(stateObj)
? getTileImageUrl(this.hass, stateObj)
: undefined;
const featurePosition = this._featurePosition(this._config);
@@ -334,7 +291,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.interactive=${hasTileCardAction(this._config)}
.actionHandlerOptions=${{
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
@@ -348,7 +305,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
hasHold: hasAction(this._config!.icon_hold_action),
hasDoubleClick: hasAction(this._config!.icon_double_tap_action),
}}
.interactive=${this._hasIconAction}
.interactive=${hasTileIconAction(this._config)}
.accessibleLabel=${name}
.imageUrl=${imageUrl}
data-domain=${ifDefined(domain)}
@@ -0,0 +1,67 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { DOMAINS_TOGGLE } from "../../../../common/const";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { cameraUrlWithWidthHeight } from "../../../../data/camera";
import type { HomeAssistantConnection } from "../../../../types";
import type { ActionConfigParams } from "../../common/handle-action";
import { hasAction } from "../../common/has-action";
import type { TileActionsConfig } from "../types";
export const getEntityDefaultTileIconAction = (entityId: string) => {
const domain = computeDomain(entityId);
const supportsIconAction =
DOMAINS_TOGGLE.has(domain) ||
["button", "input_button", "scene"].includes(domain);
return supportsIconAction ? "toggle" : "none";
};
export const getTileActionDefaults = (
entityId: string
): Pick<TileActionsConfig, "tap_action" | "icon_tap_action"> => ({
tap_action: { action: "more-info" },
icon_tap_action: { action: getEntityDefaultTileIconAction(entityId) },
});
export const hasTileCardAction = (config?: TileActionsConfig) =>
!config?.tap_action ||
hasAction(config.tap_action) ||
hasAction(config.hold_action) ||
hasAction(config.double_tap_action);
export const hasTileIconAction = (config?: TileActionsConfig) =>
!config?.icon_tap_action ||
hasAction(config.icon_tap_action) ||
hasAction(config.icon_hold_action) ||
hasAction(config.icon_double_tap_action);
export const getTileIconActionConfig = (
entity: string,
config: TileActionsConfig
): ActionConfigParams => ({
entity,
tap_action: config.icon_tap_action,
hold_action: config.icon_hold_action,
double_tap_action: config.icon_double_tap_action,
});
export const getTileImageUrl = (
connection: Pick<HomeAssistantConnection, "hassUrl">,
stateObj: HassEntity,
size = 80
): string | undefined => {
const entityPicture =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (!entityPicture) {
return undefined;
}
const imageUrl = connection.hassUrl(entityPicture);
return computeDomain(stateObj.entity_id) === "camera"
? cameraUrlWithWidthHeight(imageUrl, size, size)
: imageUrl;
};
+17 -7
View File
@@ -686,7 +686,16 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
double_tap_action?: ActionConfig;
}
export interface TileCardConfig extends LovelaceCardConfig {
export interface TileActionsConfig {
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
icon_tap_action?: ActionConfig;
icon_hold_action?: ActionConfig;
icon_double_tap_action?: ActionConfig;
}
export interface TileCardConfig extends LovelaceCardConfig, TileActionsConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
hide_state?: boolean;
@@ -695,12 +704,6 @@ export interface TileCardConfig extends LovelaceCardConfig {
color?: string;
show_entity_picture?: boolean;
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
icon_tap_action?: ActionConfig;
icon_hold_action?: ActionConfig;
icon_double_tap_action?: ActionConfig;
features?: LovelaceCardFeatureConfig[];
features_position?: LovelaceCardFeaturePosition;
time_format?: TimestampRenderingFormat;
@@ -739,9 +742,16 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
export interface AlertCardConfig extends LovelaceCardConfig {
type: "alert";
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
icon?: string;
alert_color?: string;
clear_color?: string;
pulse?: boolean;
hide_state?: boolean;
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
@@ -0,0 +1,75 @@
import { mdiAlertOutline, mdiTextShort } from "@mdi/js";
import { DEFAULT_ALERT_COLOR } from "../../../../common/entity/alert_active";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { contentLayoutSchema, displayedElementsSchema } from "./content-schema";
export const alertSchema = () =>
({
name: "alert",
type: "expandable",
flatten: true,
iconPath: mdiAlertOutline,
schema: [
{
name: "",
type: "grid",
schema: [
{
name: "clear_color",
selector: {
ui_color: { include_none: true, default_color: "none" },
},
},
{
name: "alert_color",
selector: {
ui_color: {
include_none: true,
default_color: DEFAULT_ALERT_COLOR,
},
},
},
],
},
{ name: "pulse", selector: { boolean: {} } },
],
}) as const satisfies HaFormSchema;
const nameSchema = {
name: "name",
selector: { entity_name: {} },
context: { entity: "entity" },
} as const satisfies HaFormSchema;
const iconSchema = {
name: "icon",
selector: { icon: {} },
context: { icon_entity: "entity" },
} as const satisfies HaFormSchema;
export const alertCardContentSchema = (localize: LocalizeFunc) =>
({
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [
nameSchema,
{
name: "",
type: "grid",
schema: [iconSchema, { name: "hide_state", selector: { boolean: {} } }],
},
contentLayoutSchema(localize),
],
}) as const satisfies HaFormSchema;
export const alertBadgeContentSchema = (localize: LocalizeFunc) =>
({
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [nameSchema, iconSchema, displayedElementsSchema(localize)],
}) as const satisfies HaFormSchema;
@@ -0,0 +1,246 @@
import { mdiTextShort } from "@mdi/js";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HaFormSchema } from "../../../../components/ha-form/types";
export interface TileContentFormConfig {
hide_state?: boolean;
state_content?: string | string[];
vertical?: boolean;
content_layout?: string;
}
export interface BadgeContentFormConfig {
show_name?: boolean;
show_state?: boolean;
show_icon?: boolean;
state_content?: string | string[];
displayed_elements?: string[];
}
const colorSchema = {
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
} as const satisfies HaFormSchema;
export const contentLayoutSchema = (localize: LocalizeFunc) =>
({
name: "content_layout",
required: true,
selector: {
select: {
mode: "box",
options: ["horizontal", "vertical"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
),
value,
image: {
src: `/static/images/form/tile_content_layout_${value}.svg`,
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
flip_rtl: true,
},
})),
},
},
}) as const satisfies HaFormSchema;
export const displayedElementsSchema = (localize: LocalizeFunc) =>
({
name: "displayed_elements",
selector: {
select: {
mode: "list",
multiple: true,
options: (["name", "state", "icon"] as const).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.${value}`
),
})),
},
},
}) as const satisfies HaFormSchema;
export const tileContentSchema = (
localize: LocalizeFunc,
showTimeFormat: boolean
) =>
({
name: "content",
flatten: true,
type: "expandable",
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
colorSchema,
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
{
name: "hide_state",
selector: {
boolean: {},
},
},
],
},
{
name: "state_content",
visible: { field: "hide_state", operator: "not_eq", value: true },
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
},
contentLayoutSchema(localize),
],
}) as const satisfies HaFormSchema;
export const badgeContentSchema = (
localize: LocalizeFunc,
showTimeFormat: boolean
) =>
({
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
colorSchema,
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
],
},
displayedElementsSchema(localize),
{
name: "state_content",
selector: {
ui_state_content: {
allow_name: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
},
],
}) as const satisfies HaFormSchema;
export const tileContentFormData = <T extends TileContentFormConfig>(
config: T
): T => ({
...config,
content_layout: config.vertical ? "vertical" : "horizontal",
});
export const tileContentConfigFromForm = <T extends TileContentFormConfig>(
value: T
): T => {
const config = { ...value };
if (config.hide_state || !config.state_content) {
delete config.state_content;
}
if (config.content_layout) {
config.vertical = config.content_layout === "vertical";
delete config.content_layout;
}
return config;
};
export const badgeContentFormData = <T extends BadgeContentFormConfig>(
config: T
): T => ({
...config,
displayed_elements: [
...(config.show_name ? ["name"] : []),
...(config.show_state ? ["state"] : []),
...(config.show_icon ? ["icon"] : []),
],
});
export const badgeContentConfigFromForm = <T extends BadgeContentFormConfig>(
value: T
): T => {
const config = { ...value };
if (!config.state_content) {
delete config.state_content;
}
if (config.displayed_elements) {
config.show_name = config.displayed_elements.includes("name");
config.show_state = config.displayed_elements.includes("state");
config.show_icon = config.displayed_elements.includes("icon");
delete config.displayed_elements;
}
return config;
};
@@ -1,53 +1,47 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
} from "../../../../common/entity/alert_active";
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { AlertBadgeConfig } from "../../badges/types";
import { ALERT_BADGE_DEFAULT_CONFIG } from "../../badges/hui-alert-badge";
import type { LovelaceBadgeEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import { alertBadgeContentSchema, alertSchema } from "./alert-schema";
import {
badgeContentConfigFromForm,
badgeContentFormData,
} from "./content-schema";
import { entityInteractionsSchema } from "./interactions-schema";
const badgeConfigStruct = assign(
baseLovelaceBadgeConfig,
object({
entity: optional(string()),
name: optional(entityNameStruct),
icon: optional(string()),
alert_color: optional(string()),
clear_color: optional(string()),
pulse: optional(boolean()),
show_name: optional(boolean()),
show_state: optional(boolean()),
show_icon: optional(boolean()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
})
);
const SCHEMA = [
{
name: "entity",
required: true,
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
},
{
name: "",
type: "grid",
schema: [
{
name: "clear_color",
selector: { ui_color: { include_none: true, default_color: "none" } },
},
{
name: "alert_color",
selector: {
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
},
},
],
},
{ name: "pulse", selector: { boolean: {} } },
] as const;
@customElement("hui-alert-badge-editor")
export class HuiAlertBadgeEditor
extends LitElement
@@ -59,9 +53,25 @@ export class HuiAlertBadgeEditor
public setConfig(config: AlertBadgeConfig): void {
assert(config, badgeConfigStruct);
this._config = config;
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "entity",
required: true,
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
},
alertSchema(),
alertBadgeContentSchema(localize),
entityInteractionsSchema(),
] as const satisfies readonly HaFormSchema[]
);
private _formData = memoizeOne(badgeContentFormData<AlertBadgeConfig>);
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -70,8 +80,8 @@ export class HuiAlertBadgeEditor
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.data=${this._formData(this._config)}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
@@ -81,22 +91,40 @@ export class HuiAlertBadgeEditor
private _valueChanged(ev: ValueChangedEvent<AlertBadgeConfig>): void {
ev.stopPropagation();
fireEvent(this, "config-changed", { config: ev.detail.value });
const config = badgeContentConfigFromForm(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
if (schema.name === "entity") {
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.entity"
);
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "pulse":
case "alert_color":
case "clear_color":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
case "alert":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.alert.alert_section"
);
case "displayed_elements":
return this.hass!.localize(
`ui.panel.lovelace.editor.badge.entity.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
};
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
if (
schema.name === "pulse" ||
schema.name === "alert_color" ||
@@ -1,53 +1,45 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
} from "../../../../common/entity/alert_active";
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { AlertCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import { alertCardContentSchema, alertSchema } from "./alert-schema";
import {
tileContentConfigFromForm,
tileContentFormData,
} from "./content-schema";
import { entityInteractionsSchema } from "./interactions-schema";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
entity: optional(string()),
name: optional(entityNameStruct),
icon: optional(string()),
alert_color: optional(string()),
clear_color: optional(string()),
pulse: optional(boolean()),
hide_state: optional(boolean()),
vertical: optional(boolean()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
})
);
const SCHEMA = [
{
name: "entity",
required: true,
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
},
{
name: "",
type: "grid",
schema: [
{
name: "clear_color",
selector: { ui_color: { include_none: true, default_color: "none" } },
},
{
name: "alert_color",
selector: {
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
},
},
],
},
{ name: "pulse", selector: { boolean: {} } },
] as const;
@customElement("hui-alert-card-editor")
export class HuiAlertCardEditor
extends LitElement
@@ -62,6 +54,20 @@ export class HuiAlertCardEditor
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "entity",
required: true,
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
},
alertSchema(),
alertCardContentSchema(localize),
entityInteractionsSchema(),
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -70,8 +76,8 @@ export class HuiAlertCardEditor
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.data=${tileContentFormData(this._config)}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
@@ -81,22 +87,41 @@ export class HuiAlertCardEditor
private _valueChanged(ev: ValueChangedEvent<AlertCardConfig>): void {
ev.stopPropagation();
fireEvent(this, "config-changed", { config: ev.detail.value });
const config = tileContentConfigFromForm(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
if (schema.name === "entity") {
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.entity"
);
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "pulse":
case "alert_color":
case "clear_color":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
case "alert":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.alert.alert_section"
);
case "hide_state":
case "content_layout":
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}`
);
}
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
};
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
if (
schema.name === "pulse" ||
schema.name === "alert_color" ||
@@ -1,4 +1,3 @@
import { mdiGestureTap, mdiTextShort } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -20,7 +19,7 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import {
DEFAULT_CONFIG,
DISPLAY_TYPES,
@@ -28,11 +27,16 @@ import {
} from "../../badges/hui-entity-badge";
import type { EntityBadgeConfig } from "../../badges/types";
import type { LovelaceBadgeEditor } from "../../types";
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import { configElementStyle } from "./config-elements-style";
import {
badgeContentConfigFromForm,
badgeContentFormData,
badgeContentSchema,
} from "./content-schema";
import { entityInteractionsSchema } from "./interactions-schema";
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
import { timeFormatConfigStruct } from "../../components/types";
@@ -78,148 +82,12 @@ export class HuiEntityBadgeEditor
(localize: LocalizeFunc, showTimeFormat: boolean) =>
[
{ name: "entity", selector: { entity: {} } },
{
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
],
},
{
name: "displayed_elements",
selector: {
select: {
mode: "list",
multiple: true,
options: [
{
value: "name",
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.name`
),
},
{
value: "state",
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.state`
),
},
{
value: "icon",
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.icon`
),
},
],
},
},
},
{
name: "state_content",
selector: {
ui_state_content: {
allow_name: true,
},
},
context: {
filter_entity: "entity",
},
},
...(showTimeFormat
? ([
{
name: "time_format",
selector: {
ui_time_format: {},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
],
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})
),
},
],
},
badgeContentSchema(localize, showTimeFormat),
entityInteractionsSchema(),
] as const satisfies readonly HaFormSchema[]
);
_displayedElements = memoizeOne((config: EntityBadgeConfig) => {
const elements: string[] = [];
if (config.show_name) {
elements.push("name");
}
if (config.show_state) {
elements.push("state");
}
if (config.show_icon) {
elements.push("icon");
}
return elements;
});
private _formData = memoizeOne(badgeContentFormData<EntityBadgeConfig>);
protected render() {
if (!this.hass || !this._config) {
@@ -238,10 +106,7 @@ export class HuiEntityBadgeEditor
const schema = this._schema(this.hass!.localize, showTimeFormat);
const data = {
...this._config,
displayed_elements: this._displayedElements(this._config),
};
const data = this._formData(this._config);
return html`
<ha-form
@@ -255,24 +120,13 @@ export class HuiEntityBadgeEditor
`;
}
private _valueChanged(ev: CustomEvent): void {
private _valueChanged(ev: ValueChangedEvent<EntityBadgeConfig>): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const config = { ...ev.detail.value } as EntityBadgeConfig;
if (!config.state_content) {
delete config.state_content;
}
if (config.displayed_elements) {
config.show_name = config.displayed_elements.includes("name");
config.show_state = config.displayed_elements.includes("state");
config.show_icon = config.displayed_elements.includes("icon");
delete config.displayed_elements;
}
const config = badgeContentConfigFromForm(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
@@ -1,4 +1,4 @@
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
import { mdiListBox } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -30,8 +30,6 @@ import type {
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
import { getEntityDefaultTileIconAction } from "../../cards/hui-tile-card";
import type { TileCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
@@ -39,7 +37,13 @@ import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
import { configElementStyle } from "./config-elements-style";
import {
tileContentConfigFromForm,
tileContentFormData,
tileContentSchema,
} from "./content-schema";
import { getSupportedFeaturesType } from "./hui-card-features-editor";
import { tileInteractionsSchema } from "./interactions-schema";
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
import { timeFormatConfigStruct } from "../../components/types";
@@ -96,147 +100,8 @@ export class HuiTileCardEditor
) =>
[
{ name: "entity", selector: { entity: {} } },
{
name: "content",
flatten: true,
type: "expandable",
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
{
name: "hide_state",
selector: {
boolean: {},
},
},
],
},
{
name: "state_content",
visible: { field: "hide_state", operator: "not_eq", value: true },
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
},
{
name: "content_layout",
required: true,
selector: {
select: {
mode: "box",
options: ["horizontal", "vertical"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
),
value,
image: {
src: `/static/images/form/tile_content_layout_${value}.svg`,
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
flip_rtl: true,
},
})),
},
},
},
],
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
ui_action: {
default_action: entityId
? getEntityDefaultTileIconAction(entityId)
: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (
[
"hold_action",
"icon_hold_action",
"double_tap_action",
"icon_double_tap_action",
] as const
).map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})),
},
],
},
tileContentSchema(localize, showTimeFormat),
tileInteractionsSchema(entityId),
] as const satisfies readonly HaFormSchema[]
);
@@ -296,10 +161,7 @@ export class HuiTileCardEditor
const featuresSchema = this._featuresSchema(this.hass.localize, vertical);
const data = {
...this._config,
content_layout: vertical ? "vertical" : "horizontal",
};
const data = tileContentFormData(this._config);
// Default features position to bottom and force it to bottom in vertical mode
if (!data.features_position || vertical) {
@@ -362,24 +224,10 @@ export class HuiTileCardEditor
const newConfig = ev.detail.value as TileCardConfig;
let config: TileCardConfig = {
let config: TileCardConfig = tileContentConfigFromForm({
features: this._config.features,
...newConfig,
};
if (config.hide_state) {
delete config.state_content;
}
if (!config.state_content) {
delete config.state_content;
}
// Convert content_layout to vertical
if (config.content_layout) {
config.vertical = config.content_layout === "vertical";
delete config.content_layout;
}
});
config = orderProperties(config, fieldOrder);
@@ -0,0 +1,94 @@
import { mdiGestureTap } from "@mdi/js";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { getEntityDefaultTileIconAction } from "../../cards/tile/tile-card-helpers";
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
export const tileInteractionsSchema = (entityId: string | undefined) =>
({
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
ui_action: {
default_action: entityId
? getEntityDefaultTileIconAction(entityId)
: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (
[
"hold_action",
"icon_hold_action",
"double_tap_action",
"icon_double_tap_action",
] as const
).map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})),
},
],
}) as const satisfies HaFormSchema;
export const entityInteractionsSchema = () =>
({
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})
),
},
],
}) as const satisfies HaFormSchema;
+2 -1
View File
@@ -10493,7 +10493,8 @@
"clear_color": "Clear color",
"clear_color_helper": "Used when the entity isn't in an alert state. The background doesn't pulse.",
"pulse": "Pulse",
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead."
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead.",
"alert_section": "Alert"
},
"area": {
"name": "Area",
@@ -1,75 +0,0 @@
import { beforeAll, describe, expect, it } from "vitest";
import { setPlatformAPI } from "zrender/lib/core/platform.js";
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
import { fitLabelFontSize } from "../../../src/components/chart/fit-label-font-size";
const FONT_SIZE = 12;
// jsdom has no canvas, so measure every character as 0.6em wide
const EM = 0.6;
const LINE_HEIGHT = EM * FONT_SIZE;
const fit = (text: string, width: number, height: number) =>
fitLabelFontSize(text, {
fontSize: FONT_SIZE,
width,
height,
unwrappedHeight: text.split("\n").length * LINE_HEIGHT,
});
// wrap like ECharts does at the returned font size
const wrap = (text: string, fontSize: number, width: number) =>
text.split("\n").map(
(paragraph) =>
parsePlainText(
paragraph,
{
font: `${fontSize}px Roboto, Noto, sans-serif`,
width,
overflow: "break",
},
undefined,
undefined
).lines
);
beforeAll(() => {
setPlatformAPI({
measureText: (text, font) => ({
width: text.length * EM * parseFloat(/([\d.]+)px/.exec(font!)![1]),
}),
});
});
describe("fitLabelFontSize", () => {
it("keeps the font size when the label fits", () => {
expect(fit("Kitchen", 100, 20)).toBe(FONT_SIZE);
});
it("ignores indentation after an explicit newline", () => {
expect(fit("Kitchen\n light", 100, 30)).toBe(FONT_SIZE);
});
it.each([
["narrow wrapped label", "Untracked consumption", 70, 22],
["long word", "Geschirrspülmaschine", 70, 40],
["word that fills the width", "Electric vehicle charger", 40, 20],
["label and value", "Electric vehicle charger\n140 kWh", 70, 20],
["small height", "Coffee machine", 70, 9],
])("fits a %s without breaking words", (_name, text, width, height) => {
const fontSize = fit(text, width, height);
expect(fontSize).toBeGreaterThan(0);
expect(fontSize).toBeLessThanOrEqual(FONT_SIZE);
const paragraphs = wrap(text, fontSize, width);
const lines = paragraphs.flat();
expect(lines.length * EM * fontSize).toBeLessThanOrEqual(height);
for (const word of text.split(/\s+/)) {
expect(lines.some((line) => line.includes(word))).toBe(true);
}
for (const paragraph of paragraphs) {
expect(paragraph.slice(1).some((line) => line.startsWith(" "))).toBe(
false
);
}
});
});
+3 -126
View File
@@ -15,7 +15,6 @@ import type {
HaMap,
HaMapEditableLocation,
} from "../../../src/components/map/ha-map";
import { createMockEntityState } from "../../fixtures/hass";
const FORMATTERS = {
formatEntityName: computeEntityNameDisplayWithoutContext,
@@ -188,21 +187,6 @@ const STATES = {
},
} as unknown as HassEntities;
const ZONES = {
"zone.paulus": createMockEntityState("zone.paulus", "0", {
friendly_name: "Work",
latitude: 52.372,
longitude: 4.89,
radius: 100,
}),
"zone.anne_therese": createMockEntityState("zone.anne_therese", "0", {
friendly_name: "Work",
latitude: 52.377,
longitude: 4.895,
radius: 100,
}),
};
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
const container = (el: HaMap) => el.shadowRoot!.getElementById("map")!;
const isRevealed = (el: HaMap) => container(el).classList.contains("drawn");
@@ -210,14 +194,11 @@ const isLoaded = (el: HaMap) => (el as any)._loaded as boolean;
const entityHandles = (el: HaMap) =>
(el as any)._entityHandles as MapMarkerHandle[];
const createMap = async (
states: HassEntities = STATES,
entities: string[] = Object.keys(states)
): Promise<HaMap> => {
const createMap = async (): Promise<HaMap> => {
const el = document.createElement("ha-map");
el.entities = entities;
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
el.clusterMarkers = false;
(el as any)._states = states;
(el as any)._states = STATES;
(el as any)._formatters = FORMATTERS;
(el as any)._config = {
config: { latitude: 52.3731339, longitude: 4.8903147 },
@@ -255,110 +236,6 @@ describe("ha-map engine selection", () => {
expect(entityHandles(el)).toHaveLength(2);
});
it("groups same-named zones by reported IDs", async () => {
const states: HassEntities = {
...ZONES,
"person.paulus": createMockEntityState("person.paulus", "Work", {
in_zones: ["zone.paulus"],
}),
"device_tracker.anne_therese": createMockEntityState(
"device_tracker.anne_therese",
"Work",
{
latitude: ZONES["zone.paulus"].attributes.latitude,
longitude: ZONES["zone.paulus"].attributes.longitude,
in_zones: ["zone.anne_therese"],
}
),
};
const el = await createMap(states);
expect(entityHandles(el).map((marker) => marker.clusterData)).toMatchObject(
[
{ entityId: "person.paulus", zoneId: "zone.paulus" },
{
entityId: "device_tracker.anne_therese",
zoneId: "zone.anne_therese",
},
]
);
});
it.each([
{
entityId: "device_tracker.paulus",
state: "Work",
inZones: undefined,
expectedZoneId: "zone.paulus",
},
{
entityId: "person.paulus",
state: "Work",
inZones: [],
expectedZoneId: "zone.paulus",
},
{
entityId: "person.paulus",
state: "not_home",
inZones: [],
expectedZoneId: undefined,
},
])(
"handles zone membership for $entityId ($state, in_zones: $inZones)",
async ({ entityId, state, inZones, expectedZoneId }) => {
const el = await createMap({
...ZONES,
[entityId]: createMockEntityState(entityId, state, {
latitude: ZONES["zone.paulus"].attributes.latitude,
longitude: ZONES["zone.paulus"].attributes.longitude,
in_zones: inZones,
}),
});
expect(entityHandles(el)).toMatchObject([
{ clusterData: { entityId, zoneId: expectedZoneId } },
]);
}
);
it("updates grouping when a hidden active zone becomes passive and is restored", async () => {
const hiddenZone = ZONES["zone.paulus"];
const states: HassEntities = {
...ZONES,
"zone.enclosing": createMockEntityState("zone.enclosing", "0", {
...hiddenZone.attributes,
friendly_name: "City",
radius: 1000,
}),
"person.paulus": createMockEntityState("person.paulus", "Work", {
in_zones: [hiddenZone.entity_id, "zone.enclosing"],
}),
};
const el = await createMap(states, ["zone.enclosing", "person.paulus"]);
expect(entityHandles(el)[0].clusterData).toMatchObject({
zoneId: undefined,
});
(el as any)._states = {
...states,
[hiddenZone.entity_id]: createMockEntityState(
hiddenZone.entity_id,
hiddenZone.state,
{ ...hiddenZone.attributes, passive: true }
),
};
await el.updateComplete;
expect(entityHandles(el)[0].clusterData).toMatchObject({
zoneId: "zone.enclosing",
});
(el as any)._states = states;
await el.updateComplete;
expect(entityHandles(el)[0].clusterData).toMatchObject({
zoneId: undefined,
});
});
it("sets up while themes are not loaded yet, as during onboarding", async () => {
const el = document.createElement("ha-map");
el.themeMode = "auto";