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 { 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`
|
||||
|
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 = {
|
||||
...content,
|
||||
entity: this.condition.entity ?? "",
|
||||
invert: this.condition.state_not ? "true" : "false",
|
||||
state: this.condition.state_not ?? this.condition.state ?? "",
|
||||
};
|
||||
|
@ -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(
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user