diff --git a/src/common/condition/listeners.ts b/src/common/condition/listeners.ts index ba7cc8edec..94aaacc1f5 100644 --- a/src/common/condition/listeners.ts +++ b/src/common/condition/listeners.ts @@ -1,6 +1,9 @@ import { listenMediaQuery } from "../dom/media_query"; import type { HomeAssistant } from "../../types"; -import type { Condition } from "../../panels/lovelace/common/validate-condition"; +import type { + Condition, + ConditionContext, +} from "../../panels/lovelace/common/validate-condition"; import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition"; import { extractMediaQueries, extractTimeConditions } from "./extract"; import { calculateNextTimeUpdate } from "./time-calculator"; @@ -19,7 +22,8 @@ export function setupMediaQueryListeners( conditions: Condition[], hass: HomeAssistant, addListener: (unsub: () => void) => void, - onUpdate: (conditionsMet: boolean) => void + onUpdate: (conditionsMet: boolean) => void, + getContext?: () => ConditionContext ): void { const mediaQueries = extractMediaQueries(conditions); @@ -36,7 +40,8 @@ export function setupMediaQueryListeners( if (hasOnlyMediaQuery) { onUpdate(matches); } else { - const conditionsMet = checkConditionsMet(conditions, hass); + const context = getContext?.() ?? {}; + const conditionsMet = checkConditionsMet(conditions, hass, context); onUpdate(conditionsMet); } }); @@ -51,7 +56,8 @@ export function setupTimeListeners( conditions: Condition[], hass: HomeAssistant, addListener: (unsub: () => void) => void, - onUpdate: (conditionsMet: boolean) => void + onUpdate: (conditionsMet: boolean) => void, + getContext?: () => ConditionContext ): void { const timeConditions = extractTimeConditions(conditions); @@ -70,7 +76,8 @@ export function setupTimeListeners( timeoutId = setTimeout(() => { if (delay <= MAX_TIMEOUT_DELAY) { - const conditionsMet = checkConditionsMet(conditions, hass); + const context = getContext?.() ?? {}; + const conditionsMet = checkConditionsMet(conditions, hass, context); onUpdate(conditionsMet); } scheduleUpdate(); @@ -87,3 +94,17 @@ export function setupTimeListeners( scheduleUpdate(); }); } + +/** + * Sets up all condition listeners (media query, time) for conditional visibility. + */ +export function setupConditionListeners( + conditions: Condition[], + hass: HomeAssistant, + addListener: (unsub: () => void) => void, + onUpdate: (conditionsMet: boolean) => void, + getContext?: () => ConditionContext +): void { + setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext); + setupTimeListeners(conditions, hass, addListener, onUpdate, getContext); +} diff --git a/src/mixins/conditional-listener-mixin.ts b/src/mixins/conditional-listener-mixin.ts index 4c514c8ba1..5d7a7748c8 100644 --- a/src/mixins/conditional-listener-mixin.ts +++ b/src/mixins/conditional-listener-mixin.ts @@ -1,10 +1,13 @@ -import type { ReactiveElement } from "lit"; +import { consume } from "@lit/context"; +import type { PropertyValues, ReactiveElement } from "lit"; +import { state } from "lit/decorators"; import type { HomeAssistant } from "../types"; -import { - setupMediaQueryListeners, - setupTimeListeners, -} from "../common/condition/listeners"; -import type { Condition } from "../panels/lovelace/common/validate-condition"; +import { setupConditionListeners } from "../common/condition/listeners"; +import { maxColumnsContext } from "../panels/lovelace/common/context"; +import type { + Condition, + ConditionContext, +} from "../panels/lovelace/common/validate-condition"; type Constructor = abstract new (...args: any[]) => T; @@ -32,6 +35,7 @@ export interface ConditionalConfig { * - Sets up listeners when component connects to DOM * - Cleans up listeners when component disconnects from DOM * - Handles conditional visibility based on defined conditions + * - Consumes column count from the view via Lit Context */ export const ConditionalListenerMixin = < TConfig extends ConditionalConfig = ConditionalConfig, @@ -47,6 +51,12 @@ export const ConditionalListenerMixin = < public hass?: HomeAssistant; + @state() + @consume({ context: maxColumnsContext, subscribe: true }) + protected _maxColumns?: number; + + protected _conditionContext: ConditionContext = {}; + protected _updateElement?(config: TConfig): void; protected _updateVisibility?(conditionsMet?: boolean): void; @@ -61,6 +71,20 @@ export const ConditionalListenerMixin = < this.clearConditionalListeners(); } + protected willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + if (changedProperties.has("_maxColumns")) { + this._conditionContext = { max_columns: this._maxColumns }; + } + } + + protected updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + if (changedProperties.has("_maxColumns")) { + this._updateVisibility?.(); + } + } + /** * Clear conditional listeners * @@ -106,26 +130,18 @@ export const ConditionalListenerMixin = < return; } - const onUpdate = (conditionsMet: boolean) => { - if (this._updateVisibility) { - this._updateVisibility(conditionsMet); - } else if (this._updateElement && config) { - this._updateElement(config); - } - }; - - setupMediaQueryListeners( + setupConditionListeners( finalConditions, this.hass, (unsub) => this.addConditionalListener(unsub), - onUpdate - ); - - setupTimeListeners( - finalConditions, - this.hass, - (unsub) => this.addConditionalListener(unsub), - onUpdate + (conditionsMet) => { + if (this._updateVisibility) { + this._updateVisibility(conditionsMet); + } else if (this._updateElement && config) { + this._updateElement(config); + } + }, + () => this._conditionContext ); } } diff --git a/src/panels/lovelace/badges/hui-badge.ts b/src/panels/lovelace/badges/hui-badge.ts index f567257649..60ba06205e 100644 --- a/src/panels/lovelace/badges/hui-badge.ts +++ b/src/panels/lovelace/badges/hui-badge.ts @@ -160,7 +160,11 @@ export class HuiBadge extends ConditionalListenerMixin( const visible = conditionsMet ?? (!this.config?.visibility || - checkConditionsMet(this.config.visibility, this.hass)); + checkConditionsMet( + this.config.visibility, + this.hass, + this._conditionContext + )); this._setElementVisibility(visible); } diff --git a/src/panels/lovelace/badges/hui-entity-filter-badge.ts b/src/panels/lovelace/badges/hui-entity-filter-badge.ts index c6a3278233..e1f5d493e5 100644 --- a/src/panels/lovelace/badges/hui-entity-filter-badge.ts +++ b/src/panels/lovelace/badges/hui-entity-filter-badge.ts @@ -100,7 +100,7 @@ export class HuiEntityFilterBadge const conditionWithEntity = conditions.map((condition) => addEntityToCondition(condition, entityConf.entity) ); - return checkConditionsMet(conditionWithEntity, this.hass!); + return checkConditionsMet(conditionWithEntity, this.hass!, {}); } const filters = entityConf.state_filter ?? this._config!.state_filter; diff --git a/src/panels/lovelace/cards/hui-card.ts b/src/panels/lovelace/cards/hui-card.ts index 321c7677c7..b227f633eb 100644 --- a/src/panels/lovelace/cards/hui-card.ts +++ b/src/panels/lovelace/cards/hui-card.ts @@ -257,7 +257,11 @@ export class HuiCard extends ConditionalListenerMixin( const visible = conditionsMet ?? (!this.config?.visibility || - checkConditionsMet(this.config.visibility, this.hass)); + checkConditionsMet( + this.config.visibility, + this.hass, + this._conditionContext + )); this._setElementVisibility(visible); } diff --git a/src/panels/lovelace/cards/hui-entity-filter-card.ts b/src/panels/lovelace/cards/hui-entity-filter-card.ts index 17fb7055ea..7e6795a40f 100644 --- a/src/panels/lovelace/cards/hui-entity-filter-card.ts +++ b/src/panels/lovelace/cards/hui-entity-filter-card.ts @@ -174,7 +174,7 @@ export class HuiEntityFilterCard const conditionWithEntity = conditions.map((condition) => addEntityToCondition(condition, entityConf.entity) ); - return checkConditionsMet(conditionWithEntity, this.hass!); + return checkConditionsMet(conditionWithEntity, this.hass!, {}); } const filters = entityConf.state_filter ?? this._config!.state_filter; diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index ddb7670997..d146d70944 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -317,7 +317,7 @@ class HuiMapCard extends LitElement implements LovelaceCard { const conditionWithEntity = conditions.map((condition) => addEntityToCondition(condition, entity.entity_id) ); - return checkConditionsMet(conditionWithEntity, this.hass!); + return checkConditionsMet(conditionWithEntity, this.hass!, {}); }); } else { this._filteredMapEntities = this._mapEntities; diff --git a/src/panels/lovelace/common/context.ts b/src/panels/lovelace/common/context.ts new file mode 100644 index 0000000000..5c85fec3c5 --- /dev/null +++ b/src/panels/lovelace/common/context.ts @@ -0,0 +1,3 @@ +import { createContext } from "@lit/context"; + +export const maxColumnsContext = createContext("lovelace-max-columns"); diff --git a/src/panels/lovelace/common/icon-condition.ts b/src/panels/lovelace/common/icon-condition.ts index fe872f1e6d..a358e7078a 100644 --- a/src/panels/lovelace/common/icon-condition.ts +++ b/src/panels/lovelace/common/icon-condition.ts @@ -8,10 +8,12 @@ import { mdiNumeric, mdiResponsive, mdiStateMachine, + mdiViewColumnOutline, } from "@mdi/js"; import type { Condition } from "./validate-condition"; export const ICON_CONDITION: Record = { + view_columns: mdiViewColumnOutline, location: mdiMapMarker, numeric_state: mdiNumeric, state: mdiStateMachine, diff --git a/src/panels/lovelace/common/validate-condition.ts b/src/panels/lovelace/common/validate-condition.ts index a375c15a03..3a44a28e83 100644 --- a/src/panels/lovelace/common/validate-condition.ts +++ b/src/panels/lovelace/common/validate-condition.ts @@ -13,6 +13,7 @@ import { getUserPerson } from "../../../data/person"; import type { HomeAssistant } from "../../../types"; export type Condition = + | ViewColumnsCondition | LocationCondition | NumericStateCondition | StateCondition @@ -34,6 +35,16 @@ interface BaseCondition { condition: string; } +export interface ConditionContext { + max_columns?: number; +} + +export interface ViewColumnsCondition extends BaseCondition { + condition: "view_columns"; + min?: number; + max?: number; +} + export interface LocationCondition extends BaseCondition { condition: "location"; locations?: string[]; @@ -164,6 +175,17 @@ function checkStateNumericCondition( ); } +function checkViewColumnsCondition( + condition: ViewColumnsCondition, + context: ConditionContext +) { + if (!context.max_columns) return true; + return ( + (condition.min == null || context.max_columns >= condition.min) && + (condition.max == null || context.max_columns <= condition.max) + ); +} + function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) { return condition.media_query ? matchMedia(condition.media_query).matches @@ -194,34 +216,52 @@ function checkUserCondition(condition: UserCondition, hass: HomeAssistant) { : false; } -function checkAndCondition(condition: AndCondition, hass: HomeAssistant) { +function checkAndCondition( + condition: AndCondition, + hass: HomeAssistant, + context: ConditionContext +) { if (!condition.conditions) return true; - return checkConditionsMet(condition.conditions, hass); + return checkConditionsMet(condition.conditions, hass, context); } -function checkNotCondition(condition: NotCondition, hass: HomeAssistant) { +function checkNotCondition( + condition: NotCondition, + hass: HomeAssistant, + context: ConditionContext +) { if (!condition.conditions) return true; - return !checkConditionsMet(condition.conditions, hass); + return !checkConditionsMet(condition.conditions, hass, context); } -function checkOrCondition(condition: OrCondition, hass: HomeAssistant) { +function checkOrCondition( + condition: OrCondition, + hass: HomeAssistant, + context: ConditionContext +) { if (!condition.conditions) return true; - return condition.conditions.some((c) => checkConditionsMet([c], hass)); + return condition.conditions.some((c) => + checkConditionsMet([c], hass, context) + ); } /** * Return the result of applying conditions * @param conditions conditions to apply * @param hass Home Assistant object + * @param context optional context for conditions that need runtime information * @returns true if conditions are respected */ export function checkConditionsMet( conditions: (Condition | LegacyCondition)[], - hass: HomeAssistant + hass: HomeAssistant, + context: ConditionContext ): boolean { return conditions.every((c) => { if ("condition" in c) { switch (c.condition) { + case "view_columns": + return checkViewColumnsCondition(c, context); case "time": return checkTimeCondition(c, hass); case "screen": @@ -233,11 +273,11 @@ export function checkConditionsMet( case "numeric_state": return checkStateNumericCondition(c, hass); case "and": - return checkAndCondition(c, hass); + return checkAndCondition(c, hass, context); case "not": - return checkNotCondition(c, hass); + return checkNotCondition(c, hass, context); case "or": - return checkOrCondition(c, hass); + return checkOrCondition(c, hass, context); default: return checkStateCondition(c, hass); } @@ -349,6 +389,10 @@ function validateOrCondition(condition: OrCondition) { return condition.conditions != null; } +function validateViewColumnsCondition(condition: ViewColumnsCondition) { + return condition.min != null || condition.max != null; +} + function validateNumericStateCondition(condition: NumericStateCondition) { return ( condition.entity != null && @@ -366,6 +410,8 @@ export function validateConditionalConfig( return conditions.every((c) => { if ("condition" in c) { switch (c.condition) { + case "view_columns": + return validateViewColumnsCondition(c); case "screen": return validateScreenCondition(c); case "time": diff --git a/src/panels/lovelace/components/hui-conditional-base.ts b/src/panels/lovelace/components/hui-conditional-base.ts index 15f75c4d4a..ecbb153ad4 100644 --- a/src/panels/lovelace/components/hui-conditional-base.ts +++ b/src/panels/lovelace/components/hui-conditional-base.ts @@ -102,7 +102,12 @@ export class HuiConditionalBase extends ConditionalListenerMixin< this._element.preview = this.preview; const conditionMet = - conditionsMet ?? checkConditionsMet(this._config.conditions, this.hass); + conditionsMet ?? + checkConditionsMet( + this._config.conditions, + this.hass, + this._conditionContext + ); this.setVisibility(conditionMet); } diff --git a/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts b/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts index f603fb1437..242a4c68d2 100644 --- a/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts +++ b/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts @@ -297,7 +297,7 @@ export class HaCardConditionEditor extends LitElement { return; } - this._testingResult = checkConditionsMet([condition], this.hass); + this._testingResult = checkConditionsMet([condition], this.hass, {}); this._timeout = window.setTimeout(() => { this._testingResult = undefined; diff --git a/src/panels/lovelace/elements/hui-conditional-element.ts b/src/panels/lovelace/elements/hui-conditional-element.ts index 2b8a7b4d04..6412a046f7 100644 --- a/src/panels/lovelace/elements/hui-conditional-element.ts +++ b/src/panels/lovelace/elements/hui-conditional-element.ts @@ -66,7 +66,7 @@ class HuiConditionalElement extends HTMLElement implements LovelaceElement { return; } - const visible = checkConditionsMet(this._config.conditions, this._hass); + const visible = checkConditionsMet(this._config.conditions, this._hass, {}); this._elements.forEach((el: LovelaceElement) => { if (visible) { diff --git a/src/panels/lovelace/heading-badges/hui-heading-badge.ts b/src/panels/lovelace/heading-badges/hui-heading-badge.ts index fc9228a42d..6762b0ada8 100644 --- a/src/panels/lovelace/heading-badges/hui-heading-badge.ts +++ b/src/panels/lovelace/heading-badges/hui-heading-badge.ts @@ -155,7 +155,11 @@ export class HuiHeadingBadge extends ConditionalListenerMixin( const visible = conditionsMet ?? (!this._config.visibility || - checkConditionsMet(this._config.visibility, this.hass)); + checkConditionsMet( + this._config.visibility, + this.hass, + this._conditionContext + )); if (!visible) { this._setElementVisibility(false); diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index d3f5e4e172..3e64629f26 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -1,4 +1,5 @@ import { ResizeController } from "@lit-labs/observers/resize-controller"; +import { ContextProvider } from "@lit/context"; import { mdiEyeOff, mdiViewGridPlus } from "@mdi/js"; import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; @@ -12,6 +13,7 @@ import "../../../components/ha-icon-button"; import "../../../components/ha-ripple"; import "../../../components/ha-sortable"; import "../../../components/ha-svg-icon"; +import { maxColumnsContext } from "../common/context"; import type { LovelaceViewElement } from "../../../data/lovelace"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; @@ -62,6 +64,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement { @state() private _sectionColumnCount = 0; + private _maxColumns = 0; + + private _maxColumnsProvider = new ContextProvider(this, { + context: maxColumnsContext, + }); + @state() _dragging = false; @state() private _sidebarTabActive = false; @@ -143,6 +151,16 @@ export class SectionsView extends LitElement implements LovelaceViewElement { if (changedProperties.has("sections")) { this._computeSectionsCount(); } + this._updateMaxColumnCount(); + } + + private _updateMaxColumnCount(): void { + const maxColumnCount = this._columnsController.value ?? 1; + + if (maxColumnCount !== this._maxColumns) { + this._maxColumns = maxColumnCount; + this._maxColumnsProvider.setValue(maxColumnCount); + } } protected render() { @@ -156,10 +174,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement { const totalSectionCount = this._sectionColumnCount + (editMode ? 1 : 0) + (hasSidebar ? 1 : 0); - const maxColumnCount = this._columnsController.value ?? 1; - const columnCount = Math.max( - Math.min(maxColumnCount, totalSectionCount), + Math.min(this._maxColumns, totalSectionCount), 1 ); // On mobile with sidebar, use full width for whichever view is active diff --git a/src/panels/lovelace/views/hui-view-sidebar.ts b/src/panels/lovelace/views/hui-view-sidebar.ts index f40084df4a..aab3b40e8c 100644 --- a/src/panels/lovelace/views/hui-view-sidebar.ts +++ b/src/panels/lovelace/views/hui-view-sidebar.ts @@ -40,7 +40,11 @@ export class HuiViewSidebar extends ConditionalListenerMixin { const conditions = [ { condition: "state", entity: "sensor.test", state: "on" }, ] as any; - expect(checkConditionsMet(conditions, hass)).toBe(true); + expect(checkConditionsMet(conditions, hass, {})).toBe(true); }); it("should return false when state does not match", () => { @@ -55,7 +55,7 @@ describe("checkConditionsMet", () => { const conditions = [ { condition: "state", entity: "sensor.test", state: "on" }, ] as any; - expect(checkConditionsMet(conditions, hass)).toBe(false); + expect(checkConditionsMet(conditions, hass, {})).toBe(false); }); it("should return false for condition without state or state_not", () => { @@ -63,7 +63,7 @@ describe("checkConditionsMet", () => { "sensor.test": { state: "on" }, }); const conditions = [{ condition: "state", entity: "sensor.test" }] as any; - expect(checkConditionsMet(conditions, hass)).toBe(false); + expect(checkConditionsMet(conditions, hass, {})).toBe(false); }); it("should not crash with invalid condition type", () => { @@ -74,8 +74,8 @@ describe("checkConditionsMet", () => { { condition: "numeric", entity: "sensor.test", above: 0 }, ] as any; // Should not throw - this was the bug - expect(() => checkConditionsMet(conditions, hass)).not.toThrow(); - expect(checkConditionsMet(conditions, hass)).toBe(false); + expect(() => checkConditionsMet(conditions, hass, {})).not.toThrow(); + expect(checkConditionsMet(conditions, hass, {})).toBe(false); }); }); @@ -87,7 +87,7 @@ describe("checkConditionsMet", () => { const conditions = [ { condition: "numeric_state", entity: "sensor.test", above: 0 }, ] as any; - expect(checkConditionsMet(conditions, hass)).toBe(true); + expect(checkConditionsMet(conditions, hass, {})).toBe(true); }); }); @@ -97,7 +97,7 @@ describe("checkConditionsMet", () => { "sensor.test": { state: "on" }, }); const conditions = [{ entity: "sensor.test", state: "on" }] as any; - expect(checkConditionsMet(conditions, hass)).toBe(true); + expect(checkConditionsMet(conditions, hass, {})).toBe(true); }); it("should return false for legacy condition without state", () => { @@ -105,7 +105,7 @@ describe("checkConditionsMet", () => { "sensor.test": { state: "on" }, }); const conditions = [{ entity: "sensor.test" }] as any; - expect(checkConditionsMet(conditions, hass)).toBe(false); + expect(checkConditionsMet(conditions, hass, {})).toBe(false); }); }); });