mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-27 19:26:36 +00:00
Add screen condition to conditional card. (#18041)
This commit is contained in:
parent
5a6d6dc7d3
commit
86c014b677
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])
|
||||
),
|
||||
[[]]
|
||||
);
|
||||
}
|
7
src/panels/lovelace/common/icon-condition.ts
Normal file
7
src/panels/lovelace/common/icon-condition.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { mdiResponsive, mdiStateMachine } from "@mdi/js";
|
||||
import { Condition } from "./validate-condition";
|
||||
|
||||
export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
state: mdiStateMachine,
|
||||
screen: mdiResponsive,
|
||||
};
|
@ -1,10 +1,44 @@
|
||||
import { UNAVAILABLE } from "../../../data/entity";
|
||||
import { HomeAssistant } from "../../../types";
|
||||
|
||||
export interface Condition {
|
||||
entity: string;
|
||||
export type Condition = StateCondition | ScreenCondition;
|
||||
|
||||
export type LegacyCondition = {
|
||||
entity?: string;
|
||||
state?: string;
|
||||
state_not?: string;
|
||||
};
|
||||
|
||||
export type StateCondition = {
|
||||
condition: "state";
|
||||
entity?: string;
|
||||
state?: string;
|
||||
state_not?: string;
|
||||
};
|
||||
|
||||
export type ScreenCondition = {
|
||||
condition: "screen";
|
||||
media_query?: string;
|
||||
};
|
||||
|
||||
function checkStateCondition(condition: StateCondition, hass: HomeAssistant) {
|
||||
const state =
|
||||
condition.entity && hass.states[condition.entity]
|
||||
? hass.states[condition.entity].state
|
||||
: UNAVAILABLE;
|
||||
|
||||
return condition.state != null
|
||||
? state === condition.state
|
||||
: state !== condition.state_not;
|
||||
}
|
||||
|
||||
function checkScreenCondition(
|
||||
condition: ScreenCondition,
|
||||
_hass: HomeAssistant
|
||||
) {
|
||||
return condition.media_query
|
||||
? matchMedia(condition.media_query).matches
|
||||
: false;
|
||||
}
|
||||
|
||||
export function checkConditionsMet(
|
||||
@ -12,18 +46,30 @@ export function checkConditionsMet(
|
||||
hass: HomeAssistant
|
||||
): boolean {
|
||||
return conditions.every((c) => {
|
||||
const state = hass.states[c.entity]
|
||||
? hass!.states[c.entity].state
|
||||
: UNAVAILABLE;
|
||||
if (c.condition === "screen") {
|
||||
return checkScreenCondition(c, hass);
|
||||
}
|
||||
|
||||
return c.state != null ? state === c.state : state !== c.state_not;
|
||||
return checkStateCondition(c, hass);
|
||||
});
|
||||
}
|
||||
|
||||
export function validateConditionalConfig(conditions: Condition[]): boolean {
|
||||
return conditions.every(
|
||||
(c) =>
|
||||
(c.entity &&
|
||||
(c.state != null || c.state_not != null)) as unknown as boolean
|
||||
function valideStateCondition(condition: StateCondition) {
|
||||
return (
|
||||
condition.entity != null &&
|
||||
(condition.state != null || condition.state_not != null)
|
||||
);
|
||||
}
|
||||
|
||||
function validateScreenCondition(condition: ScreenCondition) {
|
||||
return condition.media_query != null;
|
||||
}
|
||||
|
||||
export function validateConditionalConfig(conditions: Condition[]): boolean {
|
||||
return conditions.every((c) => {
|
||||
if (c.condition === "screen") {
|
||||
return validateScreenCondition(c);
|
||||
}
|
||||
return valideStateCondition(c);
|
||||
});
|
||||
}
|
||||
|
@ -3,11 +3,14 @@ import { customElement, property } from "lit/decorators";
|
||||
import { HomeAssistant } from "../../../types";
|
||||
import { ConditionalCardConfig } from "../cards/types";
|
||||
import {
|
||||
ScreenCondition,
|
||||
checkConditionsMet,
|
||||
validateConditionalConfig,
|
||||
} from "../common/validate-condition";
|
||||
import { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
|
||||
import { LovelaceCard } from "../types";
|
||||
import { listenMediaQuery } from "../../../common/dom/media_query";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
|
||||
@customElement("hui-conditional-base")
|
||||
export class HuiConditionalBase extends ReactiveElement {
|
||||
@ -21,6 +24,10 @@ export class HuiConditionalBase extends ReactiveElement {
|
||||
|
||||
protected _element?: LovelaceCard | LovelaceRow;
|
||||
|
||||
private _mediaQueriesListeners: Array<() => void> = [];
|
||||
|
||||
private _mediaQueries: string[] = [];
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@ -47,27 +54,98 @@ export class HuiConditionalBase extends ReactiveElement {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._clearMediaQueries();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._listenMediaQueries();
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
private _clearMediaQueries() {
|
||||
this._mediaQueries = [];
|
||||
while (this._mediaQueriesListeners.length) {
|
||||
this._mediaQueriesListeners.pop()!();
|
||||
}
|
||||
}
|
||||
|
||||
private _listenMediaQueries() {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conditions = this._config.conditions.filter(
|
||||
(c) => c.condition === "screen"
|
||||
) as ScreenCondition[];
|
||||
|
||||
const mediaQueries = conditions
|
||||
.filter((c) => c.media_query)
|
||||
.map((c) => c.media_query as string);
|
||||
|
||||
if (deepEqual(mediaQueries, this._mediaQueries)) return;
|
||||
|
||||
this._mediaQueries = mediaQueries;
|
||||
while (this._mediaQueriesListeners.length) {
|
||||
this._mediaQueriesListeners.pop()!();
|
||||
}
|
||||
mediaQueries.forEach((query) => {
|
||||
const listener = listenMediaQuery(query, (matches) => {
|
||||
// For performance, if there is only one condition, set the visibility directly
|
||||
if (this._config!.conditions.length === 1) {
|
||||
this._setVisibility(matches);
|
||||
return;
|
||||
}
|
||||
this._updateVisibility();
|
||||
});
|
||||
this._mediaQueriesListeners.push(listener);
|
||||
});
|
||||
}
|
||||
|
||||
protected update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
if (
|
||||
changed.has("_element") ||
|
||||
changed.has("_config") ||
|
||||
changed.has("hass")
|
||||
) {
|
||||
this._listenMediaQueries();
|
||||
this._updateVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
private _updateVisibility() {
|
||||
if (!this._element || !this.hass || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._element.editMode = this.editMode;
|
||||
|
||||
const visible =
|
||||
this.editMode || checkConditionsMet(this._config.conditions, this.hass);
|
||||
this.hidden = !visible;
|
||||
const conditionMet = checkConditionsMet(
|
||||
this._config!.conditions,
|
||||
this.hass!
|
||||
);
|
||||
this._setVisibility(conditionMet);
|
||||
}
|
||||
|
||||
private _setVisibility(conditionMet: boolean) {
|
||||
if (!this._element || !this.hass) {
|
||||
return;
|
||||
}
|
||||
const visible = this.editMode || conditionMet;
|
||||
this.hidden = !visible;
|
||||
this.style.setProperty("display", visible ? "" : "none");
|
||||
|
||||
if (visible) {
|
||||
this._element.hass = this.hass;
|
||||
if (!this._element.parentElement) {
|
||||
this.appendChild(this._element);
|
||||
if (!this._element!.parentElement) {
|
||||
this.appendChild(this._element!);
|
||||
}
|
||||
} else if (this._element.parentElement) {
|
||||
this.removeChild(this._element);
|
||||
this.removeChild(this._element!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,197 @@
|
||||
import { preventDefault } from "@fullcalendar/core/internal";
|
||||
import { ActionDetail } from "@material/mwc-list";
|
||||
import { mdiCheck, mdiDelete, mdiDotsVertical } from "@mdi/js";
|
||||
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";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/ha-button-menu";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { Condition, LegacyCondition } from "../../common/validate-condition";
|
||||
import type { LovelaceConditionEditorConstructor } from "./types";
|
||||
import { ICON_CONDITION } from "../../common/icon-condition";
|
||||
|
||||
@customElement("ha-card-condition-editor")
|
||||
export default class HaCardConditionEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) condition!: Condition | LegacyCondition;
|
||||
|
||||
@state() public _yamlMode = false;
|
||||
|
||||
protected render() {
|
||||
const condition: Condition = {
|
||||
condition: "state",
|
||||
...this.condition,
|
||||
};
|
||||
const element = customElements.get(
|
||||
`ha-card-condition-${condition.condition}`
|
||||
) as LovelaceConditionEditorConstructor | undefined;
|
||||
const supported = element !== undefined;
|
||||
|
||||
const valid =
|
||||
element &&
|
||||
(!element.validateUIConfig || element.validateUIConfig(condition));
|
||||
|
||||
const yamlMode = this._yamlMode || !supported || !valid;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<ha-svg-icon
|
||||
class="icon"
|
||||
.path=${ICON_CONDITION[condition.condition]}
|
||||
></ha-svg-icon>
|
||||
<span class="title">
|
||||
${this.hass.localize(
|
||||
`ui.panel.lovelace.editor.card.conditional.condition.${condition.condition}.label`
|
||||
) || condition.condition}
|
||||
</span>
|
||||
<ha-button-menu
|
||||
slot="icons"
|
||||
@action=${this._handleAction}
|
||||
@click=${preventDefault}
|
||||
@closed=${stopPropagation}
|
||||
fixed
|
||||
.corner=${"BOTTOM_END"}
|
||||
.menuCorner=${"END"}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
>
|
||||
</ha-icon-button>
|
||||
|
||||
<ha-list-item graphic="icon" .disabled=${!supported || !valid}>
|
||||
${this.hass.localize("ui.panel.lovelace.editor.edit_card.edit_ui")}
|
||||
${!yamlMode
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class="selected_menu_item"
|
||||
slot="graphic"
|
||||
.path=${mdiCheck}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: ``}
|
||||
</ha-list-item>
|
||||
|
||||
<ha-list-item graphic="icon">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_card.edit_yaml"
|
||||
)}
|
||||
${yamlMode
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class="selected_menu_item"
|
||||
slot="graphic"
|
||||
.path=${mdiCheck}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: ``}
|
||||
</ha-list-item>
|
||||
|
||||
<li divider role="separator"></li>
|
||||
|
||||
<ha-list-item class="warning" graphic="icon">
|
||||
${this.hass!.localize("ui.common.delete")}
|
||||
<ha-svg-icon
|
||||
class="warning"
|
||||
slot="graphic"
|
||||
.path=${mdiDelete}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
</ha-button-menu>
|
||||
</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`
|
||||
<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
.defaultValue=${this.condition}
|
||||
@value-changed=${this._onYamlChange}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
${dynamicElement(`ha-card-condition-${condition.condition}`, {
|
||||
hass: this.hass,
|
||||
condition: condition,
|
||||
})}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: CustomEvent<ActionDetail>) {
|
||||
switch (ev.detail.index) {
|
||||
case 0:
|
||||
this._yamlMode = false;
|
||||
break;
|
||||
case 1:
|
||||
this._yamlMode = true;
|
||||
break;
|
||||
case 2:
|
||||
this._delete();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _delete() {
|
||||
fireEvent(this, "value-changed", { value: null });
|
||||
}
|
||||
|
||||
private _onYamlChange(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!ev.detail.isValid) {
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value });
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header span {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
}
|
||||
.content {
|
||||
padding: 12px;
|
||||
}
|
||||
.header .icon {
|
||||
padding: 12px;
|
||||
}
|
||||
.selected_menu_item {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
li[role="separator"] {
|
||||
border-bottom-color: var(--divider-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-editor": HaCardConditionEditor;
|
||||
}
|
||||
}
|
6
src/panels/lovelace/editor/conditions/types.ts
Normal file
6
src/panels/lovelace/editor/conditions/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Condition } from "../../common/validate-condition";
|
||||
|
||||
export interface LovelaceConditionEditorConstructor {
|
||||
defaultConfig?: Condition;
|
||||
validateUIConfig?: (condition: Condition) => boolean;
|
||||
}
|
@ -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 { ScreenCondition } from "../../../common/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 ScreenConditionData = {
|
||||
breakpoints: Breakpoint[];
|
||||
};
|
||||
|
||||
@customElement("ha-card-condition-screen")
|
||||
export class HaCardConditionScreen extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: ScreenCondition;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
public static get defaultConfig(): ScreenCondition {
|
||||
return { condition: "screen", media_query: "" };
|
||||
}
|
||||
|
||||
protected static validateUIConfig(condition: ScreenCondition) {
|
||||
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.condition.screen.breakpoints_list.${b}`
|
||||
)}${
|
||||
value
|
||||
? ` (${localize(
|
||||
`ui.panel.lovelace.editor.card.conditional.condition.screen.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: ScreenConditionData = {
|
||||
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 ScreenConditionData;
|
||||
|
||||
const { breakpoints } = data;
|
||||
|
||||
const condition: ScreenCondition = {
|
||||
condition: "screen",
|
||||
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.condition.screen.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-screen": HaCardConditionScreen;
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
import { html, LitElement, PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, literal, object, optional, string } from "superstruct";
|
||||
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 { StateCondition } from "../../../common/validate-condition";
|
||||
|
||||
const stateConditionStruct = object({
|
||||
condition: literal("state"),
|
||||
entity: string(),
|
||||
state: optional(string()),
|
||||
state_not: optional(string()),
|
||||
});
|
||||
|
||||
type StateConditionData = {
|
||||
condition: "state";
|
||||
entity: string;
|
||||
invert: "true" | "false";
|
||||
state?: string;
|
||||
};
|
||||
|
||||
@customElement("ha-card-condition-state")
|
||||
export class HaCardConditionState extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: StateCondition;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
public static get defaultConfig(): StateCondition {
|
||||
return { condition: "state", entity: "", state: "" };
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (!changedProperties.has("condition")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assert(this.condition, stateConditionStruct);
|
||||
} catch (err: any) {
|
||||
fireEvent(this, "ui-mode-not-available", err);
|
||||
}
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{ name: "entity", selector: { entity: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "invert",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: [
|
||||
{
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.conditional.state_equal"
|
||||
),
|
||||
value: "false",
|
||||
},
|
||||
{
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.conditional.state_not_equal"
|
||||
),
|
||||
value: "true",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "state",
|
||||
selector: {
|
||||
state: {},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const { state, state_not, ...content } = this.condition;
|
||||
|
||||
const data: StateConditionData = {
|
||||
...content,
|
||||
entity: this.condition.entity ?? "",
|
||||
invert: this.condition.state_not ? "true" : "false",
|
||||
state: this.condition.state_not ?? this.condition.state ?? "",
|
||||
};
|
||||
|
||||
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 StateConditionData;
|
||||
|
||||
const { invert, state, entity, condition: _, ...content } = data;
|
||||
|
||||
const condition: StateCondition = {
|
||||
condition: "state",
|
||||
...content,
|
||||
entity: entity ?? "",
|
||||
state: invert === "false" ? state ?? "" : undefined,
|
||||
state_not: invert === "true" ? state ?? "" : undefined,
|
||||
};
|
||||
|
||||
fireEvent(this, "value-changed", { value: condition });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
const entity = this.condition.entity
|
||||
? this.hass.states[this.condition.entity]
|
||||
: undefined;
|
||||
switch (schema.name) {
|
||||
case "entity":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "state":
|
||||
if (entity) {
|
||||
return `${this.hass.localize(
|
||||
"ui.components.entity.entity-state-picker.state"
|
||||
)} (${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.conditional.current_state"
|
||||
)}: ${this.hass.formatEntityState(entity)})`;
|
||||
}
|
||||
return `${this.hass.localize(
|
||||
"ui.components.entity.entity-state-picker.state"
|
||||
)}`;
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-state": HaCardConditionState;
|
||||
}
|
||||
}
|
@ -1,52 +1,56 @@
|
||||
import "@material/mwc-list/mwc-list-item";
|
||||
import "@material/mwc-tab-bar/mwc-tab-bar";
|
||||
import "@material/mwc-tab/mwc-tab";
|
||||
import { mdiCodeBraces, mdiContentCopy, mdiListBoxOutline } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { MDCTabBarActivatedEvent } from "@material/tab-bar";
|
||||
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
any,
|
||||
array,
|
||||
assert,
|
||||
assign,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
} from "superstruct";
|
||||
mdiCodeBraces,
|
||||
mdiContentCopy,
|
||||
mdiListBoxOutline,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { any, array, assert, assign, object, optional } from "superstruct";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { HASSDomEvent, fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../components/ha-select";
|
||||
import "../../../../components/ha-textfield";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-menu-button";
|
||||
import type { HaSelect } from "../../../../components/ha-select";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
LovelaceCardConfig,
|
||||
LovelaceConfig,
|
||||
} from "../../../../data/lovelace";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ConditionalCardConfig } from "../../cards/types";
|
||||
import { ICON_CONDITION } from "../../common/icon-condition";
|
||||
import { Condition } from "../../common/validate-condition";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import "../card-editor/hui-card-element-editor";
|
||||
import type { HuiCardElementEditor } from "../card-editor/hui-card-element-editor";
|
||||
import "../card-editor/hui-card-picker";
|
||||
import "../conditions/ha-card-condition-editor";
|
||||
import { LovelaceConditionEditorConstructor } from "../conditions/types";
|
||||
import "../conditions/types/ha-card-condition-screen";
|
||||
import "../conditions/types/ha-card-condition-state";
|
||||
import "../hui-element-editor";
|
||||
import type { ConfigChangedEvent } from "../hui-element-editor";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import type { GUIModeChangedEvent } from "../types";
|
||||
import { configElementStyle } from "./config-elements-style";
|
||||
|
||||
const conditionStruct = object({
|
||||
entity: string(),
|
||||
state: optional(string()),
|
||||
state_not: optional(string()),
|
||||
});
|
||||
const UI_CONDITION = [
|
||||
"state",
|
||||
"screen",
|
||||
] as const satisfies readonly Condition["condition"][];
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
card: any(),
|
||||
conditions: optional(array(conditionStruct)),
|
||||
conditions: optional(array(any())),
|
||||
})
|
||||
);
|
||||
|
||||
@ -127,7 +131,6 @@ export class HuiConditionalCardEditor
|
||||
)}
|
||||
.path=${isGuiMode ? mdiCodeBraces : mdiListBoxOutline}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-icon-button
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_card.copy"
|
||||
@ -166,61 +169,44 @@ export class HuiConditionalCardEditor
|
||||
${this._config.conditions.map(
|
||||
(cond, idx) => html`
|
||||
<div class="condition">
|
||||
<div class="entity">
|
||||
<ha-entity-picker
|
||||
<ha-card-condition-editor
|
||||
.index=${idx}
|
||||
@value-changed=${this._conditionChanged}
|
||||
.hass=${this.hass}
|
||||
.value=${cond.entity}
|
||||
.idx=${idx}
|
||||
.configValue=${"entity"}
|
||||
@change=${this._changeCondition}
|
||||
allow-custom-entity
|
||||
></ha-entity-picker>
|
||||
</div>
|
||||
<div class="state">
|
||||
<ha-select
|
||||
.value=${cond.state_not !== undefined
|
||||
? "true"
|
||||
: "false"}
|
||||
.idx=${idx}
|
||||
.configValue=${"invert"}
|
||||
@selected=${this._changeCondition}
|
||||
@closed=${stopPropagation}
|
||||
naturalMenuWidth
|
||||
fixedMenuPosition
|
||||
>
|
||||
<mwc-list-item value="false">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.conditional.state_equal"
|
||||
)}
|
||||
</mwc-list-item>
|
||||
<mwc-list-item value="true">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.conditional.state_not_equal"
|
||||
)}
|
||||
</mwc-list-item>
|
||||
</ha-select>
|
||||
<ha-textfield
|
||||
.label="${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.state"
|
||||
)} (${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.conditional.current_state"
|
||||
)}: ${this.hass?.states[cond.entity].state})"
|
||||
.value=${cond.state_not !== undefined
|
||||
? cond.state_not
|
||||
: cond.state}
|
||||
.idx=${idx}
|
||||
.configValue=${"state"}
|
||||
@input=${this._changeCondition}
|
||||
></ha-textfield>
|
||||
</div>
|
||||
.condition=${cond}
|
||||
></ha-card-condition-editor>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div class="condition">
|
||||
<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
@change=${this._addCondition}
|
||||
></ha-entity-picker>
|
||||
<div>
|
||||
<ha-button-menu
|
||||
@action=${this._addCondition}
|
||||
fixed
|
||||
@closed=${stopPropagation}
|
||||
>
|
||||
<ha-button
|
||||
slot="trigger"
|
||||
outlined
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.conditional.add_condition"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
|
||||
</ha-button>
|
||||
${UI_CONDITION.map(
|
||||
(condition) => html`
|
||||
<ha-list-item .value=${condition} graphic="icon">
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.conditional.condition.${condition}.label`
|
||||
) || condition}
|
||||
<ha-svg-icon
|
||||
slot="graphic"
|
||||
.path=${ICON_CONDITION[condition]}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-button-menu>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
@ -289,53 +275,40 @@ export class HuiConditionalCardEditor
|
||||
fireEvent(this, "config-changed", { config: this._config });
|
||||
}
|
||||
|
||||
private _addCondition(ev: Event): void {
|
||||
const target = ev.target! as any;
|
||||
if (target.value === "" || !this._config) {
|
||||
private _addCondition(ev: CustomEvent): void {
|
||||
const condition = (ev.currentTarget as HaSelect).items[ev.detail.index]
|
||||
.value as Condition["condition"];
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const conditions = [...this._config.conditions];
|
||||
conditions.push({
|
||||
entity: target.value,
|
||||
state: "",
|
||||
});
|
||||
|
||||
const elClass = customElements.get(`ha-card-condition-${condition}`) as
|
||||
| LovelaceConditionEditorConstructor
|
||||
| undefined;
|
||||
|
||||
conditions.push(
|
||||
elClass?.defaultConfig
|
||||
? { ...elClass.defaultConfig }
|
||||
: { condition: condition }
|
||||
);
|
||||
this._config = { ...this._config, conditions };
|
||||
target.value = "";
|
||||
fireEvent(this, "config-changed", { config: this._config });
|
||||
}
|
||||
|
||||
private _changeCondition(ev: Event): void {
|
||||
const target = ev.target as any;
|
||||
if (!this._config || !target) {
|
||||
return;
|
||||
}
|
||||
const conditions = [...this._config.conditions];
|
||||
if (target.configValue === "entity" && target.value === "") {
|
||||
conditions.splice(target.idx, 1);
|
||||
private _conditionChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const conditions = [...this._config!.conditions];
|
||||
const newValue = ev.detail.value;
|
||||
const index = (ev.target as any).index;
|
||||
|
||||
if (newValue === null) {
|
||||
conditions.splice(index, 1);
|
||||
} else {
|
||||
const condition = { ...conditions[target.idx] };
|
||||
if (target.configValue === "entity") {
|
||||
condition.entity = target.value;
|
||||
} else if (target.configValue === "state") {
|
||||
if (condition.state_not !== undefined) {
|
||||
condition.state_not = target.value;
|
||||
} else {
|
||||
condition.state = target.value;
|
||||
conditions[index] = newValue;
|
||||
}
|
||||
} else if (target.configValue === "invert") {
|
||||
if (target.value === "true") {
|
||||
if (condition.state) {
|
||||
condition.state_not = condition.state;
|
||||
delete condition.state;
|
||||
}
|
||||
} else if (condition.state_not) {
|
||||
condition.state = condition.state_not;
|
||||
delete condition.state_not;
|
||||
}
|
||||
}
|
||||
conditions[target.idx] = condition;
|
||||
}
|
||||
this._config = { ...this._config, conditions };
|
||||
|
||||
this._config = { ...this._config!, conditions };
|
||||
fireEvent(this, "config-changed", { config: this._config });
|
||||
}
|
||||
|
||||
@ -352,22 +325,13 @@ export class HuiConditionalCardEditor
|
||||
.condition {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.condition .content {
|
||||
padding: 12px;
|
||||
}
|
||||
.condition .state {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
ha-button-menu {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.condition .state ha-select {
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.condition .state ha-textfield {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--divider-color);
|
||||
|
@ -4682,6 +4682,8 @@
|
||||
"confirm_cancel": "Are you sure you want to cancel?",
|
||||
"show_visual_editor": "Show visual editor",
|
||||
"show_code_editor": "Show code editor",
|
||||
"edit_ui": "[%key:ui::panel::config::automation::editor::edit_ui%]",
|
||||
"edit_yaml": "[%key:ui::panel::config::automation::editor::edit_yaml%]",
|
||||
"add": "Add card",
|
||||
"edit": "Edit",
|
||||
"clear": "Clear",
|
||||
@ -4780,7 +4782,24 @@
|
||||
"state_not_equal": "State is not equal to",
|
||||
"current_state": "current",
|
||||
"condition_explanation": "The card will be shown when ALL conditions below are fulfilled.",
|
||||
"change_type": "Change type"
|
||||
"change_type": "Change type",
|
||||
"add_condition": "Add condition",
|
||||
"condition": {
|
||||
"screen": {
|
||||
"label": "Screen",
|
||||
"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