From b7763882f47e90b3dba3dae283b0584b929a37da Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 19 Sep 2024 10:46:20 +0200 Subject: [PATCH] Add Heading card (#22008) * Add header card * Rename to heading card * Add heading entities * Add editor for entities * Remove unused property * Fix margin and gap * Improve content and entities container * Fix no entities displayed * Cache form to not loose state * Use style * Fix type * Add support for string entities * Add tap action support to entities * Move expandable outside of entities editor * Fix double processing --- src/panels/lovelace/cards/hui-heading-card.ts | 260 +++++++++++++ src/panels/lovelace/cards/types.ts | 15 + .../lovelace/components/hui-card-edit-mode.ts | 4 +- .../create-element/create-card-element.ts | 2 + .../config-elements/hui-entities-editor.ts | 296 +++++++++++++++ .../hui-heading-card-editor.ts | 352 ++++++++++++++++++ .../config-elements/hui-map-card-editor.ts | 2 +- .../lovelace/editor/hui-sub-form-editor.ts | 190 ++++++++++ src/panels/lovelace/editor/lovelace-cards.ts | 4 + .../editor/process-editor-entities.ts | 4 +- src/panels/lovelace/editor/types.ts | 9 + .../lovelace/sections/hui-grid-section.ts | 4 +- src/translations/en.json | 26 ++ 13 files changed, 1162 insertions(+), 6 deletions(-) create mode 100644 src/panels/lovelace/cards/hui-heading-card.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-entities-editor.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts create mode 100644 src/panels/lovelace/editor/hui-sub-form-editor.ts diff --git a/src/panels/lovelace/cards/hui-heading-card.ts b/src/panels/lovelace/cards/hui-heading-card.ts new file mode 100644 index 0000000000..af97daf390 --- /dev/null +++ b/src/panels/lovelace/cards/hui-heading-card.ts @@ -0,0 +1,260 @@ +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import "../../../components/ha-card"; +import "../../../components/ha-icon"; +import "../../../components/ha-icon-next"; +import "../../../components/ha-state-icon"; +import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; +import "../../../state-display/state-display"; +import { HomeAssistant } from "../../../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { handleAction } from "../common/handle-action"; +import { hasAction } from "../common/has-action"; +import type { + LovelaceCard, + LovelaceCardEditor, + LovelaceLayoutOptions, +} from "../types"; +import type { HeadingCardConfig, HeadingCardEntityConfig } from "./types"; + +@customElement("hui-heading-card") +export class HuiHeadingCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-heading-card-editor"); + return document.createElement("hui-heading-card-editor"); + } + + public static getStubConfig(hass: HomeAssistant): HeadingCardConfig { + return { + type: "heading", + icon: "mdi:fridge", + heading: hass.localize("ui.panel.lovelace.cards.heading.default_heading"), + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: HeadingCardConfig; + + public setConfig(config: HeadingCardConfig): void { + this._config = { + tap_action: { + action: "none", + }, + ...config, + }; + } + + public getCardSize(): number { + return 1; + } + + public getLayoutOptions(): LovelaceLayoutOptions { + return { + grid_columns: "full", + grid_rows: this._config?.heading_style === "subtitle" ? "auto" : 1, + }; + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + protected render() { + if (!this._config || !this.hass) { + return nothing; + } + + const actionable = hasAction(this._config.tap_action); + + const style = this._config.heading_style || "title"; + + return html` + +
+
+ ${this._config.icon + ? html`` + : nothing} + ${this._config.heading + ? html`

${this._config.heading}

` + : nothing} + ${actionable ? html`` : nothing} +
+ ${this._config.entities?.length + ? html` +
+ ${this._config.entities.map((config) => + this._renderEntity(config) + )} +
+ ` + : nothing} +
+
+ `; + } + + private _handleEntityAction(ev: ActionHandlerEvent) { + const config = { + tap_action: { + action: "none", + }, + ...(ev.currentTarget as any).config, + }; + + handleAction(this, this.hass!, config, ev.detail.action!); + } + + _renderEntity(entityConfig: string | HeadingCardEntityConfig) { + const config = + typeof entityConfig === "string" + ? { entity: entityConfig } + : entityConfig; + + const stateObj = this.hass!.states[config.entity]; + + if (!stateObj) { + return nothing; + } + + const actionable = hasAction(config.tap_action || { action: "none" }); + + return html` +
+ + +
+ `; + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + background: none; + border: none; + box-shadow: none; + padding: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 100%; + } + [role="button"] { + cursor: pointer; + } + ha-icon-next { + display: inline-block; + transition: transform 180ms ease-in-out; + } + .container { + padding: 2px 4px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + overflow: hidden; + gap: 8px; + } + .content:hover ha-icon-next { + transform: translateX(calc(4px * var(--scale-direction))); + } + .container .content { + flex: 1 0 fill; + min-width: 100px; + } + .container .content:not(:has(p)) { + min-width: fit-content; + } + .container .entities { + flex: 0 0; + } + .content { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + color: var(--primary-text-color); + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.1px; + --mdc-icon-size: 16px; + } + .content ha-icon, + .content ha-icon-next { + display: flex; + flex: none; + } + .content p { + margin: 0; + font-family: Roboto; + font-style: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; + } + .content.subtitle { + color: var(--secondary-text-color); + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + .entities { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: 4px 10px; + } + .entities .entity { + display: flex; + flex-direction: row; + white-space: nowrap; + align-items: center; + gap: 3px; + color: var(--secondary-text-color); + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + letter-spacing: 0.1px; + --mdc-icon-size: 14px; + } + .entities .entity ha-state-icon { + --ha-icon-display: block; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-heading-card": HuiHeadingCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 0721fe8a9d..a957bef817 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -502,3 +502,18 @@ export interface TileCardConfig extends LovelaceCardConfig { icon_double_tap_action?: ActionConfig; features?: LovelaceCardFeatureConfig[]; } + +export interface HeadingCardEntityConfig { + entity: string; + content?: string | string[]; + icon?: string; + tap_action?: ActionConfig; +} + +export interface HeadingCardConfig extends LovelaceCardConfig { + heading_style?: "title" | "subtitle"; + heading?: string; + icon?: string; + tap_action?: ActionConfig; + entities?: (string | HeadingCardEntityConfig)[]; +} diff --git a/src/panels/lovelace/components/hui-card-edit-mode.ts b/src/panels/lovelace/components/hui-card-edit-mode.ts index 3fd2e1c057..3c55b20e43 100644 --- a/src/panels/lovelace/components/hui-card-edit-mode.ts +++ b/src/panels/lovelace/components/hui-card-edit-mode.ts @@ -275,9 +275,9 @@ export class HuiCardEditMode extends LitElement { position: relative; color: var(--primary-text-color); border-radius: 50%; - padding: 12px; + padding: 8px; background: var(--secondary-background-color); - --mdc-icon-size: 24px; + --mdc-icon-size: 20px; } .more { position: absolute; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index d0f7a4949b..7b2534ff1e 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -10,6 +10,7 @@ import "../cards/hui-sensor-card"; import "../cards/hui-thermostat-card"; import "../cards/hui-weather-forecast-card"; import "../cards/hui-tile-card"; +import "../cards/hui-heading-card"; import { createLovelaceElement, getLovelaceElementClass, @@ -29,6 +30,7 @@ const ALWAYS_LOADED_TYPES = new Set([ "thermostat", "weather-forecast", "tile", + "heading", ]); const LAZY_LOAD_TYPES = { diff --git a/src/panels/lovelace/editor/config-elements/hui-entities-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entities-editor.ts new file mode 100644 index 0000000000..e47abf3280 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-entities-editor.ts @@ -0,0 +1,296 @@ +import { mdiDelete, mdiDrag, mdiPencil, mdiPlus } from "@mdi/js"; +import { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { preventDefault } from "../../../../common/dom/prevent_default"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import "../../../../components/entity/ha-entity-picker"; +import type { HaEntityPicker } from "../../../../components/entity/ha-entity-picker"; +import "../../../../components/ha-button"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-list-item"; +import "../../../../components/ha-sortable"; +import "../../../../components/ha-svg-icon"; +import { HomeAssistant } from "../../../../types"; + +type EntityConfig = { + entity: string; +}; + +declare global { + interface HASSDomEvents { + "edit-entity": { index: number }; + } +} + +@customElement("hui-entities-editor") +export class HuiEntitiesEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public entities?: EntityConfig[]; + + @query(".add-container", true) private _addContainer?: HTMLDivElement; + + @query("ha-entity-picker") private _entityPicker?: HaEntityPicker; + + @state() private _addMode = false; + + private _opened = false; + + private _entitiesKeys = new WeakMap(); + + private _getKey(entity: EntityConfig) { + if (!this._entitiesKeys.has(entity)) { + this._entitiesKeys.set(entity, Math.random().toString()); + } + + return this._entitiesKeys.get(entity)!; + } + + protected render() { + if (!this.hass) { + return nothing; + } + + return html` + ${this.entities + ? html` + +
+ ${repeat( + this.entities, + (entityConf) => this._getKey(entityConf), + (entityConf, index) => { + const editable = true; + + const entityId = entityConf.entity; + const stateObj = this.hass.states[entityId]; + const name = stateObj + ? stateObj.attributes.friendly_name + : undefined; + return html` +
+
+ +
+
+ ${name || entityId} +
+ ${editable + ? html` + + ` + : nothing} + +
+ `; + } + )} +
+
+ ` + : nothing} +
+ + + + ${this._renderPicker()} +
+ `; + } + + private _renderPicker() { + if (!this._addMode) { + return nothing; + } + return html` + + + + `; + } + + private _onClosed(ev) { + ev.stopPropagation(); + ev.target.open = true; + } + + private async _onOpened() { + if (!this._addMode) { + return; + } + await this._entityPicker?.focus(); + await this._entityPicker?.open(); + this._opened = true; + } + + private _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { + if (this._opened && !ev.detail.value) { + this._opened = false; + this._addMode = false; + } + } + + private async _addEntity(ev): Promise { + ev.stopPropagation(); + this._addMode = true; + } + + private _entityPicked(ev) { + ev.stopPropagation(); + if (!ev.detail.value) { + return; + } + const newEntity: EntityConfig = { entity: ev.detail.value }; + const newEntities = (this.entities || []).concat(newEntity); + fireEvent(this, "entities-changed", { entities: newEntities }); + } + + private _entityMoved(ev: CustomEvent): void { + ev.stopPropagation(); + const { oldIndex, newIndex } = ev.detail; + + const newEntities = (this.entities || []).concat(); + + newEntities.splice(newIndex, 0, newEntities.splice(oldIndex, 1)[0]); + + fireEvent(this, "entities-changed", { entities: newEntities }); + } + + private _removeEntity(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + const newEntities = (this.entities || []).concat(); + + newEntities.splice(index, 1); + + fireEvent(this, "entities-changed", { entities: newEntities }); + } + + private _editEntity(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + fireEvent(this, "edit-entity", { + index, + }); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: flex !important; + flex-direction: column; + } + ha-button { + margin-top: 8px; + } + .entity { + display: flex; + align-items: center; + } + .entity .handle { + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab; + padding-right: 8px; + padding-inline-end: 8px; + padding-inline-start: initial; + direction: var(--direction); + } + .entity .handle > * { + pointer-events: none; + } + + .entity-content { + height: 60px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-grow: 1; + } + + .entity-content div { + display: flex; + flex-direction: column; + } + + .remove-icon, + .edit-icon { + --mdc-icon-button-size: 36px; + color: var(--secondary-text-color); + } + + .secondary { + font-size: 12px; + color: var(--secondary-text-color); + } + + li[divider] { + border-bottom-color: var(--divider-color); + } + + .add-container { + position: relative; + width: 100%; + } + + mwc-menu-surface { + --mdc-menu-min-width: 100%; + } + + ha-entity-picker { + display: block; + width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-entities-editor": HuiEntitiesEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts new file mode 100644 index 0000000000..5c32d1ddb4 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-heading-card-editor.ts @@ -0,0 +1,352 @@ +import { mdiGestureTap, mdiListBox } from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { cache } from "lit/directives/cache"; +import memoizeOne from "memoize-one"; +import { + any, + array, + assert, + assign, + literal, + object, + optional, + string, + union, +} from "superstruct"; +import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-expansion-panel"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import "../../../../components/ha-svg-icon"; +import type { HomeAssistant } from "../../../../types"; +import type { + HeadingCardConfig, + HeadingCardEntityConfig, +} from "../../cards/types"; +import { UiAction } from "../../components/hui-action-editor"; +import type { LovelaceCardEditor } from "../../types"; +import "../hui-sub-form-editor"; +import { processEditorEntities } from "../process-editor-entities"; +import { actionConfigStruct } from "../structs/action-struct"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import { SubFormEditorData } from "../types"; +import { configElementStyle } from "./config-elements-style"; +import "./hui-entities-editor"; + +const actions: UiAction[] = ["navigate", "url", "perform-action", "none"]; + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + heading_style: optional(union([literal("title"), literal("subtitle")])), + heading: optional(string()), + icon: optional(string()), + tap_action: optional(actionConfigStruct), + entities: optional(array(any())), + }) +); + +const entityConfigStruct = object({ + entity: string(), + content: optional(union([string(), array(string())])), + icon: optional(string()), + tap_action: optional(actionConfigStruct), +}); + +@customElement("hui-heading-card-editor") +export class HuiHeadingCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: HeadingCardConfig; + + @state() + private _entityFormEditorData?: SubFormEditorData; + + public setConfig(config: HeadingCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + } + + public _assertEntityConfig(config: HeadingCardEntityConfig): void { + assert(config, entityConfigStruct); + } + + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "heading_style", + selector: { + select: { + mode: "dropdown", + options: ["title", "subtitle"].map((value) => ({ + label: localize( + `ui.panel.lovelace.editor.card.heading.heading_style_options.${value}` + ), + value: value, + })), + }, + }, + }, + { name: "heading", selector: { text: {} } }, + { + name: "icon", + selector: { + icon: {}, + }, + }, + { + name: "interactions", + type: "expandable", + flatten: true, + iconPath: mdiGestureTap, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "none", + actions, + }, + }, + }, + ], + }, + ] as const satisfies readonly HaFormSchema[] + ); + + private _entitySchema = memoizeOne( + () => + [ + { + name: "entity", + selector: { entity: {} }, + }, + { + name: "icon", + selector: { icon: {} }, + context: { icon_entity: "entity" }, + }, + { + name: "content", + selector: { ui_state_content: {} }, + context: { filter_entity: "entity" }, + }, + { + name: "interactions", + type: "expandable", + flatten: true, + iconPath: mdiGestureTap, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "none", + }, + }, + }, + ], + }, + ] as const satisfies readonly HaFormSchema[] + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + return cache( + this._entityFormEditorData ? this._renderEntityForm() : this._renderForm() + ); + } + + private _renderEntityForm() { + const schema = this._entitySchema(); + return html` + + + `; + } + + private _entities = memoizeOne((entities: HeadingCardConfig["entities"]) => + processEditorEntities(entities || []) + ); + + private _renderForm() { + const data = { + ...this._config!, + }; + + if (!data.heading_style) { + data.heading_style = "title"; + } + + const schema = this._schema(this.hass!.localize); + + return html` + + +

+ + ${this.hass!.localize( + "ui.panel.lovelace.editor.card.heading.entities" + )} +

+
+ + +
+
+ `; + } + + private _entitiesChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const config = { + ...this._config, + entities: ev.detail.entities as HeadingCardEntityConfig[], + }; + + fireEvent(this, "config-changed", { config }); + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const config = ev.detail.value as HeadingCardConfig; + + fireEvent(this, "config-changed", { config }); + } + + private _subFormChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const value = ev.detail.value; + + const newEntities = this._config!.entities + ? [...this._config!.entities] + : []; + + if (!value) { + newEntities.splice(this._entityFormEditorData!.index!, 1); + this._goBack(); + } else { + newEntities[this._entityFormEditorData!.index!] = value; + } + + this._config = { ...this._config!, entities: newEntities }; + + this._entityFormEditorData = { + ...this._entityFormEditorData!, + data: value, + }; + + fireEvent(this, "config-changed", { config: this._config }); + } + + private _editEntity(ev: HASSDomEvent<{ index: number }>): void { + const entities = this._entities(this._config!.entities); + this._entityFormEditorData = { + data: entities[ev.detail.index], + index: ev.detail.index, + }; + } + + private _goBack(): void { + this._entityFormEditorData = undefined; + } + + private _computeEntityLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "content": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.heading.entity_config.${schema.name}` + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + } + }; + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "heading_style": + case "heading": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.heading.${schema.name}` + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + } + }; + + static get styles() { + return [ + configElementStyle, + css` + .container { + display: flex; + flex-direction: column; + } + ha-form { + display: block; + margin-bottom: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-heading-card-editor": HuiHeadingCardEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts index 636ed4b85b..7aafbb5900 100644 --- a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts @@ -181,7 +181,7 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { if (ev.detail && ev.detail.entities) { this._config = { ...this._config!, entities: ev.detail.entities }; - this._configEntities = processEditorEntities(this._config.entities); + this._configEntities = processEditorEntities(this._config.entities || []); fireEvent(this, "config-changed", { config: this._config! }); } } diff --git a/src/panels/lovelace/editor/hui-sub-form-editor.ts b/src/panels/lovelace/editor/hui-sub-form-editor.ts new file mode 100644 index 0000000000..014ff865f3 --- /dev/null +++ b/src/panels/lovelace/editor/hui-sub-form-editor.ts @@ -0,0 +1,190 @@ +import "@material/mwc-button"; +import { mdiCodeBraces, mdiListBoxOutline } from "@mdi/js"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-form/ha-form"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-icon-button-prev"; +import "../../../components/ha-yaml-editor"; +import "../../../components/ha-alert"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceConfigForm } from "../types"; +import type { EditSubFormEvent } from "./types"; +import { handleStructError } from "../../../common/structs/handle-errors"; + +declare global { + interface HASSDomEvents { + "go-back": undefined; + "edit-sub-form": EditSubFormEvent; + } +} + +@customElement("hui-sub-form-editor") +export class HuiSubFormEditor extends LitElement { + public hass!: HomeAssistant; + + @property() public label?: string; + + @property({ attribute: false }) public data!: T; + + public schema!: LovelaceConfigForm["schema"]; + + public assertConfig?: (config: T) => void; + + public computeLabel?: LovelaceConfigForm["computeLabel"]; + + public computeHelper?: LovelaceConfigForm["computeHelper"]; + + @state() public _yamlMode = false; + + @state() private _errors?: string[]; + + @state() private _warnings?: string[]; + + protected render(): TemplateResult { + const uiAvailable = !this.hasWarning && !this.hasError; + + return html` +
+
+ + ${this.label} +
+ +
+ ${this._yamlMode + ? html` + + ` + : html` + + + `} + ${this.hasError + ? html` + + ${this.hass.localize("ui.errors.config.error_detected")}: +
+
    + ${this._errors!.map((error) => html`
  • ${error}
  • `)} +
+
+ ` + : nothing} + ${this.hasWarning + ? html` + + ${this._warnings!.length > 0 && this._warnings![0] !== undefined + ? html` +
    + ${this._warnings!.map( + (warning) => html`
  • ${warning}
  • ` + )} +
+ ` + : nothing} + ${this.hass.localize("ui.errors.config.edit_in_yaml_supported")} +
+ ` + : nothing} + `; + } + + protected willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has("data")) { + if (this.assertConfig) { + try { + this.assertConfig(this.data); + this._warnings = undefined; + this._errors = undefined; + } catch (err: any) { + const msgs = handleStructError(this.hass, err); + this._warnings = msgs.warnings ?? [err.message]; + this._errors = msgs.errors || undefined; + this._yamlMode = true; + } + } + } + } + + public get hasWarning(): boolean { + return this._warnings !== undefined && this._warnings.length > 0; + } + + public get hasError(): boolean { + return this._errors !== undefined && this._errors.length > 0; + } + + private _goBack(): void { + fireEvent(this, "go-back"); + } + + private _toggleMode(): void { + this._yamlMode = !this._yamlMode; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const value = (ev.detail.value ?? (ev.target as any).value ?? {}) as T; + fireEvent(this, "value-changed", { value }); + } + + static get styles(): CSSResultGroup { + return css` + .header { + display: flex; + justify-content: space-between; + align-items: center; + } + .back-title { + display: flex; + align-items: center; + font-size: 18px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-sub-form-editor": HuiSubFormEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index c84df7d6aa..fbf1e81db5 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -125,4 +125,8 @@ export const coreCards: Card[] = [ { type: "todo-list", }, + { + type: "heading", + showElement: true, + }, ]; diff --git a/src/panels/lovelace/editor/process-editor-entities.ts b/src/panels/lovelace/editor/process-editor-entities.ts index 53c1451fb5..6637389e7c 100644 --- a/src/panels/lovelace/editor/process-editor-entities.ts +++ b/src/panels/lovelace/editor/process-editor-entities.ts @@ -1,6 +1,8 @@ import { EntityConfig } from "../entity-rows/types"; -export function processEditorEntities(entities): EntityConfig[] { +export function processEditorEntities( + entities: (any | string)[] +): EntityConfig[] { return entities.map((entityConf) => { if (typeof entityConf === "string") { return { entity: entityConf }; diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index 0aacf3c406..c8de2a15b0 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -102,3 +102,12 @@ export interface SubElementEditorConfig { export interface EditSubElementEvent { subElementConfig: SubElementEditorConfig; } + +export interface SubFormEditorData { + index?: number; + data?: T; +} + +export interface EditSubFormEvent { + subFormData: SubFormEditorData; +} diff --git a/src/panels/lovelace/sections/hui-grid-section.ts b/src/panels/lovelace/sections/hui-grid-section.ts index 8378b3e173..b470416f44 100644 --- a/src/panels/lovelace/sections/hui-grid-section.ts +++ b/src/panels/lovelace/sections/hui-grid-section.ts @@ -157,7 +157,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement { } private _addCard() { - fireEvent(this, "ll-create-card", { suggested: ["tile"] }); + fireEvent(this, "ll-create-card", { suggested: ["tile", "heading"] }); } static get styles(): CSSResultGroup { @@ -182,7 +182,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement { var(--grid-column-count), minmax(0, 1fr) ); - grid-auto-rows: minmax(var(--row-height), auto); + grid-auto-rows: auto; row-gap: var(--row-gap); column-gap: var(--column-gap); padding: 0; diff --git a/src/translations/en.json b/src/translations/en.json index dc87af66c3..88a984bd2f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5447,6 +5447,9 @@ "low_carbon_energy_consumed": "Low-carbon energy consumed", "low_carbon_energy_not_calculated": "Consumed low-carbon energy couldn't be calculated" } + }, + "heading": { + "default_heading": "Kitchen" } }, "unused_entities": { @@ -5979,6 +5982,7 @@ "show_name": "Show name", "show_state": "Show state", "tap_action": "Tap behavior", + "interactions": "Interactions", "title": "Title", "theme": "Theme", "unit": "Unit", @@ -5992,6 +5996,21 @@ "custom_cards": "Custom cards", "features": "Features" }, + "heading": { + "name": "Heading", + "description": "The heading card structures your dashboard by providing title, icon and navigation.", + "heading": "Heading", + "heading_style": "Heading style", + "heading_style_options": { + "title": "Title", + "subtitle": "Subtitle" + }, + "entities": "Entities", + "entity_config": { + "content": "Content" + }, + "default_heading": "Kitchen" + }, "map": { "name": "Map", "geo_location_sources": "Geolocation sources", @@ -6135,6 +6154,13 @@ "custom_badges": "Custom badges" } }, + "entities": { + "name": "Entities", + "add": "Add entity", + "edit": "Edit entity", + "remove": "Remove entity", + "form-label": "Edit entity" + }, "features": { "name": "Features", "not_compatible": "Not compatible",