diff --git a/src/components/ha-grid-size-picker.ts b/src/components/ha-grid-size-picker.ts index fa3e7a38206f..f3b5607c21d1 100644 --- a/src/components/ha-grid-size-picker.ts +++ b/src/components/ha-grid-size-picker.ts @@ -66,7 +66,7 @@ export class HaGridSizeEditor extends LitElement { .min=${columnMin} .max=${columnMax} .range=${this.columns} - .value=${fullWidth ? this.columns : columnValue} + .value=${fullWidth ? this.columns : this.value?.columns} @value-changed=${this._valueChanged} @slider-moved=${this._sliderMoved} .disabled=${disabledColumns} @@ -81,7 +81,7 @@ export class HaGridSizeEditor extends LitElement { .max=${rowMax} .range=${this.rows} vertical - .value=${rowValue} + .value=${autoHeight ? rowMin : this.value?.rows} @value-changed=${this._valueChanged} @slider-moved=${this._sliderMoved} .disabled=${disabledRows} diff --git a/src/data/lovelace/config/card.ts b/src/data/lovelace/config/card.ts index 49482b8b2869..2f31503f60e1 100644 --- a/src/data/lovelace/config/card.ts +++ b/src/data/lovelace/config/card.ts @@ -1,11 +1,16 @@ import type { Condition } from "../../../panels/lovelace/common/validate-condition"; -import type { LovelaceLayoutOptions } from "../../../panels/lovelace/types"; +import type { + LovelaceGridOptions, + LovelaceLayoutOptions, +} from "../../../panels/lovelace/types"; export interface LovelaceCardConfig { index?: number; view_index?: number; view_layout?: any; + /** @deprecated Use `grid_options` instead */ layout_options?: LovelaceLayoutOptions; + grid_options?: LovelaceGridOptions; type: string; [key: string]: any; visibility?: Condition[]; diff --git a/src/panels/lovelace/cards/hui-card.ts b/src/panels/lovelace/cards/hui-card.ts index aed6b561fadf..855c4d30404f 100644 --- a/src/panels/lovelace/cards/hui-card.ts +++ b/src/panels/lovelace/cards/hui-card.ts @@ -6,6 +6,7 @@ import type { MediaQueriesListener } from "../../../common/dom/media_query"; import "../../../components/ha-svg-icon"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { HomeAssistant } from "../../../types"; +import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size"; import { computeCardSize } from "../common/compute-card-size"; import { attachConditionMediaQueriesListeners, @@ -13,7 +14,7 @@ import { } from "../common/validate-condition"; import { createCardElement } from "../create-element/create-card-element"; import { createErrorCardConfig } from "../create-element/create-element-base"; -import type { LovelaceCard, LovelaceLayoutOptions } from "../types"; +import type { LovelaceCard, LovelaceGridOptions } from "../types"; declare global { interface HASSDomEvents { @@ -68,20 +69,44 @@ export class HuiCard extends ReactiveElement { return 1; } - public getLayoutOptions(): LovelaceLayoutOptions { - const configOptions = this.config?.layout_options ?? {}; - if (this._element) { - const cardOptions = this._element.getLayoutOptions?.() ?? {}; - return { - ...cardOptions, - ...configOptions, - }; + public getGridOptions(): LovelaceGridOptions { + const elementOptions = this.getElementGridOptions(); + const configOptions = this.getConfigGridOptions(); + return { + ...elementOptions, + ...configOptions, + }; + } + + // options provided by the element + public getElementGridOptions(): LovelaceGridOptions { + if (!this._element) return {}; + + if (this._element.getGridOptions) { + return this._element.getGridOptions(); } - return configOptions; + if (this._element.getLayoutOptions) { + // eslint-disable-next-line no-console + console.warn( + `This card (${this.config?.type}) is using "getLayoutOptions" and it is deprecated, contact the developer to suggest to use "getGridOptions" instead` + ); + const options = migrateLayoutToGridOptions( + this._element.getLayoutOptions() + ); + return options; + } + return {}; } - public getElementLayoutOptions(): LovelaceLayoutOptions { - return this._element?.getLayoutOptions?.() ?? {}; + // options provided by the config + public getConfigGridOptions(): LovelaceGridOptions { + if (this.config?.grid_options) { + return this.config.grid_options; + } + if (this.config?.layout_options) { + return migrateLayoutToGridOptions(this.config.layout_options); + } + return {}; } private _updateElement(config: LovelaceCardConfig) { diff --git a/src/panels/lovelace/common/compute-card-grid-size.ts b/src/panels/lovelace/common/compute-card-grid-size.ts index 81444feef5db..cc0be5ac765a 100644 --- a/src/panels/lovelace/common/compute-card-grid-size.ts +++ b/src/panels/lovelace/common/compute-card-grid-size.ts @@ -1,8 +1,39 @@ import { conditionalClamp } from "../../../common/number/clamp"; -import type { LovelaceLayoutOptions } from "../types"; +import type { LovelaceGridOptions, LovelaceLayoutOptions } from "../types"; + +export const GRID_COLUMN_MULTIPLIER = 3; + +export const multiplyBy = ( + value: T, + multiplier: number +): T => (typeof value === "number" ? ((value * multiplier) as T) : value); + +export const divideBy = ( + value: T, + divider: number +): T => (typeof value === "number" ? (Math.ceil(value / divider) as T) : value); + +export const migrateLayoutToGridOptions = ( + options: LovelaceLayoutOptions +): LovelaceGridOptions => { + const gridOptions: LovelaceGridOptions = { + columns: multiplyBy(options.grid_columns, GRID_COLUMN_MULTIPLIER), + max_columns: multiplyBy(options.grid_max_columns, GRID_COLUMN_MULTIPLIER), + min_columns: multiplyBy(options.grid_min_columns, GRID_COLUMN_MULTIPLIER), + rows: options.grid_rows, + max_rows: options.grid_max_rows, + min_rows: options.grid_min_rows, + }; + for (const [key, value] of Object.entries(gridOptions)) { + if (value === undefined) { + delete gridOptions[key]; + } + } + return gridOptions; +}; export const DEFAULT_GRID_SIZE = { - columns: 4, + columns: 12, rows: "auto", } as CardGridSize; @@ -12,14 +43,14 @@ export type CardGridSize = { }; export const computeCardGridSize = ( - options: LovelaceLayoutOptions + options: LovelaceGridOptions ): CardGridSize => { - const rows = options.grid_rows ?? DEFAULT_GRID_SIZE.rows; - const columns = options.grid_columns ?? DEFAULT_GRID_SIZE.columns; - const minRows = options.grid_min_rows; - const maxRows = options.grid_max_rows; - const minColumns = options.grid_min_columns; - const maxColumns = options.grid_max_columns; + const rows = options.rows ?? DEFAULT_GRID_SIZE.rows; + const columns = options.columns ?? DEFAULT_GRID_SIZE.columns; + const minRows = options.min_rows; + const maxRows = options.max_rows; + const minColumns = options.min_columns; + const maxColumns = options.max_columns; const clampedRows = typeof rows === "string" ? rows : conditionalClamp(rows, minRows, maxRows); 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 index ff516fce355b..494dcd9edbe3 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts @@ -2,7 +2,7 @@ import type { ActionDetail } from "@material/mwc-list"; import { mdiCheck, mdiDotsVertical } from "@mdi/js"; import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../common/dom/fire_event"; @@ -18,15 +18,23 @@ import "../../../../components/ha-slider"; import "../../../../components/ha-svg-icon"; import "../../../../components/ha-switch"; import "../../../../components/ha-yaml-editor"; -import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section"; import { haStyle } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; import type { HuiCard } from "../../cards/hui-card"; import type { CardGridSize } from "../../common/compute-card-grid-size"; -import { computeCardGridSize } from "../../common/compute-card-grid-size"; -import type { LovelaceLayoutOptions } from "../../types"; +import { + computeCardGridSize, + divideBy, + GRID_COLUMN_MULTIPLIER, + migrateLayoutToGridOptions, + multiplyBy, +} from "../../common/compute-card-grid-size"; +import type { LovelaceGridOptions } from "../../types"; + +const computePreciseMode = (columns?: number | string) => + typeof columns === "number" && columns % 3 !== 0; @customElement("hui-card-layout-editor") export class HuiCardLayoutEditor extends LitElement { @@ -36,21 +44,18 @@ export class HuiCardLayoutEditor extends LitElement { @property({ attribute: false }) public sectionConfig!: LovelaceSectionConfig; - @state() _defaultLayoutOptions?: LovelaceLayoutOptions; + @state() private _defaultGridOptions?: LovelaceGridOptions; - @state() public _yamlMode = false; + @state() private _yamlMode = false; - @state() public _uiAvailable = true; + @state() private _uiAvailable = true; - @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; + @state() private _preciseMode = false; private _cardElement?: HuiCard; private _mergedOptions = memoizeOne( - ( - options?: LovelaceLayoutOptions, - defaultOptions?: LovelaceLayoutOptions - ) => ({ + (options?: LovelaceGridOptions, defaultOptions?: LovelaceGridOptions) => ({ ...defaultOptions, ...options, }) @@ -58,20 +63,52 @@ export class HuiCardLayoutEditor extends LitElement { private _computeCardGridSize = memoizeOne(computeCardGridSize); + private _simplifyOptions = ( + options: LovelaceGridOptions + ): LovelaceGridOptions => ({ + ...options, + columns: divideBy(options.columns, GRID_COLUMN_MULTIPLIER), + max_columns: divideBy(options.max_columns, GRID_COLUMN_MULTIPLIER), + min_columns: divideBy(options.min_columns, GRID_COLUMN_MULTIPLIER), + }); + + private _standardizeOptions = (options: LovelaceGridOptions) => ({ + ...options, + columns: multiplyBy(options.columns, GRID_COLUMN_MULTIPLIER), + max_columns: multiplyBy(options.max_columns, GRID_COLUMN_MULTIPLIER), + min_columns: multiplyBy(options.min_columns, GRID_COLUMN_MULTIPLIER), + }); + private _isDefault = memoizeOne( - (options?: LovelaceLayoutOptions) => - options?.grid_columns === undefined && options?.grid_rows === undefined + (options?: LovelaceGridOptions) => + options?.columns === undefined && options?.rows === undefined ); + private _configGridOptions = (config: LovelaceCardConfig) => { + if (config.grid_options) { + return config.grid_options; + } + if (config.layout_options) { + return migrateLayoutToGridOptions(config.layout_options); + } + return {}; + }; + render() { + const configOptions = this._configGridOptions(this.config); const options = this._mergedOptions( - this.config.layout_options, - this._defaultLayoutOptions + configOptions, + this._defaultGridOptions ); - const value = this._computeCardGridSize(options); + const gridOptions = this._preciseMode + ? options + : this._simplifyOptions(options); + const gridValue = this._computeCardGridSize(gridOptions); - const totalColumns = (this.sectionConfig.column_span ?? 1) * 4; + const columnSpan = this.sectionConfig.column_span ?? 1; + const gridTotalColumns = + (12 * columnSpan) / (this._preciseMode ? 1 : GRID_COLUMN_MULTIPLIER); return html`
@@ -129,24 +166,24 @@ export class HuiCardLayoutEditor extends LitElement { ? html` ` : html` @@ -161,11 +198,29 @@ export class HuiCardLayoutEditor extends LitElement { + + + ${this.hass.localize( + "ui.panel.lovelace.editor.edit_card.layout.precise_mode" + )} + + + ${this.hass.localize( + "ui.panel.lovelace.editor.edit_card.layout.precise_mode_helper" + )} + + + + `} `; } @@ -179,17 +234,24 @@ export class HuiCardLayoutEditor extends LitElement { this._cardElement.config = this.config; this._cardElement.addEventListener("card-updated", (ev: Event) => { ev.stopPropagation(); - this._defaultLayoutOptions = - this._cardElement?.getElementLayoutOptions(); + this._updateDefaultGridOptions(); }); this._cardElement.load(); - this._defaultLayoutOptions = this._cardElement.getElementLayoutOptions(); + this._updateDefaultGridOptions(); } catch (err) { // eslint-disable-next-line no-console console.error(err); } } + private _updateDefaultGridOptions() { + if (!this._cardElement) { + this._defaultGridOptions = undefined; + return; + } + this._defaultGridOptions = this._cardElement.getElementGridOptions(); + } + protected updated(changedProps: PropertyValues): void { super.updated(changedProps); if (this._cardElement) { @@ -202,6 +264,23 @@ export class HuiCardLayoutEditor extends LitElement { } } + protected willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + if (changedProps.has("config")) { + const columns = this.config.grid_options?.columns; + const preciseMode = computePreciseMode(columns); + // Force precise mode if columns count is not a multiple of 3 + if (!this._preciseMode && preciseMode) { + this._preciseMode = preciseMode; + } + // Reset precise mode when grid options config is reset + if (columns === undefined) { + const defaultColumns = this._defaultGridOptions?.columns; + this._preciseMode = computePreciseMode(defaultColumns); + } + } + } + private async _handleAction(ev: CustomEvent) { switch (ev.detail.index) { case 0: @@ -210,68 +289,80 @@ export class HuiCardLayoutEditor extends LitElement { 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 as CardGridSize; - const newConfig: LovelaceCardConfig = { - ...this.config, - layout_options: { - ...this.config.layout_options, - grid_columns: value.columns, - grid_rows: value.rows, - }, + const gridOptions = { + columns: value.columns, + 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; - } + const newOptions = this._preciseMode + ? gridOptions + : this._standardizeOptions(gridOptions); - fireEvent(this, "value-changed", { value: newConfig }); + this._updateGridOptions({ + ...this.config.grid_options, + ...newOptions, + }); } - private _valueChanged(ev: CustomEvent): void { + private _yamlChanged(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 }); + const options = ev.detail.value as LovelaceGridOptions; + this._updateGridOptions(options); } private _fullWidthChanged(ev): void { ev.stopPropagation(); const value = ev.target.checked; - const newConfig: LovelaceCardConfig = { + this._updateGridOptions({ + ...this.config.grid_options, + columns: value ? "full" : (this._defaultGridOptions?.min_columns ?? 1), + }); + } + + private _preciseModeChanged(ev): void { + ev.stopPropagation(); + this._preciseMode = ev.target.checked; + if (this._preciseMode) return; + + const newOptions = this._standardizeOptions( + this._simplifyOptions(this.config.grid_options ?? {}) + ); + if (newOptions.columns !== this.config.grid_options?.columns) { + this._updateGridOptions({ + ...this.config.grid_options, + columns: newOptions.columns, + }); + } + } + + private _updateGridOptions(options: LovelaceGridOptions): void { + const value: LovelaceCardConfig = { ...this.config, - layout_options: { - ...this.config.layout_options, - grid_columns: value - ? "full" - : (this._defaultLayoutOptions?.grid_min_columns ?? 1), + grid_options: { + ...options, }, }; - fireEvent(this, "value-changed", { value: newConfig }); + if (value.grid_options) { + for (const [k, v] of Object.entries(value.grid_options)) { + if (v === undefined) { + delete value.grid_options[k]; + } + } + if (Object.keys(value.grid_options).length === 0) { + delete value.grid_options; + } + } + if (value.layout_options) { + delete value.layout_options; + } + fireEvent(this, "value-changed", { value }); } static styles = [ diff --git a/src/panels/lovelace/editor/structs/base-card-struct.ts b/src/panels/lovelace/editor/structs/base-card-struct.ts index 8d504c2d4c94..bef404c39b5c 100644 --- a/src/panels/lovelace/editor/structs/base-card-struct.ts +++ b/src/panels/lovelace/editor/structs/base-card-struct.ts @@ -4,5 +4,6 @@ export const baseLovelaceCardConfig = object({ type: string(), view_layout: any(), layout_options: any(), + grid_options: any(), visibility: any(), }); diff --git a/src/panels/lovelace/sections/hui-grid-section.ts b/src/panels/lovelace/sections/hui-grid-section.ts index 5c1e5e18f7e2..fe4236bea038 100644 --- a/src/panels/lovelace/sections/hui-grid-section.ts +++ b/src/panels/lovelace/sections/hui-grid-section.ts @@ -108,9 +108,9 @@ export class GridSection extends LitElement implements LovelaceSectionElement { (_cardConfig, idx) => { const card = this.cards![idx]; card.layout = "grid"; - const layoutOptions = card.getLayoutOptions(); + const gridOptions = card.getGridOptions(); - const { rows, columns } = computeCardGridSize(layoutOptions); + const { rows, columns } = computeCardGridSize(gridOptions); const cardPath: LovelaceCardPath = [ this.viewIndex!, @@ -125,7 +125,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement { "--row-size": typeof rows === "number" ? rows : undefined, })} class="card ${classMap({ - "fit-rows": typeof layoutOptions?.grid_rows === "number", + "fit-rows": typeof rows === "number", "full-width": columns === "full", })}" .sortableData=${cardPath} @@ -211,7 +211,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement { haStyle, css` :host { - --base-column-count: 4; + --base-column-count: 12; --row-gap: var(--ha-section-grid-row-gap, 8px); --column-gap: var(--ha-section-grid-column-gap, 8px); --row-height: var(--ha-section-grid-row-height, 56px); @@ -281,7 +281,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement { position: relative; outline: none; grid-row: span 1; - grid-column: span 1; + grid-column: span 3; background: none; cursor: pointer; border-radius: var(--ha-card-border-radius, 12px); diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index 4397238e56f0..c862fd0bfd3a 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -53,12 +53,23 @@ export type LovelaceLayoutOptions = { grid_max_rows?: number; }; +export type LovelaceGridOptions = { + columns?: number | "full"; + rows?: number | "auto"; + max_columns?: number; + min_columns?: number; + min_rows?: number; + max_rows?: number; +}; + export interface LovelaceCard extends HTMLElement { hass?: HomeAssistant; preview?: boolean; layout?: string; getCardSize(): number | Promise; + /** @deprecated Use `getGridOptions` instead */ getLayoutOptions?(): LovelaceLayoutOptions; + getGridOptions?(): LovelaceGridOptions; setConfig(config: LovelaceCardConfig): void; } diff --git a/src/translations/en.json b/src/translations/en.json index 528743b0b90d..3643a3627609 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5673,7 +5673,9 @@ }, "layout": { "full_width": "Full width card", - "full_width_helper": "Take up the full width of the section whatever its size" + "full_width_helper": "Take up the full width of the section whatever its size", + "precise_mode": "Precise mode", + "precise_mode_helper": "Change the card width with more precision" } }, "edit_badge": {