From a2a89502d8d179781cd5d808ad307566afb1a007 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 21 May 2024 10:43:23 +0200 Subject: [PATCH] Add visibility option to sections (conditional section) (#20805) * Add first version of section visibility option * Move visibility logic into view * Simplify section view structure * Don't add hidden section to dom * Move visilibity logic to hui-section * Setup section editor * Add visibility view * Add basic settings editor * Improve visibility editor * Update conditional base * Feedbacks * Better typings --- src/common/dom/media_query.ts | 4 +- src/components/chart/ha-chart-base.ts | 43 ++- src/components/map/ha-map.ts | 14 +- src/data/lovelace/config/section.ts | 2 + .../lovelace/common/validate-condition.ts | 46 +++ .../components/hui-conditional-base.ts | 61 ++-- .../card-editor/hui-dialog-create-card.ts | 16 +- .../conditions/ha-card-conditions-editor.ts | 28 +- .../hui-conditional-card-editor.ts | 10 + src/panels/lovelace/editor/config-util.ts | 11 +- src/panels/lovelace/editor/lovelace-path.ts | 10 +- .../section-editor/hui-dialog-edit-section.ts | 319 ++++++++++++++++++ .../hui-section-settings-editor.ts | 76 +++++ .../hui-section-visibility-editor.ts | 51 +++ .../show-edit-section-dialog.ts | 22 ++ src/panels/lovelace/sections/hui-section.ts | 63 +++- .../lovelace/views/hui-sections-view.ts | 119 +++---- src/panels/lovelace/views/hui-view.ts | 4 +- src/translations/en.json | 17 +- 19 files changed, 746 insertions(+), 170 deletions(-) create mode 100644 src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts create mode 100644 src/panels/lovelace/editor/section-editor/hui-section-settings-editor.ts create mode 100644 src/panels/lovelace/editor/section-editor/hui-section-visibility-editor.ts create mode 100644 src/panels/lovelace/editor/section-editor/show-edit-section-dialog.ts diff --git a/src/common/dom/media_query.ts b/src/common/dom/media_query.ts index df8ba93668..ebb82beb8d 100644 --- a/src/common/dom/media_query.ts +++ b/src/common/dom/media_query.ts @@ -1,3 +1,5 @@ +export type MediaQueriesListener = () => void; + /** * Attach a media query. Listener is called right away and when it matches. * @param mediaQuery media query to match. @@ -7,7 +9,7 @@ export const listenMediaQuery = ( mediaQuery: string, matchesChanged: (matches: boolean) => void -) => { +): MediaQueriesListener => { const mql = matchMedia(mediaQuery); const listener = (e) => matchesChanged(e.matches); mql.addListener(listener); diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 7bf2acc739..6f8ec22da3 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -313,31 +313,38 @@ export class HaChartBase extends LitElement { `; } + private _loading = false; + private async _setupChart() { + if (this._loading) return; const ctx: CanvasRenderingContext2D = this.renderRoot .querySelector("canvas")! .getContext("2d")!; + this._loading = true; + try { + const ChartConstructor = (await import("../../resources/chartjs")).Chart; - const ChartConstructor = (await import("../../resources/chartjs")).Chart; + const computedStyles = getComputedStyle(this); - const computedStyles = getComputedStyle(this); + ChartConstructor.defaults.borderColor = + computedStyles.getPropertyValue("--divider-color"); + ChartConstructor.defaults.color = computedStyles.getPropertyValue( + "--secondary-text-color" + ); + ChartConstructor.defaults.font.family = + computedStyles.getPropertyValue("--mdc-typography-body1-font-family") || + computedStyles.getPropertyValue("--mdc-typography-font-family") || + "Roboto, Noto, sans-serif"; - ChartConstructor.defaults.borderColor = - computedStyles.getPropertyValue("--divider-color"); - ChartConstructor.defaults.color = computedStyles.getPropertyValue( - "--secondary-text-color" - ); - ChartConstructor.defaults.font.family = - computedStyles.getPropertyValue("--mdc-typography-body1-font-family") || - computedStyles.getPropertyValue("--mdc-typography-font-family") || - "Roboto, Noto, sans-serif"; - - this.chart = new ChartConstructor(ctx, { - type: this.chartType, - data: this.data, - options: this._createOptions(), - plugins: this._createPlugins(), - }); + this.chart = new ChartConstructor(ctx, { + type: this.chartType, + data: this.data, + options: this._createOptions(), + plugins: this._createPlugins(), + }); + } finally { + this._loading = false; + } } private _createOptions() { diff --git a/src/components/map/ha-map.ts b/src/components/map/ha-map.ts index ace0501822..629770864d 100644 --- a/src/components/map/ha-map.ts +++ b/src/components/map/ha-map.ts @@ -178,16 +178,24 @@ export class HaMap extends ReactiveElement { map!.classList.toggle("forced-light", this.themeMode === "light"); } + private _loading = false; + private async _loadMap(): Promise { + if (this._loading) return; let map = this.shadowRoot!.getElementById("map"); if (!map) { map = document.createElement("div"); map.id = "map"; this.shadowRoot!.append(map); } - [this.leafletMap, this.Leaflet] = await setupLeafletMap(map); - this._updateMapStyle(); - this._loaded = true; + this._loading = true; + try { + [this.leafletMap, this.Leaflet] = await setupLeafletMap(map); + this._updateMapStyle(); + this._loaded = true; + } finally { + this._loading = false; + } } public fitMap(options?: { zoom?: number; pad?: number }): void { diff --git a/src/data/lovelace/config/section.ts b/src/data/lovelace/config/section.ts index 1c21688585..bfd54042a5 100644 --- a/src/data/lovelace/config/section.ts +++ b/src/data/lovelace/config/section.ts @@ -1,8 +1,10 @@ +import type { Condition } from "../../../panels/lovelace/common/validate-condition"; import type { LovelaceCardConfig } from "./card"; import type { LovelaceStrategyConfig } from "./strategy"; export interface LovelaceBaseSectionConfig { title?: string; + visibility?: Condition[]; } export interface LovelaceSectionConfig extends LovelaceBaseSectionConfig { diff --git a/src/panels/lovelace/common/validate-condition.ts b/src/panels/lovelace/common/validate-condition.ts index 908d5719dd..8266b261a5 100644 --- a/src/panels/lovelace/common/validate-condition.ts +++ b/src/panels/lovelace/common/validate-condition.ts @@ -1,4 +1,8 @@ import { ensureArray } from "../../../common/array/ensure-array"; +import { + MediaQueriesListener, + listenMediaQuery, +} from "../../../common/dom/media_query"; import { isValidEntityId } from "../../../common/entity/valid_entity_id"; import { UNAVAILABLE } from "../../../data/entity"; import { HomeAssistant } from "../../../types"; @@ -308,3 +312,45 @@ export function addEntityToCondition( } return condition; } + +export function extractMediaQueries(conditions: Condition[]): string[] { + return conditions.reduce((array, c) => { + if ("conditions" in c && c.conditions) { + array.push(...extractMediaQueries(c.conditions)); + } + if (c.condition === "screen" && c.media_query) { + array.push(c.media_query); + } + return array; + }, []); +} + +export function attachConditionMediaQueriesListeners( + conditions: Condition[], + hass: HomeAssistant, + onChange: (visibility: boolean) => void +): MediaQueriesListener[] { + // For performance, if there is only one condition and it's a screen condition, set the visibility directly + if ( + conditions.length === 1 && + conditions[0].condition === "screen" && + conditions[0].media_query + ) { + const listener = listenMediaQuery(conditions[0].media_query, (matches) => { + onChange(matches); + }); + return [listener]; + } + + const mediaQueries = extractMediaQueries(conditions); + + const listeners = mediaQueries.map((query) => { + const listener = listenMediaQuery(query, () => { + const visibility = checkConditionsMet(conditions, hass); + onChange(visibility); + }); + return listener; + }); + + return listeners; +} diff --git a/src/panels/lovelace/components/hui-conditional-base.ts b/src/panels/lovelace/components/hui-conditional-base.ts index 31e14edbca..b2ea0405d8 100644 --- a/src/panels/lovelace/components/hui-conditional-base.ts +++ b/src/panels/lovelace/components/hui-conditional-base.ts @@ -1,32 +1,19 @@ import { PropertyValues, ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { listenMediaQuery } from "../../../common/dom/media_query"; +import { MediaQueriesListener } from "../../../common/dom/media_query"; import { deepEqual } from "../../../common/util/deep-equal"; import { HomeAssistant } from "../../../types"; import { ConditionalCardConfig } from "../cards/types"; import { Condition, - LegacyCondition, checkConditionsMet, + attachConditionMediaQueriesListeners, + extractMediaQueries, validateConditionalConfig, } from "../common/validate-condition"; import { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types"; import { LovelaceCard } from "../types"; -function extractMediaQueries( - conditions: (Condition | LegacyCondition)[] -): string[] { - return conditions.reduce((array, c) => { - if ("conditions" in c && c.conditions) { - array.push(...extractMediaQueries(c.conditions)); - } - if ("condition" in c && c.condition === "screen" && c.media_query) { - array.push(c.media_query); - } - return array; - }, []); -} - @customElement("hui-conditional-base") export class HuiConditionalBase extends ReactiveElement { @property({ attribute: false }) public hass?: HomeAssistant; @@ -37,7 +24,7 @@ export class HuiConditionalBase extends ReactiveElement { protected _element?: LovelaceCard | LovelaceRow; - private _mediaQueriesListeners: Array<() => void> = []; + private _listeners: MediaQueriesListener[] = []; private _mediaQueries: string[] = []; @@ -79,41 +66,31 @@ export class HuiConditionalBase extends ReactiveElement { } private _clearMediaQueries() { - this._mediaQueries = []; - while (this._mediaQueriesListeners.length) { - this._mediaQueriesListeners.pop()!(); - } + this._listeners.forEach((unsub) => unsub()); + this._listeners = []; } private _listenMediaQueries() { - if (!this._config) { + if (!this._config || !this.hass) { return; } - const mediaQueries = extractMediaQueries(this._config.conditions); + const supportedConditions = this._config.conditions.filter( + (c) => "condition" in c + ) as Condition[]; + const mediaQueries = extractMediaQueries(supportedConditions); if (deepEqual(mediaQueries, this._mediaQueries)) return; - this._mediaQueries = mediaQueries; - while (this._mediaQueriesListeners.length) { - this._mediaQueriesListeners.pop()!(); - } + this._clearMediaQueries(); - mediaQueries.forEach((query) => { - const listener = listenMediaQuery(query, (matches) => { - // For performance, if there is only one condition and it's a screen condition, set the visibility directly - if ( - this._config!.conditions.length === 1 && - "condition" in this._config!.conditions[0] && - this._config!.conditions[0].condition === "screen" - ) { - this._setVisibility(matches); - return; - } - this._updateVisibility(); - }); - this._mediaQueriesListeners.push(listener); - }); + this._listeners = attachConditionMediaQueriesListeners( + supportedConditions, + this.hass, + (visibility) => { + this._setVisibility(visibility); + } + ); } protected update(changed: PropertyValues): void { diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts index 89a20ab0f5..deb970b0f8 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts @@ -24,15 +24,15 @@ import { computeCards, computeSection, } from "../../common/generate-lovelace-config"; +import { + findLovelaceContainer, + parseLovelaceContainerPath, +} from "../lovelace-path"; import "./hui-card-picker"; import "./hui-entity-picker-table"; import { CreateCardDialogParams } from "./show-create-card-dialog"; import { showEditCardDialog } from "./show-edit-card-dialog"; import { showSuggestCardDialog } from "./show-suggest-card-dialog"; -import { - findLovelaceContainer, - parseLovelaceContainerPath, -} from "../lovelace-path"; declare global { interface HASSDomEvents { @@ -274,15 +274,17 @@ export class HuiCreateDialogCard let sectionOptions: Partial = {}; - const { sectionIndex } = parseLovelaceContainerPath(this._params!.path); + const { viewIndex, sectionIndex } = parseLovelaceContainerPath( + this._params!.path + ); const isSection = sectionIndex !== undefined; // If we are in a section, we want to keep the section options for the preview if (isSection) { const containerConfig = findLovelaceContainer( this._params!.lovelaceConfig!, - this._params!.path! - ) as LovelaceSectionConfig; + [viewIndex, sectionIndex] + ); if (!isStrategySection(containerConfig)) { const { cards, title, ...rest } = containerConfig; sectionOptions = rest; diff --git a/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts b/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts index 125d715bb9..66b5bfffd8 100644 --- a/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts +++ b/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts @@ -1,16 +1,8 @@ import { mdiPlus } from "@mdi/js"; -import { - CSSResultGroup, - LitElement, - PropertyValues, - css, - html, - nothing, -} from "lit"; +import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../../common/dom/fire_event"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; -import "../../../../components/ha-alert"; import "../../../../components/ha-button"; import "../../../../components/ha-list-item"; import type { HaSelect } from "../../../../components/ha-select"; @@ -21,12 +13,12 @@ import { Condition, LegacyCondition } from "../../common/validate-condition"; import "./ha-card-condition-editor"; import type { HaCardConditionEditor } from "./ha-card-condition-editor"; import { LovelaceConditionEditorConstructor } from "./types"; +import "./types/ha-card-condition-and"; import "./types/ha-card-condition-numeric_state"; +import "./types/ha-card-condition-or"; import "./types/ha-card-condition-screen"; import "./types/ha-card-condition-state"; import "./types/ha-card-condition-user"; -import "./types/ha-card-condition-or"; -import "./types/ha-card-condition-and"; const UI_CONDITION = [ "numeric_state", @@ -46,8 +38,6 @@ export class HaCardConditionsEditor extends LitElement { | LegacyCondition )[]; - @property({ type: Boolean }) public nested = false; - private _focusLastConditionOnChange = false; protected firstUpdated() { @@ -83,15 +73,6 @@ export class HaCardConditionsEditor extends LitElement { protected render() { return html`
- ${!this.nested - ? html` - - ${this.hass!.localize( - "ui.panel.lovelace.editor.condition-editor.explanation" - )} - - ` - : nothing} ${this.conditions.map( (cond, idx) => html` ` : html` + + ${this.hass!.localize( + "ui.panel.lovelace.editor.condition-editor.explanation" + )} + { - const view = findLovelaceContainer(config, [viewIndex]) as LovelaceViewConfig; + const view = findLovelaceContainer(config, [viewIndex]); if (isStrategyView(view)) { throw new Error("Deleting sections in a strategy is not supported."); } @@ -246,7 +246,7 @@ export const deleteSection = ( viewIndex: number, sectionIndex: number ): LovelaceConfig => { - const view = findLovelaceContainer(config, [viewIndex]) as LovelaceViewConfig; + const view = findLovelaceContainer(config, [viewIndex]); if (isStrategyView(view)) { throw new Error("Deleting sections in a strategy is not supported."); } @@ -267,7 +267,7 @@ export const insertSection = ( sectionIndex: number, sectionConfig: LovelaceSectionRawConfig ): LovelaceConfig => { - const view = findLovelaceContainer(config, [viewIndex]) as LovelaceViewConfig; + const view = findLovelaceContainer(config, [viewIndex]); if (isStrategyView(view)) { throw new Error("Inserting sections in a strategy is not supported."); } @@ -291,10 +291,7 @@ export const moveSection = ( fromPath: [number, number], toPath: [number, number] ): LovelaceConfig => { - const section = findLovelaceContainer( - config, - fromPath - ) as LovelaceSectionRawConfig; + const section = findLovelaceContainer(config, fromPath); let newConfig = deleteSection(config, fromPath[0], fromPath[1]); newConfig = insertSection(newConfig, toPath[0], toPath[1], section); diff --git a/src/panels/lovelace/editor/lovelace-path.ts b/src/panels/lovelace/editor/lovelace-path.ts index 2eaf9dadc6..d4527126ba 100644 --- a/src/panels/lovelace/editor/lovelace-path.ts +++ b/src/panels/lovelace/editor/lovelace-path.ts @@ -46,7 +46,15 @@ export const getLovelaceContainerPath = ( path: LovelaceCardPath ): LovelaceContainerPath => path.slice(0, -1) as LovelaceContainerPath; -export const findLovelaceContainer = ( +type FindLovelaceContainer = { + (config: LovelaceConfig, path: [number]): LovelaceViewRawConfig; + (config: LovelaceConfig, path: [number, number]): LovelaceSectionRawConfig; + ( + config: LovelaceConfig, + path: LovelaceContainerPath + ): LovelaceViewRawConfig | LovelaceSectionRawConfig; +}; +export const findLovelaceContainer: FindLovelaceContainer = ( config: LovelaceConfig, path: LovelaceContainerPath ): LovelaceViewRawConfig | LovelaceSectionRawConfig => { diff --git a/src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts b/src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts new file mode 100644 index 0000000000..0c86c42d4f --- /dev/null +++ b/src/panels/lovelace/editor/section-editor/hui-dialog-edit-section.ts @@ -0,0 +1,319 @@ +import { ActionDetail } from "@material/mwc-list"; +import { mdiCheck, mdiClose, mdiDotsVertical } from "@mdi/js"; +import "@polymer/paper-tabs/paper-tab"; +import "@polymer/paper-tabs/paper-tabs"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import "../../../../components/ha-button"; +import "../../../../components/ha-button-menu"; +import "../../../../components/ha-circular-progress"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-list-item"; +import "../../../../components/ha-yaml-editor"; +import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; +import { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { + findLovelaceContainer, + updateLovelaceContainer, +} from "../lovelace-path"; +import "./hui-section-settings-editor"; +import "./hui-section-visibility-editor"; +import type { EditSectionDialogParams } from "./show-edit-section-dialog"; + +const TABS = ["tab-settings", "tab-visibility"] as const; + +@customElement("hui-dialog-edit-section") +export class HuiDialogEditSection + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EditSectionDialogParams; + + @state() private _config?: LovelaceSectionRawConfig; + + @state() private _yamlMode = false; + + @state() private _curTab: (typeof TABS)[number] = TABS[0]; + + @query("ha-yaml-editor") private _editor?: HaYamlEditor; + + protected updated(changedProperties: PropertyValues) { + if (this._yamlMode && changedProperties.has("_yamlMode")) { + const viewConfig = { + ...this._config, + }; + this._editor?.setValue(viewConfig); + } + } + + public async showDialog(params: EditSectionDialogParams): Promise { + this._params = params; + + this._config = findLovelaceContainer(this._params.lovelaceConfig, [ + this._params.viewIndex, + this._params.sectionIndex, + ]); + } + + public closeDialog() { + this._params = undefined; + this._yamlMode = false; + this._config = undefined; + this._curTab = TABS[0]; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params || !this._config) { + return nothing; + } + + const heading = this.hass!.localize( + "ui.panel.lovelace.editor.edit_section.header" + ); + + let content: TemplateResult<1> | typeof nothing = nothing; + + if (this._yamlMode) { + content = html` + + `; + } else { + switch (this._curTab) { + case "tab-settings": + content = html` + + + `; + break; + case "tab-visibility": + content = html` + + + `; + break; + } + } + + return html` + + + + ${heading} + + + + ${this.hass!.localize( + "ui.panel.lovelace.editor.edit_section.edit_ui" + )} + ${!this._yamlMode + ? html`` + : ``} + + + + ${this.hass!.localize( + "ui.panel.lovelace.editor.edit_section.edit_yaml" + )} + ${this._yamlMode + ? html`` + : ``} + + + ${!this._yamlMode + ? html` + + ${TABS.map( + (tab, index) => html` + + ${this.hass!.localize( + `ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}` + )} + + ` + )} + + ` + : nothing} + + ${content} + + ${this.hass!.localize("ui.common.cancel")} + + + + ${this.hass!.localize("ui.common.save")} + + + `; + } + + private _configChanged(ev: CustomEvent): void { + ev.stopPropagation(); + this._config = ev.detail.value; + } + + private _handleTabSelected(ev: CustomEvent): void { + if (!ev.detail.value) { + return; + } + this._curTab = ev.detail.value.id; + } + + private async _handleAction(ev: CustomEvent) { + ev.stopPropagation(); + ev.preventDefault(); + switch (ev.detail.index) { + case 0: + this._yamlMode = false; + break; + case 1: + this._yamlMode = true; + break; + } + } + + private _viewYamlChanged(ev: CustomEvent) { + ev.stopPropagation(); + if (!ev.detail.isValid) { + return; + } + this._config = ev.detail.value; + } + + private _ignoreKeydown(ev: KeyboardEvent) { + ev.stopPropagation(); + } + + private _cancel(ev?: Event) { + if (ev) { + ev.stopPropagation(); + } + this.closeDialog(); + } + + private async _save(): Promise { + if (!this._params || !this._config) { + return; + } + const newConfig = updateLovelaceContainer( + this._params.lovelaceConfig, + [this._params.viewIndex, this._params.sectionIndex], + this._config + ); + + this._params.saveConfig(newConfig); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-dialog { + /* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */ + --vertical-align-dialog: flex-start; + --dialog-surface-margin-top: 40px; + } + + @media all and (max-width: 450px), all and (max-height: 500px) { + /* When in fullscreen dialog should be attached to top */ + ha-dialog { + --dialog-surface-margin-top: 0px; + } + } + ha-dialog.yaml-mode { + --dialog-content-padding: 0; + } + paper-tabs { + --paper-tabs-selection-bar-color: var(--primary-color); + color: var(--primary-text-color); + text-transform: uppercase; + padding: 0 20px; + } + .selected_menu_item { + color: var(--primary-color); + } + @media all and (min-width: 600px) { + ha-dialog { + --mdc-dialog-min-width: 600px; + } + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-dialog-edit-section": HuiDialogEditSection; + } +} diff --git a/src/panels/lovelace/editor/section-editor/hui-section-settings-editor.ts b/src/panels/lovelace/editor/section-editor/hui-section-settings-editor.ts new file mode 100644 index 0000000000..4e06726100 --- /dev/null +++ b/src/panels/lovelace/editor/section-editor/hui-section-settings-editor.ts @@ -0,0 +1,76 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section"; +import { HomeAssistant } from "../../../../types"; +import { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import { fireEvent } from "../../../../common/dom/fire_event"; + +const SCHEMA = [ + { + name: "title", + selector: { text: {} }, + }, +] as const satisfies HaFormSchema[]; + +type SettingsData = { + title: string; +}; + +@customElement("hui-section-settings-editor") +export class HuiDialogEditSection extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public config!: LovelaceSectionRawConfig; + + render() { + const data: SettingsData = { + title: this.config.title || "", + }; + + return html` + + `; + } + + private _computeLabel = (schema: SchemaUnion) => + this.hass.localize( + `ui.panel.lovelace.editor.edit_section.settings.${schema.name}` + ); + + private _computeHelper = (schema: SchemaUnion) => + this.hass.localize( + `ui.panel.lovelace.editor.edit_section.settings.${schema.name}_helper` + ) || ""; + + private _valueChanged(ev: CustomEvent) { + ev.stopPropagation(); + const newData = ev.detail.value as SettingsData; + + const newConfig: LovelaceSectionRawConfig = { + ...this.config, + title: newData.title, + }; + + if (!newConfig.title) { + delete newConfig.title; + } + + fireEvent(this, "value-changed", { value: newConfig }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-section-settings-editor": HuiDialogEditSection; + } +} diff --git a/src/panels/lovelace/editor/section-editor/hui-section-visibility-editor.ts b/src/panels/lovelace/editor/section-editor/hui-section-visibility-editor.ts new file mode 100644 index 0000000000..04f16a7aa3 --- /dev/null +++ b/src/panels/lovelace/editor/section-editor/hui-section-visibility-editor.ts @@ -0,0 +1,51 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-alert"; +import { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section"; +import { HomeAssistant } from "../../../../types"; +import { Condition } from "../../common/validate-condition"; +import "../conditions/ha-card-conditions-editor"; + +@customElement("hui-section-visibility-editor") +export class HuiDialogEditSection extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public config!: LovelaceSectionRawConfig; + + render() { + const conditions = this.config.visibility ?? []; + return html` + + ${this.hass.localize( + `ui.panel.lovelace.editor.edit_section.visibility.explanation` + )} + + + + `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const conditions = ev.detail.value as Condition[]; + const newConfig: LovelaceSectionRawConfig = { + ...this.config, + visibility: conditions, + }; + if (newConfig.visibility?.length === 0) { + delete newConfig.visibility; + } + fireEvent(this, "value-changed", { value: newConfig }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-section-visibility-editor": HuiDialogEditSection; + } +} diff --git a/src/panels/lovelace/editor/section-editor/show-edit-section-dialog.ts b/src/panels/lovelace/editor/section-editor/show-edit-section-dialog.ts new file mode 100644 index 0000000000..ff06083fab --- /dev/null +++ b/src/panels/lovelace/editor/section-editor/show-edit-section-dialog.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import { LovelaceConfig } from "../../../../data/lovelace/config/types"; + +export type EditSectionDialogParams = { + lovelaceConfig: LovelaceConfig; + saveConfig: (config: LovelaceConfig) => void; + viewIndex: number; + sectionIndex: number; +}; + +const importEditSectionDialog = () => import("./hui-dialog-edit-section"); + +export const showEditSectionDialog = ( + element: HTMLElement, + editSectionDialogParams: EditSectionDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hui-dialog-edit-section", + dialogImport: importEditSectionDialog, + dialogParams: editSectionDialogParams, + }); +}; diff --git a/src/panels/lovelace/sections/hui-section.ts b/src/panels/lovelace/sections/hui-section.ts index b50b1df18a..967a5cd8d2 100644 --- a/src/panels/lovelace/sections/hui-section.ts +++ b/src/panels/lovelace/sections/hui-section.ts @@ -1,5 +1,6 @@ import { PropertyValues, ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { MediaQueriesListener } from "../../../common/dom/media_query"; import "../../../components/ha-svg-icon"; import type { LovelaceSectionElement } from "../../../data/lovelace"; import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; @@ -10,6 +11,10 @@ import { } from "../../../data/lovelace/config/section"; import type { HomeAssistant } from "../../../types"; import type { HuiErrorCard } from "../cards/hui-error-card"; +import { + checkConditionsMet, + attachConditionMediaQueriesListeners, +} from "../common/validate-condition"; import { createCardElement } from "../create-element/create-card-element"; import { createErrorCardConfig, @@ -43,6 +48,8 @@ export class HuiSection extends ReactiveElement { private _layoutElement?: LovelaceSectionElement; + private _listeners: MediaQueriesListener[] = []; + // Public to make demo happy public createCardElement(cardConfig: LovelaceCardConfig) { const element = createCardElement(cardConfig) as LovelaceCard; @@ -95,6 +102,17 @@ export class HuiSection extends ReactiveElement { } } + public disconnectedCallback() { + super.disconnectedCallback(); + this._clearMediaQueries(); + } + + public connectedCallback() { + super.connectedCallback(); + this._listenMediaQueries(); + this._updateElement(); + } + protected update(changedProperties) { super.update(changedProperties); @@ -109,7 +127,6 @@ export class HuiSection extends ReactiveElement { this._rebuildCard(element, createErrorCardConfig(e.message, null)); } }); - this._layoutElement.hass = this.hass; } if (changedProperties.has("lovelace")) { @@ -118,9 +135,32 @@ export class HuiSection extends ReactiveElement { if (changedProperties.has("_cards")) { this._layoutElement.cards = this._cards; } + if (changedProperties.has("hass") || changedProperties.has("lovelace")) { + this._updateElement(); + } } } + private _clearMediaQueries() { + this._listeners.forEach((unsub) => unsub()); + this._listeners = []; + } + + private _listenMediaQueries() { + if (!this.config.visibility) { + return; + } + this._clearMediaQueries(); + this._listeners = attachConditionMediaQueriesListeners( + this.config.visibility, + this.hass, + (visibility) => { + const visible = visibility || this.lovelace!.editMode; + this._updateElement(visible); + } + ); + } + private async _initializeConfig() { let sectionConfig = { ...this.config }; let isStrategy = false; @@ -161,7 +201,26 @@ export class HuiSection extends ReactiveElement { while (this.lastChild) { this.removeChild(this.lastChild); } - this.appendChild(this._layoutElement!); + this._updateElement(); + } + } + + private _updateElement(forceVisible?: boolean) { + if (!this._layoutElement) { + return; + } + const visible = + forceVisible ?? + (this.lovelace.editMode || + !this.config.visibility || + checkConditionsMet(this.config.visibility, this.hass)); + + this.style.setProperty("display", visible ? "" : "none"); + this.toggleAttribute("hidden", !visible); + if (!visible && this._layoutElement.parentElement) { + this.removeChild(this._layoutElement); + } else if (visible && !this._layoutElement.parentElement) { + this.appendChild(this._layoutElement); } } diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index 063e2a0964..df963000c5 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -1,5 +1,12 @@ import { mdiArrowAll, mdiDelete, mdiPencil, mdiViewGridPlus } from "@mdi/js"; -import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + css, + html, + nothing, +} from "lit"; import { customElement, property, state } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; import { styleMap } from "lit/directives/style-map"; @@ -7,18 +14,12 @@ import "../../../components/ha-icon-button"; import "../../../components/ha-sortable"; import "../../../components/ha-svg-icon"; import type { LovelaceViewElement } from "../../../data/lovelace"; -import { LovelaceSectionConfig as LovelaceRawSectionConfig } from "../../../data/lovelace/config/section"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; -import { - showConfirmationDialog, - showPromptDialog, -} from "../../../dialogs/generic/show-dialog-box"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import type { HomeAssistant } from "../../../types"; import { addSection, deleteSection, moveSection } from "../editor/config-util"; -import { - findLovelaceContainer, - updateLovelaceContainer, -} from "../editor/lovelace-path"; +import { findLovelaceContainer } from "../editor/lovelace-path"; +import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog"; import { HuiSection } from "../sections/hui-section"; import type { Lovelace, LovelaceBadge } from "../types"; @@ -38,27 +39,54 @@ export class SectionsView extends LitElement implements LovelaceViewElement { @state() private _config?: LovelaceViewConfig; + @state() private _sectionCount = 0; + public setConfig(config: LovelaceViewConfig): void { this._config = config; } - private _sectionConfigKeys = new WeakMap(); + private _sectionConfigKeys = new WeakMap(); - private _getKey(sectionConfig: LovelaceRawSectionConfig) { + private _getKey(sectionConfig: HuiSection) { if (!this._sectionConfigKeys.has(sectionConfig)) { this._sectionConfigKeys.set(sectionConfig, Math.random().toString()); } return this._sectionConfigKeys.get(sectionConfig)!; } + private _sectionObserver?: MutationObserver; + + private _computeSectionsCount() { + this._sectionCount = this.sections.filter( + (section) => !section.hidden + ).length; + } + + willUpdate(changedProperties: PropertyValues): void { + if (!this._sectionObserver) { + this._sectionObserver = new MutationObserver(() => { + this._computeSectionsCount(); + }); + } + if (changedProperties.has("sections")) { + this._computeSectionsCount(); + this._sectionObserver.disconnect(); + this.sections.forEach((section) => { + this._sectionObserver!.observe(section, { + attributes: true, + attributeFilter: ["hidden"], + }); + }); + } + } + protected render() { if (!this.lovelace) return nothing; - const sectionsConfig = this._config?.sections ?? []; - + const sections = this.sections; + const totalCount = this._sectionCount + (this.lovelace?.editMode ? 1 : 0); const editMode = this.lovelace.editMode; - const sectionCount = sectionsConfig.length + (editMode ? 1 : 0); const maxColumnsCount = this._config?.max_columns; return html` @@ -77,14 +105,13 @@ export class SectionsView extends LitElement implements LovelaceViewElement { class="container" style=${styleMap({ "--max-columns-count": maxColumnsCount, - "--total-count": sectionCount, + "--total-count": totalCount, })} > ${repeat( - sectionsConfig, - (sectionConfig) => this._getKey(sectionConfig), - (_sectionConfig, idx) => { - const section = this.sections[idx]; + sections, + (section) => this._getKey(section), + (section, idx) => { (section as any).itemPath = [idx]; return html`
@@ -113,7 +140,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
` : nothing} -
${section}
+ ${section}
`; } @@ -150,39 +177,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement { private async _editSection(ev) { const index = ev.currentTarget.index; - const path = [this.index!, index] as [number, number]; - - const section = findLovelaceContainer( - this.lovelace!.config, - path - ) as LovelaceRawSectionConfig; - - const newTitle = !section.title; - - const title = await showPromptDialog(this, { - title: this.hass.localize( - `ui.panel.lovelace.editor.edit_section_title.${newTitle ? "title_new" : "title"}` - ), - inputLabel: this.hass.localize( - "ui.panel.lovelace.editor.edit_section_title.input_label" - ), - inputType: "string", - defaultValue: section.title, - confirmText: newTitle - ? this.hass.localize("ui.common.add") - : this.hass.localize("ui.common.save"), + showEditSectionDialog(this, { + lovelaceConfig: this.lovelace!.config, + saveConfig: (newConfig) => { + this.lovelace!.saveConfig(newConfig); + }, + viewIndex: this.index!, + sectionIndex: index, }); - - if (title === null) { - return; - } - - const newConfig = updateLovelaceContainer(this.lovelace!.config, path, { - ...section, - title: title || undefined, - }); - - this.lovelace!.saveConfig(newConfig); } private async _deleteSection(ev) { @@ -190,13 +192,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement { const path = [this.index!, index] as [number, number]; - const section = findLovelaceContainer( - this.lovelace!.config, - path - ) as LovelaceRawSectionConfig; + const section = findLovelaceContainer(this.lovelace!.config, path); const title = section.title?.trim(); - const cardCount = section.cards?.length; + const cardCount = "cards" in section && section.cards?.length; if (title || cardCount) { const named = title ? "named" : "unnamed"; @@ -261,6 +260,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement { border-radius: var(--ha-card-border-radius, 12px); } + .section:not(:has(> *:not([hidden]))) { + display: none; + } + .container { --max-count: min(var(--total-count), var(--max-columns-count, 4)); --max-width: min( diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 2dd636431d..8d7f5211c8 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -148,7 +148,7 @@ export class HUIView extends ReactiveElement { this._applyTheme(); } - public willUpdate(changedProperties: PropertyValues): void { + public willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); /* @@ -161,7 +161,7 @@ export class HUIView extends ReactiveElement { - lovelace changes if edit mode is enabled or config has changed */ - const oldLovelace = changedProperties.get("lovelace") as this["lovelace"]; + const oldLovelace = changedProperties.get("lovelace"); // If config has changed, create element if necessary and set all values. if ( diff --git a/src/translations/en.json b/src/translations/en.json index 7e63d31fc5..2369de0013 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5531,10 +5531,19 @@ "text_named_section_cards": "''{name}'' section and all its cards will be deleted.", "text_unnamed_section_cards": "This section and all its cards will be deleted." }, - "edit_section_title": { - "title": "Edit name", - "title_new": "Add name", - "input_label": "Name" + "edit_section": { + "header": "Edit section", + "tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]", + "tab_settings": "[%key:ui::panel::lovelace::editor::edit_view::tab_settings%]", + "edit_ui": "[%key:ui::panel::lovelace::editor::edit_view::edit_ui%]", + "edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]", + "settings": { + "title": "Title", + "title_helper": "The title will appear at the top of section. Leave empty to hide the title." + }, + "visibility": { + "explanation": "The section will be shown when ALL conditions below are fulfilled. If no conditions are set, the section will always be shown." + } }, "suggest_card": { "header": "We created a suggestion for you",