diff --git a/src/panels/lovelace/common/validate-condition.ts b/src/panels/lovelace/common/validate-condition.ts index 452d061eb0..6c92dee597 100644 --- a/src/panels/lovelace/common/validate-condition.ts +++ b/src/panels/lovelace/common/validate-condition.ts @@ -1,10 +1,48 @@ import { UNAVAILABLE } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; -export interface Condition { +export type Condition = StateCondition | ResponsiveCondition; + +export type StateCondition = { + condition: "state"; entity: string; state?: string; state_not?: string; +}; + +export type ResponsiveCondition = { + condition: "responsive"; + min_width?: number; + max_width?: number; +}; + +function checkStateCondition(condition: StateCondition, hass: HomeAssistant) { + const state = hass.states[condition.entity] + ? hass!.states[condition.entity].state + : UNAVAILABLE; + + return condition.state != null + ? state === condition.state + : state !== condition.state_not; +} + +export function buildMediaQuery(condition: ResponsiveCondition) { + const queries: string[] = []; + if (condition.min_width != null) { + queries.push(`(min-width: ${condition.min_width}px)`); + } + if (condition.max_width != null) { + queries.push(`(max-width: ${condition.max_width}px)`); + } + return queries.join(" and "); +} + +function checkResponsiveCondition( + condition: ResponsiveCondition, + _hass: HomeAssistant +) { + const query = buildMediaQuery(condition); + return matchMedia(query).matches; } export function checkConditionsMet( @@ -12,18 +50,30 @@ export function checkConditionsMet( hass: HomeAssistant ): boolean { return conditions.every((c) => { - const state = hass.states[c.entity] - ? hass!.states[c.entity].state - : UNAVAILABLE; + if (c.condition === "responsive") { + return checkResponsiveCondition(c, hass); + } - return c.state != null ? state === c.state : state !== c.state_not; + return checkStateCondition(c, hass); }); } -export function validateConditionalConfig(conditions: Condition[]): boolean { - return conditions.every( - (c) => - (c.entity && - (c.state != null || c.state_not != null)) as unknown as boolean - ); +function valideStateCondition(condition: StateCondition) { + return (condition.entity && + (condition.state != null || + condition.state_not != null)) as unknown as boolean; +} + +function valideResponsiveCondition(condition: ResponsiveCondition) { + return (condition.min_width != null || + condition.max_width != null) as unknown as boolean; +} + +export function validateConditionalConfig(conditions: Condition[]): boolean { + return conditions.every((c) => { + if (c.condition === "responsive") { + return valideResponsiveCondition(c); + } + return valideStateCondition(c); + }); } diff --git a/src/panels/lovelace/components/hui-conditional-base.ts b/src/panels/lovelace/components/hui-conditional-base.ts index d5d6da4e28..3392014400 100644 --- a/src/panels/lovelace/components/hui-conditional-base.ts +++ b/src/panels/lovelace/components/hui-conditional-base.ts @@ -3,11 +3,15 @@ import { customElement, property } from "lit/decorators"; import { HomeAssistant } from "../../../types"; import { ConditionalCardConfig } from "../cards/types"; import { + ResponsiveCondition, + buildMediaQuery, checkConditionsMet, validateConditionalConfig, } from "../common/validate-condition"; import { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types"; import { LovelaceCard } from "../types"; +import { listenMediaQuery } from "../../../common/dom/media_query"; +import { deepEqual } from "../../../common/util/deep-equal"; @customElement("hui-conditional-base") export class HuiConditionalBase extends ReactiveElement { @@ -21,6 +25,10 @@ export class HuiConditionalBase extends ReactiveElement { protected _element?: LovelaceCard | LovelaceRow; + private _mediaQueriesListeners: Array<() => void> = []; + + private _mediaQueries: string[] = []; + protected createRenderRoot() { return this; } @@ -47,27 +55,82 @@ export class HuiConditionalBase extends ReactiveElement { this._config = config; } + public disconnectedCallback() { + super.disconnectedCallback(); + this._clearMediaQueries(); + } + + public connectedCallback() { + super.connectedCallback(); + this._listenMediaQueries(); + this._updateVisibility(); + } + + private _clearMediaQueries() { + this._mediaQueries = []; + while (this._mediaQueriesListeners.length) { + this._mediaQueriesListeners.pop()!(); + } + } + + private _listenMediaQueries() { + if (!this._config) { + return; + } + + const conditions = this._config.conditions.filter( + (c) => c.condition === "responsive" + ) as ResponsiveCondition[]; + + const mediaQueries = conditions.map((c) => buildMediaQuery(c)); + + if (deepEqual(mediaQueries, this._mediaQueries)) return; + + this._mediaQueries = mediaQueries; + while (this._mediaQueriesListeners.length) { + this._mediaQueriesListeners.pop()!(); + } + mediaQueries.forEach((query) => { + const listener = listenMediaQuery(query, () => { + this._updateVisibility(); + }); + this._mediaQueriesListeners.push(listener); + }); + } + protected update(changed: PropertyValues): void { super.update(changed); + + if ( + changed.has("_element") || + changed.has("_config") || + changed.has("hass") + ) { + this._listenMediaQueries(); + this._updateVisibility(); + } + } + + private _updateVisibility() { if (!this._element || !this.hass || !this._config) { return; } - this._element.editMode = this.editMode; + this._element!.editMode = this.editMode; const visible = - this.editMode || checkConditionsMet(this._config.conditions, this.hass); + this.editMode || checkConditionsMet(this._config!.conditions, this.hass!); this.hidden = !visible; this.style.setProperty("display", visible ? "" : "none"); if (visible) { - this._element.hass = this.hass; - if (!this._element.parentElement) { - this.appendChild(this._element); + this._element!.hass = this.hass; + if (!this._element!.parentElement) { + this.appendChild(this._element!); } - } else if (this._element.parentElement) { - this.removeChild(this._element); + } else if (this._element!.parentElement) { + this.removeChild(this._element!); } } } diff --git a/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts index b63913d45b..92e98453d6 100644 --- a/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts @@ -11,9 +11,12 @@ import { array, assert, assign, + literal, + number, object, optional, string, + union, } from "superstruct"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; @@ -36,17 +39,28 @@ import type { ConfigChangedEvent } from "../hui-element-editor"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import type { GUIModeChangedEvent } from "../types"; import { configElementStyle } from "./config-elements-style"; +import { StateCondition } from "../../common/validate-condition"; -const conditionStruct = object({ +const stateConditionStruct = object({ + condition: optional(literal("state")), entity: string(), state: optional(string()), state_not: optional(string()), }); + +const responsiveConditionStruct = object({ + condition: literal("responsive"), + max_width: optional(number()), + min_width: optional(number()), +}); + const cardConfigStruct = assign( baseLovelaceCardConfig, object({ card: any(), - conditions: optional(array(conditionStruct)), + conditions: optional( + array(union([stateConditionStruct, responsiveConditionStruct])) + ), }) ); @@ -163,8 +177,10 @@ export class HuiConditionalCardEditor ${this.hass!.localize( "ui.panel.lovelace.editor.card.conditional.condition_explanation" )} - ${this._config.conditions.map( - (cond, idx) => html` + ${this._config.conditions.map((cond, idx) => { + if (cond.condition && cond.condition !== "state") + return nothing; + return html`