511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
|
import { mdiDelete, mdiDrag, mdiPencil, mdiViewGridPlus } from "@mdi/js";
|
|
import type { CSSResultGroup, PropertyValues } from "lit";
|
|
import { LitElement, css, html, nothing } 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 memoizeOne from "memoize-one";
|
|
import { clamp } from "../../../common/number/clamp";
|
|
import "../../../components/ha-icon-button";
|
|
import "../../../components/ha-ripple";
|
|
import "../../../components/ha-sortable";
|
|
import "../../../components/ha-svg-icon";
|
|
import type { LovelaceViewElement } from "../../../data/lovelace";
|
|
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
|
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
|
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
|
import type { HomeAssistant } from "../../../types";
|
|
import type { HuiBadge } from "../badges/hui-badge";
|
|
import "../badges/hui-view-badges";
|
|
import type { HuiCard } from "../cards/hui-card";
|
|
import "../components/hui-badge-edit-mode";
|
|
import { addSection, deleteSection, moveSection } from "../editor/config-util";
|
|
import { findLovelaceContainer } from "../editor/lovelace-path";
|
|
import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog";
|
|
import type { HuiSection } from "../sections/hui-section";
|
|
import type { Lovelace } from "../types";
|
|
|
|
export const DEFAULT_MAX_COLUMNS = 4;
|
|
|
|
const parsePx = (value: string) => parseInt(value.replace("px", ""));
|
|
|
|
@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: Number }) public index?: number;
|
|
|
|
@property({ type: Boolean }) public isStrategy = false;
|
|
|
|
@property({ attribute: false }) public sections: HuiSection[] = [];
|
|
|
|
@property({ attribute: false }) public badges: HuiBadge[] = [];
|
|
|
|
@property({ attribute: false }) public cards: HuiCard[] = [];
|
|
|
|
@state() private _config?: LovelaceViewConfig;
|
|
|
|
@state() private _sectionColumnCount = 0;
|
|
|
|
@state() _dragging = false;
|
|
|
|
private _columnsController = new ResizeController(this, {
|
|
callback: (entries) => {
|
|
const totalWidth = entries[0]?.contentRect.width;
|
|
|
|
if (!totalWidth) return 1;
|
|
|
|
const style = getComputedStyle(this);
|
|
const container = this.shadowRoot!.querySelector(".container")!;
|
|
const containerStyle = getComputedStyle(container);
|
|
|
|
const paddingLeft = parsePx(containerStyle.paddingLeft);
|
|
const paddingRight = parsePx(containerStyle.paddingRight);
|
|
const padding = paddingLeft + paddingRight;
|
|
const minColumnWidth = parsePx(
|
|
style.getPropertyValue("--column-min-width")
|
|
);
|
|
const columnGap = parsePx(containerStyle.columnGap);
|
|
|
|
const columns = Math.floor(
|
|
(totalWidth - padding + columnGap) / (minColumnWidth + columnGap)
|
|
);
|
|
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
|
|
return clamp(columns, 1, maxColumns);
|
|
},
|
|
});
|
|
|
|
public setConfig(config: LovelaceViewConfig): void {
|
|
this._config = config;
|
|
}
|
|
|
|
private _sectionConfigKeys = new WeakMap<HuiSection, string>();
|
|
|
|
private _getSectionKey(section: HuiSection) {
|
|
if (!this._sectionConfigKeys.has(section)) {
|
|
this._sectionConfigKeys.set(section, Math.random().toString());
|
|
}
|
|
return this._sectionConfigKeys.get(section)!;
|
|
}
|
|
|
|
private _computeSectionsCount() {
|
|
this._sectionColumnCount = this.sections
|
|
.filter((section) => !section.hidden)
|
|
.map((section) => section.config.column_span ?? 1)
|
|
.reduce((acc, val) => acc + val, 0);
|
|
}
|
|
|
|
private _sectionVisibilityChanged = () => {
|
|
this._computeSectionsCount();
|
|
};
|
|
|
|
connectedCallback(): void {
|
|
super.connectedCallback();
|
|
this.addEventListener(
|
|
"section-visibility-changed",
|
|
this._sectionVisibilityChanged
|
|
);
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
this.removeEventListener(
|
|
"section-visibility-changed",
|
|
this._sectionVisibilityChanged
|
|
);
|
|
}
|
|
|
|
willUpdate(changedProperties: PropertyValues<typeof this>): void {
|
|
if (changedProperties.has("sections")) {
|
|
this._computeSectionsCount();
|
|
}
|
|
}
|
|
|
|
protected render() {
|
|
if (!this.lovelace) return nothing;
|
|
|
|
const sections = this.sections;
|
|
const totalSectionCount =
|
|
this._sectionColumnCount + (this.lovelace?.editMode ? 1 : 0);
|
|
const editMode = this.lovelace.editMode;
|
|
|
|
const maxColumnCount = this._columnsController.value ?? 1;
|
|
|
|
return html`
|
|
<hui-view-badges
|
|
.hass=${this.hass}
|
|
.badges=${this.badges}
|
|
.lovelace=${this.lovelace}
|
|
.viewIndex=${this.index}
|
|
style=${styleMap({
|
|
"--max-column-count": maxColumnCount,
|
|
})}
|
|
></hui-view-badges>
|
|
<ha-sortable
|
|
.disabled=${!editMode}
|
|
@item-moved=${this._sectionMoved}
|
|
group="section"
|
|
handle-selector=".handle"
|
|
draggable-selector=".section"
|
|
.rollback=${false}
|
|
>
|
|
<div
|
|
class="container ${classMap({
|
|
dense: Boolean(this._config?.dense_section_placement),
|
|
})}"
|
|
style=${styleMap({
|
|
"--total-section-count": totalSectionCount,
|
|
"--max-column-count": maxColumnCount,
|
|
})}
|
|
>
|
|
${repeat(
|
|
sections,
|
|
(section) => this._getSectionKey(section),
|
|
(section, idx) => {
|
|
const sectionConfig = this._config?.sections?.[idx];
|
|
const columnSpan = Math.min(
|
|
sectionConfig?.column_span || 1,
|
|
maxColumnCount
|
|
);
|
|
|
|
const rowSpan = sectionConfig?.row_span || 1;
|
|
|
|
return html`
|
|
<div
|
|
class="section"
|
|
style=${styleMap({
|
|
"--column-span": columnSpan,
|
|
"--row-span": rowSpan,
|
|
})}
|
|
>
|
|
${
|
|
this.lovelace?.editMode
|
|
? html`
|
|
<div class="section-header">
|
|
${editMode
|
|
? html`
|
|
<div class="section-actions">
|
|
<ha-svg-icon
|
|
aria-hidden="true"
|
|
class="handle"
|
|
.path=${mdiDrag}
|
|
></ha-svg-icon>
|
|
<ha-icon-button
|
|
.label=${this.hass.localize(
|
|
"ui.common.edit"
|
|
)}
|
|
@click=${this._editSection}
|
|
.index=${idx}
|
|
.path=${mdiPencil}
|
|
></ha-icon-button>
|
|
<ha-icon-button
|
|
.label=${this.hass.localize(
|
|
"ui.common.delete"
|
|
)}
|
|
@click=${this._deleteSection}
|
|
.index=${idx}
|
|
.path=${mdiDelete}
|
|
></ha-icon-button>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
`
|
|
: nothing
|
|
}
|
|
${section}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
)}
|
|
${editMode
|
|
? html`
|
|
<button
|
|
class="create-section"
|
|
@click=${this._createSection}
|
|
aria-label=${this.hass.localize(
|
|
"ui.panel.lovelace.editor.section.create_section"
|
|
)}
|
|
.title=${this.hass.localize(
|
|
"ui.panel.lovelace.editor.section.create_section"
|
|
)}
|
|
>
|
|
<ha-ripple></ha-ripple>
|
|
<ha-svg-icon .path=${mdiViewGridPlus}></ha-svg-icon>
|
|
</button>
|
|
`
|
|
: nothing}
|
|
${editMode && this._config?.cards?.length
|
|
? html`
|
|
<div class="section imported-cards">
|
|
<div class="imported-card-header">
|
|
<p class="title">
|
|
${this.hass.localize(
|
|
"ui.panel.lovelace.editor.section.imported_cards_title"
|
|
)}
|
|
</p>
|
|
<p class="subtitle">
|
|
${this.hass.localize(
|
|
"ui.panel.lovelace.editor.section.imported_cards_description"
|
|
)}
|
|
</p>
|
|
</div>
|
|
<hui-section
|
|
.lovelace=${this.lovelace}
|
|
.hass=${this.hass}
|
|
.config=${this._importedCardSectionConfig(
|
|
this._config.cards
|
|
)}
|
|
.viewIndex=${this.index}
|
|
preview
|
|
import-only
|
|
></hui-section>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
</ha-sortable>
|
|
`;
|
|
}
|
|
|
|
private _importedCardSectionConfig = memoizeOne(
|
|
(cards: LovelaceCardConfig[]) => ({
|
|
type: "grid",
|
|
cards,
|
|
})
|
|
);
|
|
|
|
private _createSection(): void {
|
|
const newConfig = addSection(this.lovelace!.config, this.index!, {
|
|
type: "grid",
|
|
cards: [
|
|
{
|
|
type: "heading",
|
|
heading: this.hass!.localize(
|
|
"ui.panel.lovelace.editor.section.default_section_title"
|
|
),
|
|
},
|
|
],
|
|
});
|
|
this.lovelace!.saveConfig(newConfig);
|
|
}
|
|
|
|
private async _editSection(ev) {
|
|
const index = ev.currentTarget.index;
|
|
|
|
showEditSectionDialog(this, {
|
|
lovelaceConfig: this.lovelace!.config,
|
|
saveConfig: (newConfig) => {
|
|
this.lovelace!.saveConfig(newConfig);
|
|
},
|
|
viewIndex: this.index!,
|
|
sectionIndex: index,
|
|
});
|
|
}
|
|
|
|
private async _deleteSection(ev) {
|
|
const index = ev.currentTarget.index;
|
|
|
|
const path = [this.index!, index] as [number, number];
|
|
|
|
const section = findLovelaceContainer(this.lovelace!.config, path);
|
|
|
|
const cardCount = "cards" in section && section.cards?.length;
|
|
|
|
if (cardCount) {
|
|
const confirm = await showConfirmationDialog(this, {
|
|
title: this.hass.localize(
|
|
"ui.panel.lovelace.editor.delete_section.title"
|
|
),
|
|
text: this.hass.localize(
|
|
`ui.panel.lovelace.editor.delete_section.text`
|
|
),
|
|
confirmText: this.hass.localize("ui.common.delete"),
|
|
destructive: true,
|
|
});
|
|
|
|
if (!confirm) return;
|
|
}
|
|
|
|
const newConfig = deleteSection(this.lovelace!.config, this.index!, index);
|
|
this.lovelace!.saveConfig(newConfig);
|
|
}
|
|
|
|
private _sectionMoved(ev: CustomEvent) {
|
|
ev.stopPropagation();
|
|
const { oldIndex, newIndex } = ev.detail;
|
|
|
|
const newConfig = moveSection(
|
|
this.lovelace!.config,
|
|
[this.index!, oldIndex],
|
|
[this.index!, newIndex]
|
|
);
|
|
this.lovelace!.saveConfig(newConfig);
|
|
}
|
|
|
|
static get styles(): CSSResultGroup {
|
|
return css`
|
|
:host {
|
|
--row-height: var(--ha-view-sections-row-height, 56px);
|
|
--row-gap: var(--ha-view-sections-row-gap, 8px);
|
|
--column-gap: var(--ha-view-sections-column-gap, 32px);
|
|
--column-max-width: var(--ha-view-sections-column-max-width, 500px);
|
|
--column-min-width: var(--ha-view-sections-column-min-width, 320px);
|
|
display: block;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
:host {
|
|
--column-gap: var(--row-gap);
|
|
}
|
|
}
|
|
|
|
.container > * {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.section {
|
|
border-radius: var(--ha-card-border-radius, 12px);
|
|
grid-column: span var(--column-span);
|
|
grid-row: span var(--row-span);
|
|
}
|
|
|
|
.section:has(hui-section[hidden]) {
|
|
display: none;
|
|
}
|
|
|
|
.container {
|
|
--column-count: min(
|
|
var(--max-column-count),
|
|
var(--total-section-count)
|
|
);
|
|
display: grid;
|
|
align-items: start;
|
|
justify-content: center;
|
|
grid-template-columns: repeat(var(--column-count), 1fr);
|
|
grid-auto-flow: row;
|
|
gap: var(--row-gap) var(--column-gap);
|
|
padding: var(--row-gap) var(--column-gap);
|
|
box-sizing: content-box;
|
|
margin: 0 auto;
|
|
max-width: calc(
|
|
var(--column-count) * var(--column-max-width) +
|
|
(var(--column-count) - 1) * var(--column-gap)
|
|
);
|
|
}
|
|
.container.dense {
|
|
grid-auto-flow: row dense;
|
|
}
|
|
|
|
.handle {
|
|
cursor: grab;
|
|
padding: 8px;
|
|
}
|
|
|
|
.create-section {
|
|
margin-top: 36px;
|
|
outline: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
border-radius: var(--ha-card-border-radius, 12px);
|
|
border: 2px dashed var(--primary-color);
|
|
order: 1;
|
|
height: calc(var(--row-height) + 2 * (var(--row-gap) + 2px));
|
|
padding: 8px;
|
|
box-sizing: border-box;
|
|
--ha-ripple-color: var(--primary-color);
|
|
--ha-ripple-hover-opacity: 0.04;
|
|
--ha-ripple-pressed-opacity: 0.12;
|
|
}
|
|
|
|
.create-section:focus {
|
|
border: 2px solid var(--primary-color);
|
|
}
|
|
|
|
.sortable-ghost {
|
|
border-radius: var(--ha-card-border-radius, 12px);
|
|
}
|
|
|
|
hui-view-badges {
|
|
display: block;
|
|
text-align: center;
|
|
padding: 0 var(--column-gap);
|
|
padding-top: var(--row-gap);
|
|
margin: auto;
|
|
max-width: calc(
|
|
var(--max-column-count) * var(--column-max-width) +
|
|
(var(--max-column-count) - 1) * var(--column-gap)
|
|
);
|
|
}
|
|
|
|
.section-header {
|
|
position: relative;
|
|
height: 34px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.section-actions {
|
|
position: absolute;
|
|
height: 36px;
|
|
bottom: -2px;
|
|
right: 0;
|
|
inset-inline-end: 0;
|
|
inset-inline-start: initial;
|
|
opacity: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: opacity 0.2s ease-in-out;
|
|
border-radius: var(--ha-card-border-radius, 12px);
|
|
border-bottom-left-radius: 0px;
|
|
border-bottom-right-radius: 0px;
|
|
background: var(--secondary-background-color);
|
|
--mdc-icon-button-size: 36px;
|
|
--mdc-icon-size: 20px;
|
|
color: var(--primary-text-color);
|
|
}
|
|
|
|
.imported-cards {
|
|
--column-span: var(--column-count);
|
|
--row-span: 1;
|
|
order: 2;
|
|
}
|
|
|
|
.imported-card-header {
|
|
margin-top: 24px;
|
|
padding: 16px 8px;
|
|
border-top: 2px dashed var(--divider-color);
|
|
}
|
|
|
|
.imported-card-header .title {
|
|
margin: 0;
|
|
color: var(--primary-text-color);
|
|
font-size: 16px;
|
|
font-weight: 400;
|
|
line-height: 24px;
|
|
}
|
|
.imported-card-header .subtitle {
|
|
margin: 0;
|
|
color: var(--secondary-text-color);
|
|
font-size: 14px;
|
|
font-weight: 400;
|
|
line-height: 20px;
|
|
}
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"hui-sections-view": SectionsView;
|
|
}
|
|
}
|