diff --git a/src/panels/lovelace/create-element/create-view-element.ts b/src/panels/lovelace/create-element/create-view-element.ts index 158cf2da31..65bd3e30b2 100644 --- a/src/panels/lovelace/create-element/create-view-element.ts +++ b/src/panels/lovelace/create-element/create-view-element.ts @@ -9,6 +9,7 @@ const ALWAYS_LOADED_LAYOUTS = new Set(["masonry"]); const LAZY_LOAD_LAYOUTS = { panel: () => import("../views/hui-panel-view"), sidebar: () => import("../views/hui-sidebar-view"), + sections: () => import("../views/hui-sections-view"), }; export const createViewElement = ( diff --git a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts index 29aa461de2..b31871389c 100644 --- a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts +++ b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts @@ -10,6 +10,7 @@ import type { HomeAssistant } from "../../../../types"; import { DEFAULT_VIEW_LAYOUT, PANEL_VIEW_LAYOUT, + SECTIONS_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT, } from "../../views/const"; import { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; @@ -53,6 +54,7 @@ export class HuiViewEditor extends LitElement { DEFAULT_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT, PANEL_VIEW_LAYOUT, + SECTIONS_VIEW_LAYOUT, ] as const ).map((type) => ({ value: type, diff --git a/src/panels/lovelace/views/const.ts b/src/panels/lovelace/views/const.ts index 5cc4709bbb..45f6b5f10d 100644 --- a/src/panels/lovelace/views/const.ts +++ b/src/panels/lovelace/views/const.ts @@ -1,4 +1,9 @@ export const DEFAULT_VIEW_LAYOUT = "masonry"; export const PANEL_VIEW_LAYOUT = "panel"; export const SIDEBAR_VIEW_LAYOUT = "sidebar"; -export const VIEWS_NO_BADGE_SUPPORT = [PANEL_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT]; +export const SECTIONS_VIEW_LAYOUT = "sections"; +export const VIEWS_NO_BADGE_SUPPORT = [ + PANEL_VIEW_LAYOUT, + SIDEBAR_VIEW_LAYOUT, + SECTIONS_VIEW_LAYOUT, +]; diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts new file mode 100644 index 0000000000..0a88b224ca --- /dev/null +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -0,0 +1,211 @@ +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { repeat } from "lit/directives/repeat"; +import { styleMap } from "lit/directives/style-map"; +import { SortableEvent } from "sortablejs"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/entity/ha-state-label-badge"; +import "../../../components/ha-card"; +import "../../../components/ha-svg-icon"; +import type { + LovelaceCardConfig, + LovelaceViewConfig, + LovelaceViewElement, +} from "../../../data/lovelace"; +import { SortableInstance } from "../../../resources/sortable"; +import { loadSortable } from "../../../resources/sortable.ondemand"; +import type { HomeAssistant } from "../../../types"; +import type { HuiErrorCard } from "../cards/hui-error-card"; +import type { Lovelace, LovelaceCard } from "../types"; + +@customElement("hui-sections-view") +export class SectionsView extends LitElement implements LovelaceViewElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public lovelace?: Lovelace; + + @property({ type: Boolean }) public narrow!: boolean; + + @property({ type: Number }) public index?: number; + + @property({ type: Boolean }) public isStrategy = false; + + @property({ attribute: false }) public cards: Array< + LovelaceCard | HuiErrorCard + > = []; + + @state() private _config?: LovelaceViewConfig; + + public setConfig(config: LovelaceViewConfig): void { + this._config = config; + } + + private _cardConfigKeys = new WeakMap(); + + private _getKey(cardConfig: LovelaceCardConfig) { + if (!this._cardConfigKeys.has(cardConfig)) { + this._cardConfigKeys.set(cardConfig, Math.random().toString()); + } + + return this._cardConfigKeys.get(cardConfig)!; + } + + protected firstUpdated( + _changedProperties: Map + ): void { + this._createSortable(); + } + + public disconnectedCallback() { + this._destroySortable(); + } + + protected render(): TemplateResult { + const cardsConfig = this._config?.cards ?? []; + + return html` +
+
+

Section

+ ${this.lovelace?.editMode + ? html` + + Add card + + ` + : ""} +
+ +
+ ${repeat( + cardsConfig, + (cardConfig) => this._getKey(cardConfig), + (cardConfig, idx) => { + const card = this.cards[idx]; + return html` +
+ ${card} +
+ `; + } + )} +
+
+ `; + } + + private _sortable?: SortableInstance; + + private async _createSortable() { + const Sortable = await loadSortable(); + this._sortable = new Sortable(this.shadowRoot!.querySelector("#grid")!, { + animation: 500, + draggable: ".draggable", + swapThreshold: 0.5, + onSort: (evt: SortableEvent) => { + this._dragged(evt); + }, + }); + } + + private _dragged(ev: SortableEvent): void { + if (ev.oldIndex === ev.newIndex) return; + this._move(ev.oldIndex!, ev.newIndex!); + } + + private _move(index: number, newIndex: number) { + const config = this._config; + if (config) { + const cardsConfig = [...(config.cards ?? [])]; + + const cardConfig = cardsConfig.splice(index, 1)[0]; + cardsConfig.splice(newIndex, 0, cardConfig); + const newConfig = { + ...config, + cards: cardsConfig, + }; + + this.lovelace?.saveConfig({ + ...this.lovelace.config, + views: this.lovelace.config.views.map((view, i) => + i === this.index ? newConfig : view + ), + }); + } + } + + private _destroySortable() { + this._sortable?.destroy(); + this._sortable = undefined; + } + + private _addCard(): void { + fireEvent(this, "ll-create-card"); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + padding-top: 4px; + height: 100%; + box-sizing: border-box; + --grid-gap: 8px; + --grid-cell-height: 64px; + --grid-cell-width: 168px; + } + .container { + max-width: 1000px; + margin: auto; + } + .header { + padding: 0 var(--grid-gap); + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + #grid { + padding: var(--grid-gap); + gap: var(--grid-gap); + display: grid; + grid-template-rows: var(--grid-cell-height); + grid-template-columns: repeat( + auto-fill, + minmax(var(--grid-cell-width), 1fr) + ); + grid-auto-flow: row dense; + grid-auto-columns: min-content; + } + #grid > div { + position: relative; + grid-row: span var(--row-size, 1); + grid-column: span var(--column-size, 1); + } + .draggable { + cursor: move; + } + .draggable > * { + pointer-events: none; + } + .sortable-ghost { + opacity: 0.4; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-sections-view": SectionsView; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index e6890de1d7..37648d1e72 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4831,7 +4831,8 @@ "types": { "masonry": "Masonry (default)", "sidebar": "Sidebar", - "panel": "Panel (1 card)" + "panel": "Panel (1 card)", + "sections": "Sections" }, "subview": "Subview", "subview_helper": "Subviews don't appear in tabs and have a back button.",