mirror of
https://github.com/home-assistant/frontend.git
synced 2025-08-02 14:07:55 +00:00
Add reponsive editor with breakpoints
This commit is contained in:
parent
15522d4926
commit
89b53a76f0
9
src/common/array/combinations.ts
Normal file
9
src/common/array/combinations.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function getAllCombinations<T>(arr: T[]) {
|
||||||
|
return arr.reduce<T[][]>(
|
||||||
|
(combinations, element) =>
|
||||||
|
combinations.concat(
|
||||||
|
combinations.map((combination) => [...combination, element])
|
||||||
|
),
|
||||||
|
[[]]
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { mdiCodeBraces, mdiDelete, mdiListBoxOutline } from "@mdi/js";
|
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 { customElement, property, state } from "lit/decorators";
|
||||||
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
|
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
|
||||||
import { fireEvent } from "../../../common/dom/fire_event";
|
import { fireEvent } from "../../../common/dom/fire_event";
|
||||||
@ -7,6 +7,7 @@ import "../../../components/ha-icon-button";
|
|||||||
import "../../../components/ha-yaml-editor";
|
import "../../../components/ha-yaml-editor";
|
||||||
import { haStyle } from "../../../resources/styles";
|
import { haStyle } from "../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../types";
|
import type { HomeAssistant } from "../../../types";
|
||||||
|
import "./types/ha-card-condition-responsive";
|
||||||
import "./types/ha-card-condition-state";
|
import "./types/ha-card-condition-state";
|
||||||
import { Condition } from "./validate-condition";
|
import { Condition } from "./validate-condition";
|
||||||
|
|
||||||
@ -20,16 +21,22 @@ export default class HaCardConditionEditor extends LitElement {
|
|||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
const condition = this.condition;
|
const condition = this.condition;
|
||||||
const supported =
|
const element = customElements.get(
|
||||||
customElements.get(`ha-card-condition-${condition.condition}`) !==
|
`ha-card-condition-${condition.condition}`
|
||||||
undefined;
|
) as any | undefined;
|
||||||
const yamlMode = this._yamlMode || !supported;
|
const supported = element !== undefined;
|
||||||
|
|
||||||
|
const valid =
|
||||||
|
element &&
|
||||||
|
(!element.validateUIConfig || element.validateUIConfig(this.condition));
|
||||||
|
|
||||||
|
const yamlMode = this._yamlMode || !supported || !valid;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
@click=${this._toggleMode}
|
@click=${this._toggleMode}
|
||||||
.disabled=${!supported}
|
.disabled=${!supported || !valid}
|
||||||
.label=${this.hass!.localize(
|
.label=${this.hass!.localize(
|
||||||
yamlMode
|
yamlMode
|
||||||
? "ui.panel.lovelace.editor.edit_card.show_visual_editor"
|
? "ui.panel.lovelace.editor.edit_card.show_visual_editor"
|
||||||
@ -44,6 +51,13 @@ export default class HaCardConditionEditor extends LitElement {
|
|||||||
>
|
>
|
||||||
</ha-icon-button>
|
</ha-icon-button>
|
||||||
</div>
|
</div>
|
||||||
|
${!valid
|
||||||
|
? html`
|
||||||
|
<ha-alert alert-type="warning">
|
||||||
|
${this.hass.localize("ui.errors.config.editor_not_supported")}
|
||||||
|
</ha-alert>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
${yamlMode
|
${yamlMode
|
||||||
? html`
|
? html`
|
||||||
|
196
src/panels/lovelace/common/types/ha-card-condition-responsive.ts
Normal file
196
src/panels/lovelace/common/types/ha-card-condition-responsive.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -96,6 +96,7 @@ export class HaCardConditionState extends LitElement {
|
|||||||
|
|
||||||
const data: StateConditionData = {
|
const data: StateConditionData = {
|
||||||
...content,
|
...content,
|
||||||
|
entity: this.condition.entity ?? "",
|
||||||
invert: this.condition.state_not ? "true" : "false",
|
invert: this.condition.state_not ? "true" : "false",
|
||||||
state: this.condition.state_not ?? this.condition.state ?? "",
|
state: this.condition.state_not ?? this.condition.state ?? "",
|
||||||
};
|
};
|
||||||
|
@ -5,7 +5,7 @@ export type Condition = StateCondition | ResponsiveCondition;
|
|||||||
|
|
||||||
export type StateCondition = {
|
export type StateCondition = {
|
||||||
condition: "state";
|
condition: "state";
|
||||||
entity: string;
|
entity?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
state_not?: string;
|
state_not?: string;
|
||||||
};
|
};
|
||||||
@ -16,9 +16,10 @@ export type ResponsiveCondition = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function checkStateCondition(condition: StateCondition, hass: HomeAssistant) {
|
function checkStateCondition(condition: StateCondition, hass: HomeAssistant) {
|
||||||
const state = hass.states[condition.entity]
|
const state =
|
||||||
? hass.states[condition.entity].state
|
condition.entity && hass.states[condition.entity]
|
||||||
: UNAVAILABLE;
|
? hass.states[condition.entity].state
|
||||||
|
: UNAVAILABLE;
|
||||||
|
|
||||||
return condition.state != null
|
return condition.state != null
|
||||||
? state === condition.state
|
? state === condition.state
|
||||||
@ -29,7 +30,9 @@ function checkResponsiveCondition(
|
|||||||
condition: ResponsiveCondition,
|
condition: ResponsiveCondition,
|
||||||
_hass: HomeAssistant
|
_hass: HomeAssistant
|
||||||
) {
|
) {
|
||||||
return matchMedia(condition.media_query ?? "").matches;
|
return condition.media_query
|
||||||
|
? matchMedia(condition.media_query).matches
|
||||||
|
: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkConditionsMet(
|
export function checkConditionsMet(
|
||||||
|
@ -4767,10 +4767,24 @@
|
|||||||
"state_equal": "State is equal to",
|
"state_equal": "State is equal to",
|
||||||
"state_not_equal": "State is not equal to",
|
"state_not_equal": "State is not equal to",
|
||||||
"current_state": "current",
|
"current_state": "current",
|
||||||
"min_width": "Min width",
|
|
||||||
"max_width": "Max width",
|
|
||||||
"condition_explanation": "The card will be shown when ALL conditions below are fulfilled.",
|
"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": {
|
"config": {
|
||||||
"required": "required",
|
"required": "required",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user