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
This commit is contained in:
Paul Bottein 2024-05-21 10:43:23 +02:00 committed by GitHub
parent 4cc5d2d04b
commit a2a89502d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 746 additions and 170 deletions

View File

@ -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);

View File

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

View File

@ -178,16 +178,24 @@ export class HaMap extends ReactiveElement {
map!.classList.toggle("forced-light", this.themeMode === "light");
}
private _loading = false;
private async _loadMap(): Promise<void> {
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 {

View File

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

View File

@ -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<string[]>((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;
}

View File

@ -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<string[]>((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 {

View File

@ -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<LovelaceSectionConfig> = {};
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;

View File

@ -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`
<div class="conditions">
${!this.nested
? html`
<ha-alert alert-type="info">
${this.hass!.localize(
"ui.panel.lovelace.editor.condition-editor.explanation"
)}
</ha-alert>
`
: nothing}
${this.conditions.map(
(cond, idx) => html`
<ha-card-condition-editor
@ -172,9 +153,6 @@ export class HaCardConditionsEditor extends LitElement {
static get styles(): CSSResultGroup {
return [
css`
mwc-tab-bar {
border-bottom: 1px solid var(--divider-color);
}
ha-alert {
display: block;
margin-top: 12px;

View File

@ -8,6 +8,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { any, array, assert, assign, object, optional } from "superstruct";
import { storage } from "../../../../common/decorators/storage";
import { HASSDomEvent, fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-svg-icon";
@ -142,6 +143,11 @@ export class HuiConditionalCardEditor
</div>
`
: html`
<ha-alert alert-type="info">
${this.hass!.localize(
"ui.panel.lovelace.editor.condition-editor.explanation"
)}
</ha-alert>
<ha-card-conditions-editor
.hass=${this.hass}
.conditions=${this._config.conditions}
@ -231,6 +237,10 @@ export class HuiConditionalCardEditor
mwc-tab-bar {
border-bottom: 1px solid var(--divider-color);
}
ha-alert {
display: block;
margin-top: 12px;
}
.card {
margin-top: 8px;
border: 1px solid var(--divider-color);

View File

@ -226,7 +226,7 @@ export const addSection = (
viewIndex: number,
sectionConfig: LovelaceSectionRawConfig
): 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.");
}
@ -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);

View File

@ -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 => {

View File

@ -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<EditSectionDialogParams>
{
@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<void> {
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`
<ha-yaml-editor
.hass=${this.hass}
dialogInitialFocus
@value-changed=${this._viewYamlChanged}
></ha-yaml-editor>
`;
} else {
switch (this._curTab) {
case "tab-settings":
content = html`
<hui-section-settings-editor
.hass=${this.hass}
.config=${this._config}
@value-changed=${this._configChanged}
>
</hui-section-settings-editor>
`;
break;
case "tab-visibility":
content = html`
<hui-section-visibility-editor
.hass=${this.hass}
.config=${this._config}
@value-changed=${this._configChanged}
>
</hui-section-visibility-editor>
`;
break;
}
}
return html`
<ha-dialog
open
scrimClickAction
@keydown=${this._ignoreKeydown}
@closed=${this._cancel}
.heading=${heading}
class=${classMap({
"yaml-mode": this._yamlMode,
})}
>
<ha-dialog-header show-border slot="heading">
<ha-icon-button
slot="navigationIcon"
dialogAction="cancel"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">${heading}</span>
<ha-button-menu
slot="actionItems"
fixed
corner="BOTTOM_END"
menuCorner="END"
@closed=${stopPropagation}
@action=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_section.edit_ui"
)}
${!this._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_section.edit_yaml"
)}
${this._yamlMode
? html`<ha-svg-icon
class="selected_menu_item"
slot="graphic"
.path=${mdiCheck}
></ha-svg-icon>`
: ``}
</ha-list-item>
</ha-button-menu>
${!this._yamlMode
? html`
<paper-tabs
scrollable
hide-scroll-buttons
.selected=${TABS.indexOf(this._curTab)}
@selected-item-changed=${this._handleTabSelected}
>
${TABS.map(
(tab, index) => html`
<paper-tab id=${tab} .dialogInitialFocus=${index === 0}>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
)}
</paper-tab>
`
)}
</paper-tabs>
`
: nothing}
</ha-dialog-header>
${content}
<ha-button slot="secondaryAction">
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
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<ActionDetail>) {
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<void> {
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;
}
}

View File

@ -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`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _computeLabel = (schema: SchemaUnion<typeof SCHEMA>) =>
this.hass.localize(
`ui.panel.lovelace.editor.edit_section.settings.${schema.name}`
);
private _computeHelper = (schema: SchemaUnion<typeof SCHEMA>) =>
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;
}
}

View File

@ -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`
<ha-alert alert-type="info">
${this.hass.localize(
`ui.panel.lovelace.editor.edit_section.visibility.explanation`
)}
</ha-alert>
<ha-card-conditions-editor
.hass=${this.hass}
.conditions=${conditions}
@value-changed=${this._valueChanged}
>
</ha-card-conditions-editor>
`;
}
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;
}
}

View File

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

View File

@ -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);
}
}

View File

@ -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<LovelaceRawSectionConfig, string>();
private _sectionConfigKeys = new WeakMap<HuiSection, string>();
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<typeof this>): 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`
<div class="section">
@ -113,7 +140,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
</div>
`
: nothing}
<div class="section-wrapper">${section}</div>
${section}
</div>
`;
}
@ -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(

View File

@ -148,7 +148,7 @@ export class HUIView extends ReactiveElement {
this._applyTheme();
}
public willUpdate(changedProperties: PropertyValues): void {
public willUpdate(changedProperties: PropertyValues<typeof this>): 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 (

View File

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