diff --git a/src/mixins/conditional-listener-mixin.ts b/src/mixins/conditional-listener-mixin.ts new file mode 100644 index 000000000000..f598098254d1 --- /dev/null +++ b/src/mixins/conditional-listener-mixin.ts @@ -0,0 +1,104 @@ +import type { ReactiveElement } from "lit"; +import { listenMediaQuery } from "../common/dom/media_query"; +import type { HomeAssistant } from "../types"; +import type { Condition } from "../panels/lovelace/common/validate-condition"; +import { checkConditionsMet } from "../panels/lovelace/common/validate-condition"; + +type Constructor = abstract new (...args: any[]) => T; + +/** + * Extract media queries from conditions recursively + */ +export function extractMediaQueries(conditions: Condition[]): string[] { + return conditions.reduce((array, c) => { + if ("conditions" in c && c.conditions) { + array.push(...extractMediaQueries(c.conditions)); + } + if (c.condition === "screen" && c.media_query) { + array.push(c.media_query); + } + return array; + }, []); +} + +/** + * Helper to setup media query listeners for conditional visibility + */ +export function setupMediaQueryListeners( + conditions: Condition[], + hass: HomeAssistant, + addListener: (unsub: () => void) => void, + onUpdate: (conditionsMet: boolean) => void +): void { + const mediaQueries = extractMediaQueries(conditions); + + if (mediaQueries.length === 0) return; + + // Optimization for single media query + const hasOnlyMediaQuery = + conditions.length === 1 && + conditions[0].condition === "screen" && + !!conditions[0].media_query; + + mediaQueries.forEach((mediaQuery) => { + const unsub = listenMediaQuery(mediaQuery, (matches) => { + if (hasOnlyMediaQuery) { + onUpdate(matches); + } else { + const conditionsMet = checkConditionsMet(conditions, hass); + onUpdate(conditionsMet); + } + }); + addListener(unsub); + }); +} + +/** + * Mixin to handle conditional listeners for visibility control + * + * Provides lifecycle management for listeners (media queries, time-based, state changes, etc.) + * that control conditional visibility of components. + * + * Usage: + * 1. Extend your component with ConditionalListenerMixin(ReactiveElement) + * 2. Override setupConditionalListeners() to setup your listeners + * 3. Use addConditionalListener() to register unsubscribe functions + * 4. Call clearConditionalListeners() and setupConditionalListeners() when config changes + * + * The mixin automatically: + * - Sets up listeners when component connects to DOM + * - Cleans up listeners when component disconnects from DOM + */ +export const ConditionalListenerMixin = < + T extends Constructor, +>( + superClass: T +) => { + abstract class ConditionalListenerClass extends superClass { + private __listeners: (() => void)[] = []; + + public connectedCallback() { + super.connectedCallback(); + this.setupConditionalListeners(); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this.clearConditionalListeners(); + } + + protected clearConditionalListeners(): void { + this.__listeners.forEach((unsub) => unsub()); + this.__listeners = []; + } + + protected addConditionalListener(unsubscribe: () => void): void { + this.__listeners.push(unsubscribe); + } + + protected setupConditionalListeners(): void { + // Override in subclass + } + } + return ConditionalListenerClass; +}; diff --git a/src/panels/lovelace/badges/hui-badge.ts b/src/panels/lovelace/badges/hui-badge.ts index d89d011c68d7..104fe34a177e 100644 --- a/src/panels/lovelace/badges/hui-badge.ts +++ b/src/panels/lovelace/badges/hui-badge.ts @@ -2,14 +2,14 @@ import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; -import type { MediaQueriesListener } from "../../../common/dom/media_query"; import "../../../components/ha-svg-icon"; import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; import type { HomeAssistant } from "../../../types"; import { - attachConditionMediaQueriesListeners, - checkConditionsMet, -} from "../common/validate-condition"; + ConditionalListenerMixin, + setupMediaQueryListeners, +} from "../../../mixins/conditional-listener-mixin"; +import { checkConditionsMet } from "../common/validate-condition"; import { createBadgeElement } from "../create-element/create-badge-element"; import { createErrorBadgeConfig } from "../create-element/create-element-base"; import type { LovelaceBadge } from "../types"; @@ -22,7 +22,7 @@ declare global { } @customElement("hui-badge") -export class HuiBadge extends ReactiveElement { +export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) { @property({ type: Boolean }) public preview = false; @property({ attribute: false }) public config?: LovelaceBadgeConfig; @@ -40,20 +40,16 @@ export class HuiBadge extends ReactiveElement { private _element?: LovelaceBadge; - private _listeners: MediaQueriesListener[] = []; - protected createRenderRoot() { return this; } public disconnectedCallback() { super.disconnectedCallback(); - this._clearMediaQueries(); } public connectedCallback() { super.connectedCallback(); - this._listenMediaQueries(); this._updateVisibility(); } @@ -137,26 +133,17 @@ export class HuiBadge extends ReactiveElement { } } - private _clearMediaQueries() { - this._listeners.forEach((unsub) => unsub()); - this._listeners = []; - } - - private _listenMediaQueries() { - this._clearMediaQueries(); - if (!this.config?.visibility) { + protected setupConditionalListeners() { + if (!this.config?.visibility || !this.hass) { return; } - const conditions = this.config.visibility; - const hasOnlyMediaQuery = - conditions.length === 1 && - conditions[0].condition === "screen" && - !!conditions[0].media_query; - this._listeners = attachConditionMediaQueriesListeners( + setupMediaQueryListeners( this.config.visibility, - (matches) => { - this._updateVisibility(hasOnlyMediaQuery && matches); + this.hass, + (unsub) => this.addConditionalListener(unsub), + (conditionsMet) => { + this._updateVisibility(conditionsMet); } ); } diff --git a/src/panels/lovelace/cards/hui-card.ts b/src/panels/lovelace/cards/hui-card.ts index 68517dec2015..22b56d5d872f 100644 --- a/src/panels/lovelace/cards/hui-card.ts +++ b/src/panels/lovelace/cards/hui-card.ts @@ -2,16 +2,16 @@ import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; -import type { MediaQueriesListener } from "../../../common/dom/media_query"; import "../../../components/ha-svg-icon"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { HomeAssistant } from "../../../types"; +import { + ConditionalListenerMixin, + setupMediaQueryListeners, +} from "../../../mixins/conditional-listener-mixin"; import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size"; import { computeCardSize } from "../common/compute-card-size"; -import { - attachConditionMediaQueriesListeners, - checkConditionsMet, -} from "../common/validate-condition"; +import { checkConditionsMet } from "../common/validate-condition"; import { tryCreateCardElement } from "../create-element/create-card-element"; import { createErrorCardElement } from "../create-element/create-element-base"; import type { LovelaceCard, LovelaceGridOptions } from "../types"; @@ -24,7 +24,7 @@ declare global { } @customElement("hui-card") -export class HuiCard extends ReactiveElement { +export class HuiCard extends ConditionalListenerMixin(ReactiveElement) { @property({ type: Boolean }) public preview = false; @property({ attribute: false }) public config?: LovelaceCardConfig; @@ -44,20 +44,16 @@ export class HuiCard extends ReactiveElement { private _element?: LovelaceCard; - private _listeners: MediaQueriesListener[] = []; - protected createRenderRoot() { return this; } public disconnectedCallback() { super.disconnectedCallback(); - this._clearMediaQueries(); } public connectedCallback() { super.connectedCallback(); - this._listenMediaQueries(); this._updateVisibility(); } @@ -251,26 +247,17 @@ export class HuiCard extends ReactiveElement { } } - private _clearMediaQueries() { - this._listeners.forEach((unsub) => unsub()); - this._listeners = []; - } - - private _listenMediaQueries() { - this._clearMediaQueries(); - if (!this.config?.visibility) { + protected setupConditionalListeners() { + if (!this.config?.visibility || !this.hass) { return; } - const conditions = this.config.visibility; - const hasOnlyMediaQuery = - conditions.length === 1 && - conditions[0].condition === "screen" && - !!conditions[0].media_query; - this._listeners = attachConditionMediaQueriesListeners( + setupMediaQueryListeners( this.config.visibility, - (matches) => { - this._updateVisibility(hasOnlyMediaQuery && matches); + this.hass, + (unsub) => this.addConditionalListener(unsub), + (conditionsMet) => { + this._updateVisibility(conditionsMet); } ); } diff --git a/src/panels/lovelace/common/validate-condition.ts b/src/panels/lovelace/common/validate-condition.ts index 332b75c2241d..758b621f3762 100644 --- a/src/panels/lovelace/common/validate-condition.ts +++ b/src/panels/lovelace/common/validate-condition.ts @@ -1,6 +1,4 @@ import { ensureArray } from "../../../common/array/ensure-array"; -import type { MediaQueriesListener } from "../../../common/dom/media_query"; -import { listenMediaQuery } from "../../../common/dom/media_query"; import { isValidEntityId } from "../../../common/entity/valid_entity_id"; import { UNKNOWN } from "../../../data/entity"; @@ -362,31 +360,3 @@ export function addEntityToCondition( } return condition; } - -export function extractMediaQueries(conditions: Condition[]): string[] { - return conditions.reduce((array, c) => { - if ("conditions" in c && c.conditions) { - array.push(...extractMediaQueries(c.conditions)); - } - if (c.condition === "screen" && c.media_query) { - array.push(c.media_query); - } - return array; - }, []); -} - -export function attachConditionMediaQueriesListeners( - conditions: Condition[], - onChange: (visibility: boolean) => void -): MediaQueriesListener[] { - const mediaQueries = extractMediaQueries(conditions); - - const listeners = mediaQueries.map((query) => { - const listener = listenMediaQuery(query, (matches) => { - onChange(matches); - }); - return listener; - }); - - return listeners; -} diff --git a/src/panels/lovelace/components/hui-conditional-base.ts b/src/panels/lovelace/components/hui-conditional-base.ts index 0d5249ee061e..8c53e9fb0d81 100644 --- a/src/panels/lovelace/components/hui-conditional-base.ts +++ b/src/panels/lovelace/components/hui-conditional-base.ts @@ -1,16 +1,16 @@ import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; -import type { MediaQueriesListener } from "../../../common/dom/media_query"; -import { deepEqual } from "../../../common/util/deep-equal"; import type { HomeAssistant } from "../../../types"; +import { + ConditionalListenerMixin, + setupMediaQueryListeners, +} from "../../../mixins/conditional-listener-mixin"; import type { HuiCard } from "../cards/hui-card"; import type { ConditionalCardConfig } from "../cards/types"; import type { Condition } from "../common/validate-condition"; import { - attachConditionMediaQueriesListeners, checkConditionsMet, - extractMediaQueries, validateConditionalConfig, } from "../common/validate-condition"; import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types"; @@ -22,7 +22,9 @@ declare global { } @customElement("hui-conditional-base") -export class HuiConditionalBase extends ReactiveElement { +export class HuiConditionalBase extends ConditionalListenerMixin( + ReactiveElement +) { @property({ attribute: false }) public hass?: HomeAssistant; @property({ type: Boolean }) public preview = false; @@ -31,10 +33,6 @@ export class HuiConditionalBase extends ReactiveElement { protected _element?: HuiCard | LovelaceRow; - private _listeners: MediaQueriesListener[] = []; - - private _mediaQueries: string[] = []; - protected createRenderRoot() { return this; } @@ -63,21 +61,14 @@ export class HuiConditionalBase extends ReactiveElement { public disconnectedCallback() { super.disconnectedCallback(); - this._clearMediaQueries(); } public connectedCallback() { super.connectedCallback(); - this._listenMediaQueries(); this._updateVisibility(); } - private _clearMediaQueries() { - this._listeners.forEach((unsub) => unsub()); - this._listeners = []; - } - - private _listenMediaQueries() { + protected setupConditionalListeners() { if (!this._config || !this.hass) { return; } @@ -85,27 +76,13 @@ export class HuiConditionalBase extends ReactiveElement { const supportedConditions = this._config.conditions.filter( (c) => "condition" in c ) as Condition[]; - const mediaQueries = extractMediaQueries(supportedConditions); - - if (deepEqual(mediaQueries, this._mediaQueries)) return; - - this._clearMediaQueries(); - - const conditions = this._config.conditions; - const hasOnlyMediaQuery = - conditions.length === 1 && - "condition" in conditions[0] && - conditions[0].condition === "screen" && - !!conditions[0].media_query; - this._listeners = attachConditionMediaQueriesListeners( + setupMediaQueryListeners( supportedConditions, - (matches) => { - if (hasOnlyMediaQuery) { - this.setVisibility(matches); - return; - } - this._updateVisibility(); + this.hass, + (unsub) => this.addConditionalListener(unsub), + (conditionsMet) => { + this.setVisibility(conditionsMet); } ); } @@ -119,7 +96,8 @@ export class HuiConditionalBase extends ReactiveElement { changed.has("hass") || changed.has("preview") ) { - this._listenMediaQueries(); + this.clearConditionalListeners(); + this.setupConditionalListeners(); this._updateVisibility(); } } diff --git a/src/panels/lovelace/heading-badges/hui-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-heading-badge.ts index 3f7c74b048b5..44b363e06ee3 100644 --- a/src/panels/lovelace/heading-badges/hui-heading-badge.ts +++ b/src/panels/lovelace/heading-badges/hui-heading-badge.ts @@ -2,13 +2,13 @@ import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; -import type { MediaQueriesListener } from "../../../common/dom/media_query"; import "../../../components/ha-svg-icon"; import type { HomeAssistant } from "../../../types"; import { - attachConditionMediaQueriesListeners, - checkConditionsMet, -} from "../common/validate-condition"; + ConditionalListenerMixin, + setupMediaQueryListeners, +} from "../../../mixins/conditional-listener-mixin"; +import { checkConditionsMet } from "../common/validate-condition"; import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element"; import type { LovelaceHeadingBadge } from "../types"; import type { LovelaceHeadingBadgeConfig } from "./types"; @@ -21,7 +21,7 @@ declare global { } @customElement("hui-heading-badge") -export class HuiHeadingBadge extends ReactiveElement { +export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) { @property({ type: Boolean }) public preview = false; @property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig; @@ -39,20 +39,16 @@ export class HuiHeadingBadge extends ReactiveElement { private _element?: LovelaceHeadingBadge; - private _listeners: MediaQueriesListener[] = []; - protected createRenderRoot() { return this; } public disconnectedCallback() { super.disconnectedCallback(); - this._clearMediaQueries(); } public connectedCallback() { super.connectedCallback(); - this._listenMediaQueries(); this._updateVisibility(); } @@ -137,26 +133,17 @@ export class HuiHeadingBadge extends ReactiveElement { } } - private _clearMediaQueries() { - this._listeners.forEach((unsub) => unsub()); - this._listeners = []; - } - - private _listenMediaQueries() { - this._clearMediaQueries(); - if (!this.config?.visibility) { + protected setupConditionalListeners() { + if (!this.config?.visibility || !this.hass) { return; } - const conditions = this.config.visibility; - const hasOnlyMediaQuery = - conditions.length === 1 && - conditions[0].condition === "screen" && - !!conditions[0].media_query; - this._listeners = attachConditionMediaQueriesListeners( + setupMediaQueryListeners( this.config.visibility, - (matches) => { - this._updateVisibility(hasOnlyMediaQuery && matches); + this.hass, + (unsub) => this.addConditionalListener(unsub), + (conditionsMet) => { + this._updateVisibility(conditionsMet); } ); } diff --git a/src/panels/lovelace/sections/hui-section.ts b/src/panels/lovelace/sections/hui-section.ts index 4800cab7bd6b..f1d7eb102448 100644 --- a/src/panels/lovelace/sections/hui-section.ts +++ b/src/panels/lovelace/sections/hui-section.ts @@ -4,7 +4,6 @@ import { ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import { storage } from "../../../common/decorators/storage"; import { fireEvent } from "../../../common/dom/fire_event"; -import type { MediaQueriesListener } from "../../../common/dom/media_query"; import "../../../components/ha-svg-icon"; import type { LovelaceSectionElement } from "../../../data/lovelace"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; @@ -14,12 +13,13 @@ import type { } from "../../../data/lovelace/config/section"; import { isStrategySection } from "../../../data/lovelace/config/section"; import type { HomeAssistant } from "../../../types"; +import { + ConditionalListenerMixin, + setupMediaQueryListeners, +} from "../../../mixins/conditional-listener-mixin"; import "../cards/hui-card"; import type { HuiCard } from "../cards/hui-card"; -import { - attachConditionMediaQueriesListeners, - checkConditionsMet, -} from "../common/validate-condition"; +import { checkConditionsMet } from "../common/validate-condition"; import { createSectionElement } from "../create-element/create-section-element"; import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; @@ -37,7 +37,7 @@ declare global { } @customElement("hui-section") -export class HuiSection extends ReactiveElement { +export class HuiSection extends ConditionalListenerMixin(ReactiveElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public config!: LovelaceSectionRawConfig; @@ -59,8 +59,6 @@ export class HuiSection extends ReactiveElement { private _layoutElement?: LovelaceSectionElement; - private _listeners: MediaQueriesListener[] = []; - private _config: LovelaceSectionConfig | undefined; @storage({ @@ -114,14 +112,10 @@ export class HuiSection extends ReactiveElement { public disconnectedCallback() { super.disconnectedCallback(); - this._clearMediaQueries(); } public connectedCallback() { super.connectedCallback(); - if (this.hasUpdated) { - this._listenMediaQueries(); - } this._updateElement(); } @@ -158,26 +152,17 @@ export class HuiSection extends ReactiveElement { } } - private _clearMediaQueries() { - this._listeners.forEach((unsub) => unsub()); - this._listeners = []; - } - - private _listenMediaQueries() { - this._clearMediaQueries(); - if (!this._config?.visibility) { + protected setupConditionalListeners() { + if (!this._config?.visibility || !this.hass) { return; } - const conditions = this._config.visibility; - const hasOnlyMediaQuery = - conditions.length === 1 && - conditions[0].condition === "screen" && - conditions[0].media_query != null; - this._listeners = attachConditionMediaQueriesListeners( + setupMediaQueryListeners( this._config.visibility, - (matches) => { - this._updateElement(hasOnlyMediaQuery && matches); + this.hass, + (unsub) => this.addConditionalListener(unsub), + (conditionsMet) => { + this._updateElement(conditionsMet); } ); } @@ -199,9 +184,6 @@ export class HuiSection extends ReactiveElement { type: sectionConfig.type || DEFAULT_SECTION_LAYOUT, }; this._config = sectionConfig; - if (this.isConnected) { - this._listenMediaQueries(); - } // Create a new layout element if necessary. let addLayoutElement = false;