From 321a085c0e10634882cbb39bfbf611c877d3bdc4 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 24 Jun 2024 22:10:31 +0200 Subject: [PATCH] Resize card editor (#21115) --- src/common/dom/prevent_default.ts | 1 + src/components/ha-grid-size-picker.ts | 233 +++++++++++ src/data/lovelace.ts | 1 + src/panels/lovelace/cards/hui-card.ts | 6 +- .../card-editor/ha-grid-layout-slider.ts | 389 ++++++++++++++++++ .../card-editor/hui-card-element-editor.ts | 29 +- .../card-editor/hui-card-layout-editor.ts | 266 ++++++++++++ .../card-editor/hui-card-visibility-editor.ts | 14 +- .../card-editor/hui-dialog-edit-card.ts | 13 + .../conditions/ha-card-condition-editor.ts | 2 +- .../lovelace/sections/hui-grid-section.ts | 19 +- src/panels/lovelace/sections/hui-section.ts | 3 +- src/panels/lovelace/views/hui-view.ts | 1 + src/translations/en.json | 13 +- 14 files changed, 971 insertions(+), 19 deletions(-) create mode 100644 src/common/dom/prevent_default.ts create mode 100644 src/components/ha-grid-size-picker.ts create mode 100644 src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts create mode 100644 src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts diff --git a/src/common/dom/prevent_default.ts b/src/common/dom/prevent_default.ts new file mode 100644 index 000000000000..3124b2643341 --- /dev/null +++ b/src/common/dom/prevent_default.ts @@ -0,0 +1 @@ +export const preventDefault = (ev) => ev.preventDefault(); diff --git a/src/components/ha-grid-size-picker.ts b/src/components/ha-grid-size-picker.ts new file mode 100644 index 000000000000..dca2cbd2fbe1 --- /dev/null +++ b/src/components/ha-grid-size-picker.ts @@ -0,0 +1,233 @@ +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "./ha-icon-button"; +import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider"; + +import { mdiRestore } from "@mdi/js"; +import { styleMap } from "lit/directives/style-map"; +import { fireEvent } from "../common/dom/fire_event"; +import { HomeAssistant } from "../types"; + +type GridSizeValue = { + rows?: number; + columns?: number; +}; + +@customElement("ha-grid-size-picker") +export class HaGridSizeEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: GridSizeValue; + + @property({ attribute: false }) public rows = 6; + + @property({ attribute: false }) public columns = 4; + + @property({ attribute: false }) public rowMin?: number; + + @property({ attribute: false }) public rowMax?: number; + + @property({ attribute: false }) public columnMin?: number; + + @property({ attribute: false }) public columnMax?: number; + + @property({ attribute: false }) public isDefault?: boolean; + + @state() public _localValue?: GridSizeValue = undefined; + + protected willUpdate(changedProperties) { + if (changedProperties.has("value")) { + this._localValue = this.value; + } + } + + protected render() { + return html` +
+ + + ${!this.isDefault + ? html` + + + ` + : nothing} +
+
+ ${Array(this.rows * this.columns) + .fill(0) + .map((_, index) => { + const row = Math.floor(index / this.columns) + 1; + const column = (index % this.columns) + 1; + const disabled = + (this.rowMin !== undefined && row < this.rowMin) || + (this.rowMax !== undefined && row > this.rowMax) || + (this.columnMin !== undefined && column < this.columnMin) || + (this.columnMax !== undefined && column > this.columnMax); + return html` +
+ `; + })} +
+
+
+
+
+
+ `; + } + + _cellClick(ev) { + const cell = ev.currentTarget as HTMLElement; + if (cell.getAttribute("disabled") !== null) return; + const rows = Number(cell.getAttribute("data-row")); + const columns = Number(cell.getAttribute("data-column")); + fireEvent(this, "value-changed", { + value: { rows, columns }, + }); + } + + private _valueChanged(ev) { + ev.stopPropagation(); + const key = ev.currentTarget.id; + const newValue = { + ...this.value, + [key]: ev.detail.value, + }; + fireEvent(this, "value-changed", { value: newValue }); + } + + private _reset(ev) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { + rows: undefined, + columns: undefined, + }, + }); + } + + private _sliderMoved(ev) { + ev.stopPropagation(); + const key = ev.currentTarget.id; + const value = ev.detail.value; + if (value === undefined) return; + this._localValue = { + ...this.value, + [key]: ev.detail.value, + }; + } + + static styles = [ + css` + .grid { + display: grid; + grid-template-areas: + "reset column-slider" + "row-slider preview"; + grid-template-rows: auto 1fr; + grid-template-columns: auto 1fr; + gap: 8px; + } + #columns { + grid-area: column-slider; + } + #rows { + grid-area: row-slider; + } + .reset { + grid-area: reset; + } + .preview { + position: relative; + grid-area: preview; + aspect-ratio: 1 / 1; + } + .preview > div { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + display: grid; + grid-template-columns: repeat(var(--total-columns), 1fr); + grid-template-rows: repeat(var(--total-rows), 1fr); + gap: 4px; + } + .preview .cell { + background-color: var(--disabled-color); + grid-column: span 1; + grid-row: span 1; + border-radius: 4px; + opacity: 0.2; + cursor: pointer; + } + .preview .cell[disabled] { + opacity: 0.05; + cursor: initial; + } + .selected { + pointer-events: none; + } + .selected .cell { + background-color: var(--primary-color); + grid-column: 1 / span var(--columns, 0); + grid-row: 1 / span var(--rows, 0); + opacity: 0.5; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-grid-size-picker": HaGridSizeEditor; + } +} diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 983d2dde5b19..bfd794ef8de0 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -30,6 +30,7 @@ export interface LovelaceViewElement extends HTMLElement { export interface LovelaceSectionElement extends HTMLElement { hass?: HomeAssistant; lovelace?: Lovelace; + preview?: boolean; viewIndex?: number; index?: number; cards?: HuiCard[]; diff --git a/src/panels/lovelace/cards/hui-card.ts b/src/panels/lovelace/cards/hui-card.ts index 8f313ff62d00..e8091f2b871f 100644 --- a/src/panels/lovelace/cards/hui-card.ts +++ b/src/panels/lovelace/cards/hui-card.ts @@ -86,6 +86,10 @@ export class HuiCard extends ReactiveElement { return configOptions; } + public getElementLayoutOptions(): LovelaceLayoutOptions { + return this._element?.getLayoutOptions?.() ?? {}; + } + private _createElement(config: LovelaceCardConfig) { const element = createCardElement(config); element.hass = this.hass; @@ -155,7 +159,7 @@ export class HuiCard extends ReactiveElement { protected willUpdate( changedProps: PropertyValueMap | Map ): void { - if (changedProps.has("hass") || changedProps.has("lovelace")) { + if (changedProps.has("hass") || changedProps.has("preview")) { this._updateVisibility(); } } diff --git a/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts new file mode 100644 index 000000000000..2ef6a61f0c40 --- /dev/null +++ b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts @@ -0,0 +1,389 @@ +import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs"; +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 { styleMap } from "lit/directives/style-map"; +import { fireEvent } from "../../../../common/dom/fire_event"; + +declare global { + interface HASSDomEvents { + "slider-moved": { value?: number }; + } +} + +const A11Y_KEY_CODES = new Set([ + "ArrowRight", + "ArrowUp", + "ArrowLeft", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", +]); + +@customElement("ha-grid-layout-slider") +export class HaGridLayoutSlider extends LitElement { + @property({ type: Boolean, reflect: true }) + public disabled = false; + + @property({ type: Boolean, reflect: true }) + public vertical = false; + + @property({ attribute: "touch-action" }) + public touchAction?: string; + + @property({ type: Number }) + public value?: number; + + @property({ type: Number }) + public step = 1; + + @property({ type: Number }) + public min = 1; + + @property({ type: Number }) + public max = 4; + + @property({ type: Number }) + public range?: number; + + @state() + public pressed = false; + + private _mc?: HammerManager; + + private get _range() { + return this.range ?? this.max; + } + + private _valueToPercentage(value: number) { + const percentage = this._boundedValue(value) / this._range; + return percentage; + } + + private _percentageToValue(percentage: number) { + return this._range * percentage; + } + + private _steppedValue(value: number) { + return Math.round(value / this.step) * this.step; + } + + private _boundedValue(value: number) { + return Math.min(Math.max(value, this.min), this.max); + } + + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this.setupListeners(); + this.setAttribute("role", "slider"); + if (!this.hasAttribute("tabindex")) { + this.setAttribute("tabindex", "0"); + } + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (changedProps.has("value")) { + const valuenow = this._steppedValue(this.value ?? 0); + this.setAttribute("aria-valuenow", valuenow.toString()); + this.setAttribute("aria-valuetext", valuenow.toString()); + } + if (changedProps.has("min")) { + this.setAttribute("aria-valuemin", this.min.toString()); + } + if (changedProps.has("max")) { + this.setAttribute("aria-valuemax", this.max.toString()); + } + if (changedProps.has("vertical")) { + const orientation = this.vertical ? "vertical" : "horizontal"; + this.setAttribute("aria-orientation", orientation); + } + } + + connectedCallback(): void { + super.connectedCallback(); + this.setupListeners(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.destroyListeners(); + } + + @query("#slider") + private slider; + + setupListeners() { + if (this.slider && !this._mc) { + this._mc = new Manager(this.slider, { + touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"), + }); + this._mc.add( + new Pan({ + threshold: 10, + direction: DIRECTION_ALL, + enable: true, + }) + ); + + this._mc.add(new Tap({ event: "singletap" })); + + let savedValue; + this._mc.on("panstart", () => { + if (this.disabled) return; + this.pressed = true; + savedValue = this.value; + }); + this._mc.on("pancancel", () => { + if (this.disabled) return; + this.pressed = false; + this.value = savedValue; + }); + this._mc.on("panmove", (e) => { + if (this.disabled) return; + const percentage = this._getPercentageFromEvent(e); + this.value = this._percentageToValue(percentage); + const value = this._steppedValue(this._boundedValue(this.value)); + fireEvent(this, "slider-moved", { value }); + }); + this._mc.on("panend", (e) => { + if (this.disabled) return; + this.pressed = false; + const percentage = this._getPercentageFromEvent(e); + const value = this._percentageToValue(percentage); + this.value = this._steppedValue(this._boundedValue(value)); + fireEvent(this, "slider-moved", { value: undefined }); + fireEvent(this, "value-changed", { value: this.value }); + }); + + this._mc.on("singletap", (e) => { + if (this.disabled) return; + const percentage = this._getPercentageFromEvent(e); + const value = this._percentageToValue(percentage); + this.value = this._steppedValue(this._boundedValue(value)); + fireEvent(this, "value-changed", { value: this.value }); + }); + + this.addEventListener("keydown", this._handleKeyDown); + this.addEventListener("keyup", this._handleKeyUp); + } + } + + destroyListeners() { + if (this._mc) { + this._mc.destroy(); + this._mc = undefined; + } + this.removeEventListener("keydown", this._handleKeyDown); + this.removeEventListener("keyup", this._handleKeyUp); + } + + private get _tenPercentStep() { + return Math.max(this.step, (this.max - this.min) / 10); + } + + _handleKeyDown(e: KeyboardEvent) { + if (!A11Y_KEY_CODES.has(e.code)) return; + e.preventDefault(); + switch (e.code) { + case "ArrowRight": + case "ArrowUp": + this.value = this._boundedValue((this.value ?? 0) + this.step); + break; + case "ArrowLeft": + case "ArrowDown": + this.value = this._boundedValue((this.value ?? 0) - this.step); + break; + case "PageUp": + this.value = this._steppedValue( + this._boundedValue((this.value ?? 0) + this._tenPercentStep) + ); + break; + case "PageDown": + this.value = this._steppedValue( + this._boundedValue((this.value ?? 0) - this._tenPercentStep) + ); + break; + case "Home": + this.value = this.min; + break; + case "End": + this.value = this.max; + break; + } + fireEvent(this, "slider-moved", { value: this.value }); + } + + _handleKeyUp(e: KeyboardEvent) { + if (!A11Y_KEY_CODES.has(e.code)) return; + e.preventDefault(); + fireEvent(this, "value-changed", { value: this.value }); + } + + private _getPercentageFromEvent = (e: HammerInput) => { + if (this.vertical) { + const y = e.center.y; + const offset = e.target.getBoundingClientRect().top; + const total = e.target.clientHeight; + return Math.max(Math.min(1, (y - offset) / total), 0); + } + const x = e.center.x; + const offset = e.target.getBoundingClientRect().left; + const total = e.target.clientWidth; + return Math.max(Math.min(1, (x - offset) / total), 0); + }; + + protected render(): TemplateResult { + return html` +
+
+
+
+
+
+
+ ${this.value !== undefined + ? html`
` + : nothing} +
+
+ `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + --grid-layout-slider: 48px; + height: var(--grid-layout-slider); + width: 100%; + outline: none; + transition: box-shadow 180ms ease-in-out; + } + :host(:focus-visible) { + box-shadow: 0 0 0 2px var(--primary-color); + } + :host([vertical]) { + width: var(--grid-layout-slider); + height: 100%; + } + .container { + position: relative; + height: 100%; + width: 100%; + } + .slider { + position: relative; + height: 100%; + width: 100%; + transform: translateZ(0); + overflow: visible; + cursor: pointer; + } + .slider * { + pointer-events: none; + } + .track { + position: absolute; + inset: 0; + margin: auto; + height: 16px; + width: 100%; + border-radius: 8px; + overflow: hidden; + } + :host([vertical]) .track { + width: 16px; + height: 100%; + } + .background { + position: absolute; + inset: 0; + background: var(--disabled-color); + opacity: 0.5; + } + .active { + position: absolute; + background: grey; + top: 0; + right: calc(var(--max) * 100%); + bottom: 0; + left: calc(var(--min) * 100%); + } + :host([vertical]) .active { + top: calc(var(--min) * 100%); + right: 0; + bottom: calc(var(--max) * 100%); + left: 0; + } + .handle { + position: absolute; + top: 0; + height: 100%; + width: 16px; + transform: translate(-50%, 0); + background: var(--card-background-color); + left: calc(var(--value, 0%) * 100%); + transition: + left 180ms ease-in-out, + top 180ms ease-in-out; + } + :host([vertical]) .handle { + transform: translate(0, -50%); + left: 0; + top: calc(var(--value, 0%) * 100%); + height: 16px; + width: 100%; + } + .handle::after { + position: absolute; + inset: 0; + width: 4px; + border-radius: 2px; + height: 100%; + margin: auto; + background: grey; + content: ""; + } + :host([vertical]) .handle::after { + height: 4px; + width: 100%; + } + :host(:disabled) .slider { + cursor: not-allowed; + } + .pressed .handle { + transition: none; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-grid-layout-slider": HaGridLayoutSlider; + } +} diff --git a/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts index 6300c50e11e9..aa4806689a17 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts @@ -6,17 +6,21 @@ import { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; import { getCardElementClass } from "../../create-element/create-card-element"; import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types"; import { HuiElementEditor } from "../hui-element-editor"; +import "./hui-card-layout-editor"; import "./hui-card-visibility-editor"; -const TABS = ["config", "visibility"] as const; +type Tab = "config" | "visibility" | "layout"; @customElement("hui-card-element-editor") export class HuiCardElementEditor extends HuiElementEditor { - @state() private _curTab: (typeof TABS)[number] = TABS[0]; + @state() private _curTab: Tab = "config"; @property({ type: Boolean, attribute: "show-visibility-tab" }) public showVisibilityTab = false; + @property({ type: Boolean, attribute: "show-layout-tab" }) + public showLayoutTab = false; + protected async getConfigElement(): Promise { const elClass = await getCardElementClass(this.configElementType!); @@ -52,7 +56,11 @@ export class HuiCardElementEditor extends HuiElementEditor { } protected renderConfigElement(): TemplateResult { - if (!this.showVisibilityTab) return super.renderConfigElement(); + const displayedTabs: Tab[] = ["config"]; + if (this.showVisibilityTab) displayedTabs.push("visibility"); + if (this.showLayoutTab) displayedTabs.push("layout"); + + if (displayedTabs.length === 1) return super.renderConfigElement(); let content: TemplateResult<1> | typeof nothing = nothing; @@ -69,19 +77,28 @@ export class HuiCardElementEditor extends HuiElementEditor { > `; break; + case "layout": + content = html` + + + `; } return html` - ${TABS.map( + ${displayedTabs.map( (tab, index) => html` ${this.hass.localize( - `ui.panel.lovelace.editor.edit_card.tab-${tab}` + `ui.panel.lovelace.editor.edit_card.tab_${tab}` )} ` diff --git a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts new file mode 100644 index 000000000000..c09179d3935d --- /dev/null +++ b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts @@ -0,0 +1,266 @@ +import type { ActionDetail } from "@material/mwc-list"; +import { mdiCheck, mdiDotsVertical } from "@mdi/js"; +import { LitElement, PropertyValues, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { preventDefault } from "../../../../common/dom/prevent_default"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import "../../../../components/ha-button"; +import "../../../../components/ha-button-menu"; +import "../../../../components/ha-grid-size-picker"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-list-item"; +import "../../../../components/ha-slider"; +import "../../../../components/ha-svg-icon"; +import "../../../../components/ha-yaml-editor"; +import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; +import { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; +import { haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { HuiCard } from "../../cards/hui-card"; +import { DEFAULT_GRID_OPTIONS } from "../../sections/hui-grid-section"; +import { LovelaceLayoutOptions } from "../../types"; + +@customElement("hui-card-layout-editor") +export class HuiCardLayoutEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public config!: LovelaceCardConfig; + + @state() _defaultLayoutOptions?: LovelaceLayoutOptions; + + @state() public _yamlMode = false; + + @state() public _uiAvailable = true; + + @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; + + private _cardElement?: HuiCard; + + private _gridSizeValue = memoizeOne( + ( + options?: LovelaceLayoutOptions, + defaultOptions?: LovelaceLayoutOptions + ) => ({ + rows: + options?.grid_rows ?? + defaultOptions?.grid_rows ?? + DEFAULT_GRID_OPTIONS.grid_rows, + columns: + options?.grid_columns ?? + defaultOptions?.grid_columns ?? + DEFAULT_GRID_OPTIONS.grid_columns, + }) + ); + + private _isDefault = memoizeOne( + (options?: LovelaceLayoutOptions) => + options?.grid_columns === undefined && options?.grid_rows === undefined + ); + + render() { + return html` +
+

+ ${this.hass.localize( + `ui.panel.lovelace.editor.edit_card.layout.explanation` + )} +

+ + + + + + ${this.hass.localize("ui.panel.lovelace.editor.edit_card.edit_ui")} + ${!this._yamlMode + ? html` + + ` + : nothing} + + + + ${this.hass.localize( + "ui.panel.lovelace.editor.edit_card.edit_yaml" + )} + ${this._yamlMode + ? html` + + ` + : nothing} + + +
+ ${this._yamlMode + ? html` + + ` + : html` + + `} + `; + } + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + try { + this._cardElement = document.createElement("hui-card"); + this._cardElement.hass = this.hass; + this._cardElement.preview = true; + this._cardElement.config = this.config; + this._cardElement.addEventListener("card-updated", (ev: Event) => { + ev.stopPropagation(); + this._defaultLayoutOptions = + this._cardElement?.getElementLayoutOptions(); + }); + this._defaultLayoutOptions = this._cardElement.getElementLayoutOptions(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + if (this._cardElement) { + if (changedProps.has("hass")) { + this._cardElement.hass = this.hass; + } + if (changedProps.has("config")) { + this._cardElement.config = this.config; + } + } + } + + private async _handleAction(ev: CustomEvent) { + switch (ev.detail.index) { + case 0: + this._yamlMode = false; + break; + case 1: + this._yamlMode = true; + break; + case 2: + this._reset(); + break; + } + } + + private async _reset() { + const newConfig = { ...this.config }; + delete newConfig.layout_options; + this._yamlEditor?.setValue({}); + fireEvent(this, "value-changed", { value: newConfig }); + } + + private _gridSizeChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const value = ev.detail.value; + + const newConfig: LovelaceCardConfig = { + ...this.config, + layout_options: { + ...this.config.layout_options, + grid_columns: value.columns, + grid_rows: value.rows, + }, + }; + + if (newConfig.layout_options!.grid_columns === undefined) { + delete newConfig.layout_options!.grid_columns; + } + if (newConfig.layout_options!.grid_rows === undefined) { + delete newConfig.layout_options!.grid_rows; + } + if (Object.keys(newConfig.layout_options!).length === 0) { + delete newConfig.layout_options; + } + + fireEvent(this, "value-changed", { value: newConfig }); + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const options = ev.detail.value as LovelaceLayoutOptions; + const newConfig: LovelaceCardConfig = { + ...this.config, + layout_options: options, + }; + fireEvent(this, "value-changed", { value: newConfig }); + } + + static styles = [ + haStyle, + css` + .header { + display: flex; + flex-direction: row; + align-items: flex-start; + } + .header .intro { + flex: 1; + margin: 0; + color: var(--secondary-text-color); + } + .header ha-button-menu { + --mdc-theme-text-primary-on-background: var(--primary-text-color); + margin-top: -8px; + } + .selected_menu_item { + color: var(--primary-color); + } + .disabled { + opacity: 0.5; + pointer-events: none; + } + ha-grid-size-editor { + display: block; + max-width: 250px; + margin: 16px auto; + } + ha-yaml-editor { + display: block; + margin: 16px 0; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-card-layout-editor": HuiCardLayoutEditor; + } +} diff --git a/src/panels/lovelace/editor/card-editor/hui-card-visibility-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-visibility-editor.ts index 9e114dae57af..7f9007f573de 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-visibility-editor.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-visibility-editor.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { LitElement, html, css } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-alert"; @@ -16,11 +16,11 @@ export class HuiCardVisibilityEditor extends LitElement { render() { const conditions = this.config.visibility ?? []; return html` - +

${this.hass.localize( `ui.panel.lovelace.editor.edit_card.visibility.explanation` )} - +

{ const { cards, title, ...containerConfig } = this diff --git a/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts b/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts index f4f0e0e718fa..fa9c8d54816f 100644 --- a/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts +++ b/src/panels/lovelace/editor/conditions/ha-card-condition-editor.ts @@ -1,4 +1,3 @@ -import { preventDefault } from "@fullcalendar/core/internal"; import { ActionDetail } from "@material/mwc-list"; import { mdiCheck, mdiDelete, mdiDotsVertical, mdiFlask } from "@mdi/js"; import { LitElement, PropertyValues, css, html, nothing } from "lit"; @@ -6,6 +5,7 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { dynamicElement } from "../../../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { preventDefault } from "../../../../common/dom/prevent_default"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { handleStructError } from "../../../../common/structs/handle-errors"; import "../../../../components/ha-alert"; diff --git a/src/panels/lovelace/sections/hui-grid-section.ts b/src/panels/lovelace/sections/hui-grid-section.ts index 83dce5856ac5..11fd8aa0afd3 100644 --- a/src/panels/lovelace/sections/hui-grid-section.ts +++ b/src/panels/lovelace/sections/hui-grid-section.ts @@ -14,7 +14,7 @@ import type { HomeAssistant } from "../../../types"; import { HuiCard } from "../cards/hui-card"; import "../components/hui-card-edit-mode"; import { moveCard } from "../editor/config-util"; -import type { Lovelace } from "../types"; +import type { Lovelace, LovelaceLayoutOptions } from "../types"; const CARD_SORTABLE_OPTIONS: HaSortableOptions = { delay: 100, @@ -23,6 +23,11 @@ const CARD_SORTABLE_OPTIONS: HaSortableOptions = { invertedSwapThreshold: 0.7, } as HaSortableOptions; +export const DEFAULT_GRID_OPTIONS: LovelaceLayoutOptions = { + grid_columns: 4, + grid_rows: 1, +}; + export class GridSection extends LitElement implements LovelaceSectionElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -95,11 +100,15 @@ export class GridSection extends LitElement implements LovelaceSectionElement { const card = this.cards![idx]; const layoutOptions = card.getLayoutOptions(); + const columnSize = + layoutOptions.grid_columns ?? DEFAULT_GRID_OPTIONS.grid_columns; + const rowSize = + layoutOptions.grid_rows ?? DEFAULT_GRID_OPTIONS.grid_rows; return html`
{ element.preview = this.preview; }); @@ -129,7 +130,7 @@ export class HuiSection extends ReactiveElement { if (changedProperties.has("_cards")) { this._layoutElement.cards = this._cards; } - if (changedProperties.has("hass") || changedProperties.has("lovelace")) { + if (changedProperties.has("hass") || changedProperties.has("preview")) { this._updateElement(); } } diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 9e515cd3ef90..06df93b7465b 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -210,6 +210,7 @@ export class HUIView extends ReactiveElement { try { element.hass = this.hass; element.lovelace = this.lovelace; + element.preview = this.lovelace.editMode; } catch (e: any) { this._rebuildSection(element, createErrorSectionConfig(e.message)); } diff --git a/src/translations/en.json b/src/translations/en.json index ed0696c663dd..85882f0488d8 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -741,6 +741,11 @@ "last_year": "Last year" } }, + "grid-size-picker": { + "reset_default": "Reset to default size", + "columns": "Number of columns", + "rows": "Number of rows" + }, "relative_time": { "never": "Never" }, @@ -5507,10 +5512,14 @@ "increase_position": "Increase card position", "options": "More options", "search_cards": "Search cards", - "tab-config": "Config", - "tab-visibility": "Visibility", + "tab_config": "Config", + "tab_visibility": "Visibility", + "tab_layout": "Layout", "visibility": { "explanation": "The card will be shown when ALL conditions below are fulfilled. If no conditions are set, the card will always be shown." + }, + "layout": { + "explanation": "Configure how the card will appear on the dashboard. This settings will override the default size and position of the card." } }, "move_card": {