Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/mixins/conditional-listener-mixin.ts
Original file line number Diff line number Diff line change
@@ -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<T> = abstract new (...args: any[]) => T;

/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((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<ReactiveElement>,
>(
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;
};
37 changes: 12 additions & 25 deletions src/panels/lovelace/badges/hui-badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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();
}

Expand Down Expand Up @@ -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);
}
);
}
Expand Down
39 changes: 13 additions & 26 deletions src/panels/lovelace/cards/hui-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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();
}

Expand Down Expand Up @@ -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);
}
);
}
Expand Down
30 changes: 0 additions & 30 deletions src/panels/lovelace/common/validate-condition.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -362,31 +360,3 @@ export function addEntityToCondition(
}
return condition;
}

export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((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;
}
Loading
Loading