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._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": {