1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-01 16:17:21 +01:00

Add view columns visibility condition (#51288)

* Add view columns visibility condition

* Use max column, not column count

* Rename

* Remove editor
This commit is contained in:
Paul Bottein
2026-04-01 10:11:53 +02:00
committed by GitHub
parent 2911cc77fa
commit 74f7139a09
19 changed files with 195 additions and 60 deletions

View File

@@ -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);
}

View File

@@ -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<T> = 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
);
}
}

View File

@@ -160,7 +160,11 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass));
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
this._setElementVisibility(visible);
}

View File

@@ -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;

View File

@@ -257,7 +257,11 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass));
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
this._setElementVisibility(visible);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import { createContext } from "@lit/context";
export const maxColumnsContext = createContext<number>("lovelace-max-columns");

View File

@@ -8,10 +8,12 @@ import {
mdiNumeric,
mdiResponsive,
mdiStateMachine,
mdiViewColumnOutline,
} from "@mdi/js";
import type { Condition } from "./validate-condition";
export const ICON_CONDITION: Record<Condition["condition"], string> = {
view_columns: mdiViewColumnOutline,
location: mdiMapMarker,
numeric_state: mdiNumeric,
state: mdiStateMachine,

View File

@@ -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":

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -155,7 +155,11 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass));
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
this._setElementVisibility(visible);
}

View File

@@ -234,7 +234,11 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
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);

View File

@@ -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

View File

@@ -40,7 +40,11 @@ export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebar
const visible =
conditionsMet ??
(!this.config.visibility ||
checkConditionsMet(this.config.visibility, this.hass));
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
if (visible !== this._visible) {
this._visible = visible;

View File

@@ -8867,6 +8867,12 @@
"invalid_config_title": "Invalid configuration",
"invalid_config_text": "The condition cannot be tested because the configuration is not valid.",
"condition": {
"view_columns": {
"label": "Number of columns",
"min": "Minimum",
"max": "Maximum",
"unit": "columns"
},
"numeric_state": {
"label": "Entity numeric state",
"above": "Above",

View File

@@ -45,7 +45,7 @@ describe("checkConditionsMet", () => {
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);
});
});
});