Add reponsive editor with breakpoints

This commit is contained in:
Paul Bottein 2023-09-28 16:24:41 +02:00
parent 15522d4926
commit 89b53a76f0
No known key found for this signature in database
6 changed files with 251 additions and 14 deletions

View File

@ -0,0 +1,9 @@
export function getAllCombinations<T>(arr: T[]) {
return arr.reduce<T[][]>(
(combinations, element) =>
combinations.concat(
combinations.map((combination) => [...combination, element])
),
[[]]
);
}

View File

@ -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`
<div class="header">
<ha-icon-button
@click=${this._toggleMode}
.disabled=${!supported}
.disabled=${!supported || !valid}
.label=${this.hass!.localize(
yamlMode
? "ui.panel.lovelace.editor.edit_card.show_visual_editor"
@ -44,6 +51,13 @@ export default class HaCardConditionEditor extends LitElement {
>
</ha-icon-button>
</div>
${!valid
? html`
<ha-alert alert-type="warning">
${this.hass.localize("ui.errors.config.editor_not_supported")}
</ha-alert>
`
: nothing}
<div class="content">
${yamlMode
? html`

View File

@ -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<BreakpointSize>((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`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._schema(this.hass.localize)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
></ha-form>
`;
}
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<ReturnType<typeof this._schema>>
): 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;
}
}

View File

@ -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 ?? "",
};

View File

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

View File

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