From 89b53a76f006a0039fab53df31d41dd96672b658 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 28 Sep 2023 16:24:41 +0200 Subject: [PATCH] Add reponsive editor with breakpoints --- src/common/array/combinations.ts | 9 + .../common/ha-card-condition-editor.ts | 26 ++- .../types/ha-card-condition-responsive.ts | 196 ++++++++++++++++++ .../common/types/ha-card-condition-state.ts | 1 + .../lovelace/common/validate-condition.ts | 13 +- src/translations/en.json | 20 +- 6 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 src/common/array/combinations.ts create mode 100644 src/panels/lovelace/common/types/ha-card-condition-responsive.ts diff --git a/src/common/array/combinations.ts b/src/common/array/combinations.ts new file mode 100644 index 0000000000..adb6e6476d --- /dev/null +++ b/src/common/array/combinations.ts @@ -0,0 +1,9 @@ +export function getAllCombinations(arr: T[]) { + return arr.reduce( + (combinations, element) => + combinations.concat( + combinations.map((combination) => [...combination, element]) + ), + [[]] + ); +} diff --git a/src/panels/lovelace/common/ha-card-condition-editor.ts b/src/panels/lovelace/common/ha-card-condition-editor.ts index 7209868ed4..4f6151f3ad 100644 --- a/src/panels/lovelace/common/ha-card-condition-editor.ts +++ b/src/panels/lovelace/common/ha-card-condition-editor.ts @@ -1,5 +1,5 @@ import { mdiCodeBraces, mdiDelete, mdiListBoxOutline } from "@mdi/js"; -import { LitElement, css, html } from "lit"; +import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { dynamicElement } from "../../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../../common/dom/fire_event"; @@ -7,6 +7,7 @@ import "../../../components/ha-icon-button"; import "../../../components/ha-yaml-editor"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; +import "./types/ha-card-condition-responsive"; import "./types/ha-card-condition-state"; import { Condition } from "./validate-condition"; @@ -20,16 +21,22 @@ export default class HaCardConditionEditor extends LitElement { protected render() { const condition = this.condition; - const supported = - customElements.get(`ha-card-condition-${condition.condition}`) !== - undefined; - const yamlMode = this._yamlMode || !supported; + const element = customElements.get( + `ha-card-condition-${condition.condition}` + ) as any | undefined; + const supported = element !== undefined; + + const valid = + element && + (!element.validateUIConfig || element.validateUIConfig(this.condition)); + + const yamlMode = this._yamlMode || !supported || !valid; return html`
+ ${!valid + ? html` + + ${this.hass.localize("ui.errors.config.editor_not_supported")} + + ` + : nothing}
${yamlMode ? html` diff --git a/src/panels/lovelace/common/types/ha-card-condition-responsive.ts b/src/panels/lovelace/common/types/ha-card-condition-responsive.ts new file mode 100644 index 0000000000..d43c77913f --- /dev/null +++ b/src/panels/lovelace/common/types/ha-card-condition-responsive.ts @@ -0,0 +1,196 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { getAllCombinations } from "../../../../common/array/combinations"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../../components/ha-form/types"; +import { HaFormSchema } from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import { ResponsiveCondition } from "../validate-condition"; + +const BREAKPOINT_VALUES = [0, 768, 1024, 1280, Infinity]; +const BREAKPOINTS = ["mobile", "tablet", "desktop", "wide"] as const; + +type BreakpointSize = [number, number]; +type Breakpoint = (typeof BREAKPOINTS)[number]; + +function mergeConsecutiveRanges(arr: [number, number][]): [number, number][] { + if (arr.length === 0) { + return []; + } + + [...arr].sort((a, b) => a[0] - b[0]); + + const mergedRanges = [arr[0]]; + + for (let i = 1; i < arr.length; i++) { + const currentRange = arr[i]; + const previousRange = mergedRanges[mergedRanges.length - 1]; + + if (currentRange[0] <= previousRange[1] + 1) { + previousRange[1] = currentRange[1]; + } else { + mergedRanges.push(currentRange); + } + } + + return mergedRanges; +} + +function buildMediaQuery(size: BreakpointSize) { + const [min, max] = size; + const query: string[] = []; + if (min != null) { + query.push(`(min-width: ${min}px)`); + } + if (max != null && max !== Infinity) { + query.push(`(max-width: ${max - 1}px)`); + } + return query.join(" and "); +} + +function computeBreakpointsSize(breakpoints: Breakpoint[]) { + const sizes = breakpoints.map((breakpoint) => { + const index = BREAKPOINTS.indexOf(breakpoint); + return [BREAKPOINT_VALUES[index], BREAKPOINT_VALUES[index + 1] || Infinity]; + }); + + const mergedSizes = mergeConsecutiveRanges(sizes); + + const queries = mergedSizes + .map((size) => buildMediaQuery(size)) + .filter((size) => size); + + return queries.join(", "); +} + +function computeBreakpointsKey(breakpoints) { + return [...breakpoints].sort().join("_"); +} + +// Compute all possible media queries from each breakpoints combination (2 ^ breakpoints = 16) +const queries = getAllCombinations(BREAKPOINTS as unknown as Breakpoint[]) + .filter((arr) => arr.length !== 0) + .map( + (breakpoints) => + [breakpoints, computeBreakpointsSize(breakpoints)] as [ + Breakpoint[], + string, + ] + ); + +// Store them in maps to avoid recomputing them +const mediaQueryMap = new Map( + queries.map(([b, m]) => [computeBreakpointsKey(b), m]) +); +const mediaQueryReverseMap = new Map(queries.map(([b, m]) => [m, b])); + +type ResponsiveConditionData = { + breakpoints: Breakpoint[]; +}; + +@customElement("ha-card-condition-responsive") +export class HaCardConditionResponsive extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public condition!: ResponsiveCondition; + + @property({ type: Boolean }) public disabled = false; + + public static get defaultConfig(): ResponsiveCondition { + return { condition: "responsive", media_query: "" }; + } + + protected static validateUIConfig(condition: ResponsiveCondition) { + return ( + !condition.media_query || mediaQueryReverseMap.get(condition.media_query) + ); + } + + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "breakpoints", + selector: { + select: { + mode: "list", + options: BREAKPOINTS.map((b) => { + const value = BREAKPOINT_VALUES[BREAKPOINTS.indexOf(b)]; + return { + value: b, + label: `${localize( + `ui.panel.lovelace.editor.card.conditional.types.responsive.breakpoints_list.${b}` + )}${ + value + ? ` (${localize( + `ui.panel.lovelace.editor.card.conditional.types.responsive.min`, + { size: value } + )})` + : "" + }`, + }; + }), + multiple: true, + }, + }, + }, + ] as const satisfies readonly HaFormSchema[] + ); + + protected render() { + const breakpoints = this.condition.media_query + ? mediaQueryReverseMap.get(this.condition.media_query) + : undefined; + + const data: ResponsiveConditionData = { + breakpoints: breakpoints ?? [], + }; + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const data = ev.detail.value as ResponsiveConditionData; + + const { breakpoints } = data; + + const condition: ResponsiveCondition = { + condition: "responsive", + media_query: mediaQueryMap.get(computeBreakpointsKey(breakpoints)) ?? "", + }; + + fireEvent(this, "value-changed", { value: condition }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ): string => { + switch (schema.name) { + case "breakpoints": + return this.hass.localize( + `ui.panel.lovelace.editor.card.conditional.types.responsive.${schema.name}` + ); + default: + return ""; + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-card-condition-responsove": HaCardConditionResponsive; + } +} diff --git a/src/panels/lovelace/common/types/ha-card-condition-state.ts b/src/panels/lovelace/common/types/ha-card-condition-state.ts index f728a54ff8..87ac4ab768 100644 --- a/src/panels/lovelace/common/types/ha-card-condition-state.ts +++ b/src/panels/lovelace/common/types/ha-card-condition-state.ts @@ -96,6 +96,7 @@ export class HaCardConditionState extends LitElement { const data: StateConditionData = { ...content, + entity: this.condition.entity ?? "", invert: this.condition.state_not ? "true" : "false", state: this.condition.state_not ?? this.condition.state ?? "", }; diff --git a/src/panels/lovelace/common/validate-condition.ts b/src/panels/lovelace/common/validate-condition.ts index 87e179260a..8ecc85ac25 100644 --- a/src/panels/lovelace/common/validate-condition.ts +++ b/src/panels/lovelace/common/validate-condition.ts @@ -5,7 +5,7 @@ export type Condition = StateCondition | ResponsiveCondition; export type StateCondition = { condition: "state"; - entity: string; + entity?: string; state?: string; state_not?: string; }; @@ -16,9 +16,10 @@ export type ResponsiveCondition = { }; function checkStateCondition(condition: StateCondition, hass: HomeAssistant) { - const state = hass.states[condition.entity] - ? hass.states[condition.entity].state - : UNAVAILABLE; + const state = + condition.entity && hass.states[condition.entity] + ? hass.states[condition.entity].state + : UNAVAILABLE; return condition.state != null ? state === condition.state @@ -29,7 +30,9 @@ function checkResponsiveCondition( condition: ResponsiveCondition, _hass: HomeAssistant ) { - return matchMedia(condition.media_query ?? "").matches; + return condition.media_query + ? matchMedia(condition.media_query).matches + : false; } export function checkConditionsMet( diff --git a/src/translations/en.json b/src/translations/en.json index aadbf74386..3bf1724e9c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4767,10 +4767,24 @@ "state_equal": "State is equal to", "state_not_equal": "State is not equal to", "current_state": "current", - "min_width": "Min width", - "max_width": "Max width", "condition_explanation": "The card will be shown when ALL conditions below are fulfilled.", - "change_type": "Change type" + "change_type": "Change type", + "types": { + "responsive": { + "label": "Responsive", + "breakpoints": "Screen sizes", + "breakpoints_list": { + "mobile": "Mobile", + "tablet": "Tablet", + "desktop": "Desktop", + "wide": "Wide" + }, + "min": "min: {size}px" + }, + "state": { + "label": "Entity state" + } + } }, "config": { "required": "required",