Compare commits

...
Author SHA1 Message Date
Paul Bottein b621042d4e Add settings to the sections view sidebar 2026-10-27 09:58:32 +01:00
Paul Bottein ebc35db562 Make the sections view sidebar editable 2026-10-27 09:58:27 +01:00
26 changed files with 939 additions and 147 deletions

No files matched your search

@@ -0,0 +1,7 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="black" stroke-opacity="0.12" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="78" height="8" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="8" y="20" width="78" height="8" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="8" y="32" width="78" height="16" rx="4" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 519 B

@@ -0,0 +1,7 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="white" stroke-opacity="0.24" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="78" height="8" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="8" y="20" width="78" height="8" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="8" y="32" width="78" height="16" rx="4" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

@@ -0,0 +1,9 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="black" stroke-opacity="0.12" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="78" height="12" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="8" y="24" width="78" height="10" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="23.5" y="37.5" width="47" height="13" rx="6.5" fill="white" stroke="black" stroke-opacity="0.12"/>
<rect x="26" y="40" width="20" height="8" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="48" y="40" width="20" height="8" rx="4" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 713 B

@@ -0,0 +1,9 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="white" stroke-opacity="0.24" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="78" height="12" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="8" y="24" width="78" height="10" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="23.5" y="37.5" width="47" height="13" rx="6.5" fill="#1C1C1C" stroke="white" stroke-opacity="0.24"/>
<rect x="26" y="40" width="20" height="8" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="48" y="40" width="20" height="8" rx="4" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

@@ -0,0 +1,7 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="black" stroke-opacity="0.12" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="78" height="16" rx="4" fill="black" fill-opacity="0.32"/>
<rect x="8" y="28" width="78" height="8" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="8" y="40" width="78" height="8" rx="4" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 519 B

@@ -0,0 +1,7 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="white" stroke-opacity="0.24" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="78" height="16" rx="4" fill="white" fill-opacity="0.48"/>
<rect x="8" y="28" width="78" height="8" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="8" y="40" width="78" height="8" rx="4" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

@@ -0,0 +1,9 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="black" stroke-opacity="0.12" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="22" height="20" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="8" y="32" width="22" height="16" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="34" y="8" width="22" height="12" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="34" y="24" width="22" height="24" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="60" y="8" width="26" height="28" rx="4" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 690 B

@@ -0,0 +1,9 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="white" stroke-opacity="0.24" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="22" height="20" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="8" y="32" width="22" height="16" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="34" y="8" width="22" height="12" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="34" y="24" width="22" height="24" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="60" y="8" width="26" height="28" rx="4" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 692 B

@@ -0,0 +1,9 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="black" stroke-opacity="0.12" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="26" height="28" rx="4" fill="black" fill-opacity="0.32"/>
<rect x="38" y="8" width="22" height="20" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="38" y="32" width="22" height="16" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="64" y="8" width="22" height="12" rx="4" fill="black" fill-opacity="0.12"/>
<rect x="64" y="24" width="22" height="24" rx="4" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 691 B

@@ -0,0 +1,9 @@
<svg width="94" height="56" viewBox="0 0 94 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="56" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="55" rx="7.5" stroke="white" stroke-opacity="0.24" stroke-dasharray="4 4"/>
<rect x="8" y="8" width="26" height="28" rx="4" fill="white" fill-opacity="0.48"/>
<rect x="38" y="8" width="22" height="20" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="38" y="32" width="22" height="16" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="64" y="8" width="22" height="12" rx="4" fill="white" fill-opacity="0.24"/>
<rect x="64" y="24" width="22" height="24" rx="4" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 693 B

+9
View File
@@ -49,6 +49,15 @@ export const normalizeViewConfig = (
.map(normalizeSectionConfig);
}
if (updatedView.sidebar?.sections) {
updatedView.sidebar = {
...updatedView.sidebar,
sections: updatedView.sidebar.sections
.filter(Boolean)
.map(normalizeSectionConfig),
};
}
return updatedView;
};
+14
View File
@@ -48,8 +48,22 @@ export interface LovelaceViewFooterConfig {
max_width?: number;
}
export const SIDEBAR_POSITIONS = ["start", "end"] as const;
export type SidebarPosition = (typeof SIDEBAR_POSITIONS)[number];
export const DEFAULT_SIDEBAR_POSITION: SidebarPosition = "end";
export const SIDEBAR_MOBILE_POSITIONS = ["tabs", "top", "bottom"] as const;
export type SidebarMobilePosition = (typeof SIDEBAR_MOBILE_POSITIONS)[number];
export const DEFAULT_SIDEBAR_MOBILE_POSITION: SidebarMobilePosition = "bottom";
export interface LovelaceViewSidebarConfig {
sections?: LovelaceSectionConfig[];
position?: SidebarPosition;
mobile_position?: SidebarMobilePosition;
content_label?: string;
sidebar_label?: string;
visibility?: VisibilityCondition[];
@@ -0,0 +1,125 @@
import { mdiDelete, mdiDotsVertical, mdiPencil } from "@mdi/js";
import "@home-assistant/webawesome/dist/components/divider/divider";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/view";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import type { LovelacePath } from "../editor/lovelace-path";
import { deleteAtPath, getAtPath } from "../editor/lovelace-path";
import { showEditSidebarDialog } from "../editor/sidebar-editor/show-edit-sidebar-dialog";
import type { Lovelace } from "../types";
@customElement("hui-sidebar-edit-header")
export class HuiSidebarEditHeader extends LitElement {
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public path!: LovelacePath;
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
return html`
<div class="actions">
<span class="title">
${this._localize("ui.panel.lovelace.editor.sidebar.title")}
</span>
<ha-dropdown
placement="bottom-end"
@wa-select=${this._handleDropdownSelect}
>
<ha-icon-button
slot="trigger"
.label=${this._localize("ui.panel.lovelace.editor.sidebar.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="edit">
<ha-svg-icon slot="icon" .path=${mdiPencil}></ha-svg-icon>
${this._localize("ui.common.edit")}
</ha-dropdown-item>
<wa-divider></wa-divider>
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
${this._localize("ui.common.delete")}
</ha-dropdown-item>
</ha-dropdown>
</div>
`;
}
private _handleDropdownSelect(ev: HaDropdownSelectEvent): void {
const action = ev.detail?.item?.value;
switch (action) {
case "edit":
showEditSidebarDialog(this, {
lovelace: this.lovelace,
path: this.path,
});
break;
case "delete":
this._deleteSidebar();
break;
}
}
private async _deleteSidebar() {
const sidebar = getAtPath<LovelaceViewSidebarConfig>(
this.lovelace.config,
this.path
);
if (sidebar?.sections?.length) {
const confirm = await showConfirmationDialog(this, {
title: this._localize("ui.panel.lovelace.editor.sidebar.delete_title"),
text: this._localize("ui.panel.lovelace.editor.sidebar.delete_text"),
confirmText: this._localize("ui.common.delete"),
destructive: true,
});
if (!confirm) return;
}
this.lovelace.saveConfig(deleteAtPath(this.lovelace.config, this.path));
}
static styles = css`
:host {
display: block;
position: relative;
height: 34px;
}
.actions {
position: absolute;
height: 36px;
bottom: -2px;
inset-inline-end: 0;
display: flex;
align-items: center;
padding-inline-start: var(--ha-space-3);
border-radius: var(
--ha-section-border-radius,
var(--ha-border-radius-xl)
);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background: var(--secondary-background-color);
color: var(--primary-text-color);
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
}
.title {
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-sidebar-edit-header": HuiSidebarEditHeader;
}
}
@@ -2,9 +2,10 @@ import type { LovelaceSectionElement } from "../../../data/lovelace";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { HuiErrorCard } from "../cards/hui-error-card";
import "../sections/hui-grid-section";
import "../sections/hui-stack-section";
import { createLovelaceElement } from "./create-element-base";
const ALWAYS_LOADED_LAYOUTS = new Set(["grid"]);
const ALWAYS_LOADED_LAYOUTS = new Set(["grid", "stack"]);
const LAZY_LOAD_LAYOUTS = {};
+10 -1
View File
@@ -17,6 +17,7 @@ import {
getParentPath,
insertAtPath,
getPathTarget,
isViewSectionPath,
moveAtPath,
pathEquals,
setAtPath,
@@ -102,7 +103,15 @@ export const getCardSectionConfig = (
if (!section || isStrategySection(section)) {
return undefined;
}
return section;
if (isViewSectionPath(containerPath)) {
return section;
}
// Nested sections take the width of the section placed in the view
const viewSectionPath = containerPath.slice(0, 4);
const viewSection = isViewSectionPath(viewSectionPath)
? getAtPath<LovelaceSectionRawConfig>(config, viewSectionPath)
: undefined;
return { ...section, column_span: viewSection?.column_span };
};
export const addView = (
@@ -40,6 +40,10 @@ export const getParentPath = (path: LovelacePath): LovelacePath =>
export const getViewPath = (path: LovelacePath): LovelacePath =>
path.slice(0, 2);
export const isViewSectionPath = (path: LovelacePath): boolean =>
path[path.length - 2] === "sections" &&
pathEquals(getParentPath(getParentPath(path)), getViewPath(path));
export const getPathTarget = (path: LovelacePath): LovelacePathTarget => {
const last = path[path.length - 1];
if (typeof last === "number") {
@@ -55,6 +55,7 @@ import {
getAtPath,
getViewPath,
insertAtPath,
isViewSectionPath,
moveAtPath,
setAtPath,
} from "../lovelace-path";
@@ -224,6 +225,7 @@ export class HuiDialogEditSection
<hui-section-settings-editor
.config=${this._config}
.viewConfig=${this._viewConfig}
.nested=${!isViewSectionPath(this._params.path)}
@value-changed=${this._configChanged}
>
</hui-section-settings-editor>
@@ -99,6 +99,8 @@ export class HuiDialogEditSection extends LitElement {
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
@property({ attribute: false }) public nested = false;
private get _maxColumns() {
return this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS;
}
@@ -120,9 +122,9 @@ export class HuiDialogEditSection extends LitElement {
}
private _schema = memoizeOne(
(localize: LocalizeFunc, maxColumns: number) =>
(localize: LocalizeFunc, maxColumns: number, nested: boolean) =>
[
...computeColumnSpanSchemas(maxColumns, localize),
...(nested ? [] : computeColumnSpanSchemas(maxColumns, localize)),
{
name: "background_enabled",
selector: { boolean: {} },
@@ -189,7 +191,7 @@ export class HuiDialogEditSection extends LitElement {
theme: this.config.theme,
};
const schema = this._schema(this._localize, this._maxColumns);
const schema = this._schema(this._localize, this._maxColumns, this.nested);
return html`
<ha-form
@@ -0,0 +1,279 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import {
DEFAULT_SIDEBAR_MOBILE_POSITION,
DEFAULT_SIDEBAR_POSITION,
SIDEBAR_MOBILE_POSITIONS,
SIDEBAR_POSITIONS,
type LovelaceViewSidebarConfig,
type SidebarPosition,
} from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { getAtPath, setAtPath } from "../lovelace-path";
import "../section-editor/hui-section-visibility-editor";
import type { EditSidebarDialogParams } from "./show-edit-sidebar-dialog";
const TABS = ["settings", "visibility"] as const;
type Tab = (typeof TABS)[number];
const computeSide = (position: SidebarPosition, isRTL: boolean) => {
if (position === "start") {
return isRTL ? "right" : "left";
}
return isRTL ? "left" : "right";
};
@customElement("hui-dialog-edit-sidebar")
export class HuiDialogEditSidebar
extends DirtyStateProviderMixin<LovelaceViewSidebarConfig>()(LitElement)
implements HassDialog<EditSidebarDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EditSidebarDialogParams;
@state() private _config?: LovelaceViewSidebarConfig;
@state() private _tab: Tab = "settings";
@state() private _open = false;
@state() private _saving = false;
public showDialog(params: EditSidebarDialogParams): void {
this._params = params;
this._config =
getAtPath<LovelaceViewSidebarConfig>(
params.lovelace.config,
params.path
) ?? {};
this._initDirtyTracking({ type: "deep" }, this._config);
this._tab = "settings";
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._config = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _schema = memoizeOne(
(localize: LocalizeFunc, isRTL: boolean) =>
[
{
name: "position",
required: true,
selector: {
select: {
mode: "box",
options: SIDEBAR_POSITIONS.map((position) => ({
value: position,
label: localize(
`ui.panel.lovelace.editor.sidebar.position_options.${computeSide(position, isRTL)}`
),
image: {
src: `/static/images/form/sidebar_position_${position}.svg`,
src_dark: `/static/images/form/sidebar_position_${position}_dark.svg`,
flip_rtl: true,
},
})),
},
},
},
{
name: "mobile_position",
required: true,
selector: {
select: {
mode: "box",
options: SIDEBAR_MOBILE_POSITIONS.map((position) => ({
value: position,
label: localize(
`ui.panel.lovelace.editor.sidebar.mobile_position_options.${position}`
),
image: {
src: `/static/images/form/sidebar_mobile_position_${position}.svg`,
src_dark: `/static/images/form/sidebar_mobile_position_${position}_dark.svg`,
flip_rtl: true,
},
})),
},
},
},
{
name: "content_label",
visible: { field: "mobile_position", value: "tabs" },
selector: { text: {} },
},
{
name: "sidebar_label",
visible: { field: "mobile_position", value: "tabs" },
selector: { text: {} },
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this._params || !this._config) {
return nothing;
}
const data = {
...this._config,
position: this._config.position ?? DEFAULT_SIDEBAR_POSITION,
mobile_position:
this._config.mobile_position ?? DEFAULT_SIDEBAR_MOBILE_POSITION,
};
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.sidebar.settings"
)}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-tab-group @wa-tab-show=${this._tabChanged}>
${TABS.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._tab === tab}
>
${this.hass.localize(
`ui.panel.lovelace.editor.sidebar.tab_${tab}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
${
this._tab === "settings"
? html`
<ha-form
autofocus
.hass=${this.hass}
.data=${data}
.schema=${this._schema(this.hass.localize, isRTL)}
.computeLabel=${this._computeLabel}
@value-changed=${this._valueChanged}
></ha-form>
`
: html`
<hui-section-visibility-editor
.hass=${this.hass}
.config=${this._config}
@value-changed=${this._valueChanged}
></hui-section-visibility-editor>
`
}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${this._saving || !this.isDirtyState}
.loading=${this._saving}
@click=${this._save}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => this.hass.localize(`ui.panel.lovelace.editor.sidebar.${schema.name}`);
private _tabChanged(ev: CustomEvent<{ name: Tab }>) {
this._tab = ev.detail.name;
}
private _valueChanged(ev: ValueChangedEvent<LovelaceViewSidebarConfig>) {
ev.stopPropagation();
this._config = ev.detail.value;
this._updateDirtyState(this._config);
}
private async _save() {
if (!this._params || !this._config) {
return;
}
const { lovelace, path } = this._params;
this._saving = true;
try {
await lovelace.saveConfig(setAtPath(lovelace.config, path, this._config));
this.closeDialog();
} catch (err: any) {
showAlertDialog(this, {
text: `${this.hass.localize(
"ui.panel.lovelace.editor.sidebar.saving_failed"
)}: ${err.message}`,
});
} finally {
this._saving = false;
}
}
static styles = [
haStyleDialog,
css`
ha-tab-group {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-edit-sidebar": HuiDialogEditSidebar;
}
}
@@ -0,0 +1,19 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface EditSidebarDialogParams {
lovelace: Lovelace;
path: LovelacePath;
}
export const showEditSidebarDialog = (
element: HTMLElement,
params: EditSidebarDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-edit-sidebar",
dialogImport: () => import("./hui-dialog-edit-sidebar"),
dialogParams: params,
});
};
+29
View File
@@ -8,6 +8,7 @@ import {
mdiFormatListBulletedTriangle,
mdiHelpCircleOutline,
mdiMagnify,
mdiPageLayoutSidebarRight,
mdiPencil,
mdiPlus,
mdiRedo,
@@ -58,6 +59,7 @@ import type {
} from "../../data/lovelace/config/types";
import { isStrategyDashboard } from "../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../data/lovelace/config/view";
import { isStrategyView } from "../../data/lovelace/config/view";
import {
deleteDashboard,
fetchDashboards,
@@ -87,6 +89,7 @@ import { showAddIntegrationDialog } from "../config/integrations/show-add-integr
import { showDashboardDetailDialog } from "../config/lovelace/dashboards/show-dialog-lovelace-dashboard-detail";
import { showPersonDetailDialog } from "../config/person/show-dialog-person-detail";
import { swapView } from "./editor/config-util";
import { setAtPath } from "./editor/lovelace-path";
import { showDashboardStrategyEditorDialog } from "./editor/dashboard-strategy-editor/dialogs/show-dialog-dashboard-strategy-editor";
import { showSaveDialog } from "./editor/show-save-config-dialog";
import { showEditViewDialog } from "./editor/view-editor/show-edit-view-dialog";
@@ -97,6 +100,8 @@ import "./views/hui-view";
import type { HUIView } from "./views/hui-view";
import "./views/hui-view-background";
import "./views/hui-view-container";
import { SECTIONS_VIEW_LAYOUT } from "./views/const";
import { getViewType } from "./views/get-view-type";
import { LOVELACE_EDIT_TOAST_ID } from "./types";
interface ActionItem {
@@ -219,7 +224,24 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
const isLovelaceDashboard = this.panel?.component_name === "lovelace";
const curViewConfig =
typeof this._curView === "number"
? this.lovelace?.config.views[this._curView]
: undefined;
const canAddSidebar =
curViewConfig !== undefined &&
!isStrategyView(curViewConfig) &&
getViewType(curViewConfig) === SECTIONS_VIEW_LAYOUT &&
!curViewConfig.sidebar;
const items: ActionItem[] = [
{
icon: mdiPageLayoutSidebarRight,
key: "ui.panel.lovelace.editor.menu.add_sidebar",
overflowAction: this._handleAddSidebar,
visible: this._editMode && canAddSidebar,
overflow: true,
},
{
icon: mdiFormatListBulletedTriangle,
key: "ui.panel.lovelace.unused_entities.title",
@@ -972,6 +994,13 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
});
};
private _handleAddSidebar = () => {
const sidebarPath = ["views", this._curView as number, "sidebar"];
this.lovelace!.saveConfig(
setAtPath(this.lovelace!.config, sidebarPath, { sections: [] })
);
};
private _handleRawEditor = () => {
this.lovelace!.enableFullEditMode();
};
@@ -0,0 +1,199 @@
import { mdiViewGridPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { LovelaceSectionElement } from "../../../data/lovelace";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../types";
import "../components/hui-section-edit-mode";
import type { LovelacePath } from "../editor/lovelace-path";
import { moveAtPath } from "../editor/lovelace-path";
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
import type { Lovelace } from "../types";
import type { HuiSection } from "./hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "./section-container";
@customElement("hui-stack-section")
export class HuiStackSection
extends LitElement
implements LovelaceSectionElement
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ attribute: false }) public path?: LovelacePath;
@property({ attribute: false }) public isStrategy = false;
@property({ attribute: false }) public sections: HuiSection[] = [];
public setConfig(_config: LovelaceSectionConfig): void {
// The stack has no settings of its own
}
protected render() {
const { hass, lovelace, path } = this;
if (!hass) {
return nothing;
}
const editMode = Boolean(lovelace?.editMode && !this.isStrategy);
if (!editMode || !lovelace || !path) {
return html`
<div class="sections">
${repeat(
this.sections,
(section) => section,
(section) =>
html`<div class="section">
${renderSectionContainer(hass, section.config, section)}
</div>`
)}
</div>
`;
}
const sectionsPath = [...path, "sections"];
return html`
<ha-sortable
@item-moved=${this._sectionMoved}
@item-added=${this._sectionAdded}
@item-removed=${this._sectionRemoved}
group="section"
handle-selector=".handle"
draggable-selector=".section"
>
<div class="sections">
${repeat(
this.sections,
(section) => section,
(section, idx) => html`
<div class="section" .sortableData=${[...sectionsPath, idx]}>
<hui-section-edit-mode
.hass=${hass}
.lovelace=${lovelace}
.path=${[...sectionsPath, idx]}
.isStrategy=${isStrategySection(section.config)}
>
${renderSectionContainer(hass, section.config, section)}
</hui-section-edit-mode>
</div>
`
)}
<button
class="create-section"
@click=${this._createSection}
aria-label=${hass.localize(
"ui.panel.lovelace.editor.section.create_section"
)}
.title=${hass.localize(
"ui.panel.lovelace.editor.section.create_section"
)}
>
<ha-ripple></ha-ripple>
<ha-svg-icon .path=${mdiViewGridPlus}></ha-svg-icon>
</button>
</div>
</ha-sortable>
`;
}
private _sectionMoved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const sectionsPath = [...this.path!, "sections"];
this.lovelace!.saveConfig(
moveAtPath(
this.lovelace!.config,
[...sectionsPath, oldIndex],
[...sectionsPath, newIndex]
)
);
}
private _sectionAdded(ev: HASSDomEvent<HASSDomEvents["item-added"]>) {
ev.stopPropagation();
const { index, data } = ev.detail;
this.lovelace!.saveConfig(
moveAtPath(this.lovelace!.config, data as LovelacePath, [
...this.path!,
"sections",
index,
])
);
}
private _sectionRemoved(ev: HASSDomEvent<HASSDomEvents["item-removed"]>) {
// The list the section is dropped in moves it
ev.stopPropagation();
}
private _createSection() {
showCreateSectionDialog(this, {
lovelace: this.lovelace!,
containerPath: this.path!,
});
}
static styles = [
sectionContainerStyles,
css`
:host {
display: block;
width: 100%;
}
.sections {
display: flex;
flex-direction: column;
gap: var(--row-gap, 8px);
}
.section:has(hui-section[hidden]) {
display: none;
}
.create-section {
display: block;
position: relative;
outline: none;
background: none;
cursor: pointer;
border-radius: var(
--ha-section-border-radius,
var(--ha-border-radius-xl)
);
border: 2px dashed var(--primary-color);
height: calc(var(--row-height, 56px) + 2 * (var(--row-gap, 8px) + 2px));
padding: var(--ha-space-2);
box-sizing: border-box;
width: 100%;
--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-section-border-radius,
var(--ha-border-radius-xl)
);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-stack-section": HuiStackSection;
}
}
+131 -34
View File
@@ -8,6 +8,7 @@ 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 type { HASSDomEvent } from "../../../common/dom/fire_event";
import { clamp } from "../../../common/number/clamp";
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
import "../../../components/ha-icon-button";
@@ -17,19 +18,26 @@ import "../../../components/ha-svg-icon";
import { maxColumnsContext } from "../common/context";
import type { LovelaceViewElement } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import {
DEFAULT_SECTION_COLUMN_SPAN,
isStrategySection,
} from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import {
DEFAULT_SIDEBAR_MOBILE_POSITION,
type LovelaceViewConfig,
type LovelaceViewSidebarConfig,
} from "../../../data/lovelace/config/view";
import { DEFAULT_MAX_COLUMNS } from "./const";
import type { HomeAssistant } from "../../../types";
import type { HuiBadge } from "../badges/hui-badge";
import type { HuiCard } from "../cards/hui-card";
import "../components/hui-section-edit-mode";
import "../components/hui-sidebar-edit-header";
import { addSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
import "../sections/hui-section";
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
import type { HuiSection } from "../sections/hui-section";
import {
@@ -40,7 +48,6 @@ import type { Lovelace } from "../types";
import { generateDefaultSection } from "./default-section";
import "./hui-view-footer";
import "./hui-view-header";
import "./hui-view-sidebar";
import { computeSectionsBackgroundAlignment } from "./sections-background-alignment";
const parsePx = (value: string) => parseInt(value.replace("px", ""));
@@ -181,18 +188,19 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const sections = this.sections;
const editMode = this.lovelace.editMode;
const sidebar = this._config?.sidebar;
const sidebarEditMode = editMode && !this.isStrategy;
const hasSidebar =
this._config?.sidebar && (this._sidebarVisible || editMode);
sidebar &&
(editMode || (this._sidebarVisible && Boolean(sidebar.sections?.length)));
const singleColumn = this._maxColumns <= 1;
// The tab switcher is opt-in: a sidebar enables it by labelling both tabs,
// otherwise the sidebar stacks below the content.
const mobilePosition =
this._config?.sidebar?.mobile_position ?? DEFAULT_SIDEBAR_MOBILE_POSITION;
const useSidebarTabs = Boolean(
singleColumn &&
hasSidebar &&
this._config?.sidebar?.content_label &&
this._config?.sidebar?.sidebar_label
singleColumn && hasSidebar && mobilePosition === "tabs"
);
const sidebarOnTop = singleColumn && mobilePosition === "top";
const totalSectionCount =
this._sectionColumnCount + (editMode ? 1 : 0) + (hasSidebar ? 1 : 0);
@@ -218,6 +226,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
"has-sidebar": Boolean(hasSidebar),
"single-column": singleColumn,
"has-sidebar-tabs": useSidebarTabs,
"sidebar-top": sidebarOnTop,
"sidebar-at-start": sidebar?.position === "start",
})}"
style=${styleMap({
"--column-count": columnCount,
@@ -241,11 +251,19 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.options=${[
{
value: "content",
label: this._config!.sidebar!.content_label,
label:
this._config!.sidebar!.content_label ||
this.hass.localize(
"ui.panel.lovelace.views.sidebar_tabs.content"
),
},
{
value: "sidebar",
label: this._config!.sidebar!.sidebar_label,
label:
this._config!.sidebar!.sidebar_label ||
this.hass.localize(
"ui.panel.lovelace.views.sidebar_tabs.sidebar"
),
},
]}
>
@@ -258,6 +276,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<ha-sortable
.disabled=${!editMode}
@item-moved=${this._sectionMoved}
@item-added=${this._sectionAdded}
@item-removed=${this._sectionRemoved}
group="section"
handle-selector=".handle"
draggable-selector=".section"
@@ -281,6 +301,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
return html`
<div
class="section"
.sortableData=${[...this.path!, "sections", idx]}
style=${styleMap({
"--column-span": columnSpan,
"--row-span": rowSpan,
@@ -348,23 +369,41 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
</div>
</ha-sortable>
${
this._config?.sidebar
sidebar
? html`
<hui-view-sidebar
<div
class=${classMap({
sidebar: true,
"edit-mode": sidebarEditMode,
hidden:
!hasSidebar ||
(useSidebarTabs && !this._sidebarTabActive),
})}
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.path=${this.path!}
.config=${this._config.sidebar}
@sidebar-visibility-changed=${
this._handleSidebarVisibilityChanged
>
${
sidebarEditMode
? html`
<hui-sidebar-edit-header
.lovelace=${this.lovelace}
.path=${this._sidebarPath(this.path!)}
></hui-sidebar-edit-header>
`
: nothing
}
></hui-view-sidebar>
<div class="sidebar-content">
<hui-section
.hass=${this.hass}
.lovelace=${this.lovelace}
.config=${this._sidebarSectionConfig(sidebar)}
.path=${this._sidebarPath(this.path!)}
.preview=${editMode}
.insideStrategy=${this.isStrategy}
@section-visibility-changed=${
this._handleSidebarVisibilityChanged
}
></hui-section>
</div>
</div>
`
: nothing
}
@@ -481,6 +520,22 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
this.lovelace!.saveConfig(newConfig);
}
private _sectionAdded(ev: HASSDomEvent<HASSDomEvents["item-added"]>) {
ev.stopPropagation();
const { index, data } = ev.detail;
const newConfig = moveAtPath(this.lovelace!.config, data as LovelacePath, [
...this.path!,
"sections",
index,
]);
this.lovelace!.saveConfig(newConfig);
}
private _sectionRemoved(ev: HASSDomEvent<HASSDomEvents["item-removed"]>) {
// The list the section is dropped in moves it
ev.stopPropagation();
}
private _viewChanged(ev: CustomEvent) {
const newValue = ev.detail.value;
const shouldShowSidebar = newValue === "sidebar";
@@ -512,12 +567,30 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
});
}
// A stack section also hides when all its sections are hidden
private _sidebarSectionConfig = memoizeOne(
(sidebar: LovelaceViewSidebarConfig): LovelaceSectionConfig => ({
type: "stack",
sections: sidebar.sections,
visibility: sidebar.visibility,
})
);
private _sidebarPath = memoizeOne((path: LovelacePath): LovelacePath => [
...path,
"sidebar",
]);
private _handleSidebarVisibilityChanged = (
e: CustomEvent<{ visible: boolean }>
ev: CustomEvent<{ value: boolean }>
) => {
this._sidebarVisible = e.detail.visible;
// Sections inside the sidebar fire the same event
if (ev.target !== ev.currentTarget) {
return;
}
this._sidebarVisible = ev.detail.value;
// Reset sidebar tab when sidebar becomes hidden
if (!e.detail.visible) {
if (!ev.detail.value) {
this._sidebarTabActive = false;
}
};
@@ -575,10 +648,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.container {
display: grid;
grid-template-columns: [content-start] repeat(
var(--content-column-count),
1fr
);
grid-template-columns:
[content-start] repeat(var(--content-column-count), 1fr)
[content-end];
gap: var(--row-gap) var(--column-gap);
padding: var(--row-gap) 0;
align-items: flex-start;
@@ -588,7 +660,13 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.wrapper.has-sidebar .container {
grid-template-columns:
[content-start] repeat(var(--content-column-count), 1fr)
[sidebar-start] 1fr;
[content-end sidebar-start] 1fr [sidebar-end];
}
.wrapper.has-sidebar.sidebar-at-start .container {
grid-template-columns:
[sidebar-start] 1fr [sidebar-end content-start]
repeat(var(--content-column-count), 1fr) [content-end];
}
/* With a single column, content and sidebar stack at full width */
@@ -596,15 +674,33 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
grid-template-columns: 1fr;
}
hui-view-sidebar {
grid-column: sidebar-start / -1;
.sidebar {
grid-column: sidebar-start / sidebar-end;
}
.wrapper.single-column hui-view-sidebar {
.sidebar hui-section {
display: block;
}
.sidebar.edit-mode .sidebar-content {
padding: var(--ha-space-2);
border: 2px dashed var(--divider-color);
border-radius: var(
--ha-section-border-radius,
var(--ha-border-radius-xl)
);
border-start-end-radius: 0;
}
.wrapper.sidebar-top .sidebar {
order: -1;
}
.wrapper.single-column .sidebar {
grid-column: 1 / -1;
}
.wrapper.has-sidebar-tabs hui-view-sidebar {
.wrapper.has-sidebar-tabs .sidebar {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
@@ -641,7 +737,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
.content {
grid-column: content-start / sidebar-start;
grid-column: content-start / content-end;
grid-row: 1 / -1;
display: grid;
align-items: start;
@@ -653,6 +749,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.wrapper.single-column .content {
grid-column: 1 / -1;
grid-row: auto;
}
.wrapper.has-sidebar-tabs .content {
@@ -1,108 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/view";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import type { HomeAssistant } from "../../../types";
import "../sections/hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "../sections/section-container";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
export const DEFAULT_VIEW_SIDEBAR_LAYOUT = "start";
@customElement("hui-view-sidebar")
export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebarConfig>(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public config?: LovelaceViewSidebarConfig;
@property({ attribute: false }) public path!: LovelacePath;
private _visible = true;
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("hass") || changedProperties.has("config")) {
this._updateVisibility();
}
}
protected _updateVisibility(conditionsMet?: boolean) {
if (!this.hass || !this.config) return;
const visible = conditionsMet ?? this._conditionsVisible();
if (visible !== this._visible) {
this._visible = visible;
fireEvent(this, "sidebar-visibility-changed", { visible });
}
}
private _sectionConfigKeys = new WeakMap<LovelaceSectionConfig, string>();
private _getSectionKey(section: LovelaceSectionConfig) {
if (!this._sectionConfigKeys.has(section)) {
this._sectionConfigKeys.set(section, Math.random().toString());
}
return this._sectionConfigKeys.get(section)!;
}
render() {
if (!this.lovelace) return nothing;
// Use preview mode instead of setting lovelace to avoid the sections to be
// editable as it is not yet supported
return html`
<div class="container">
${repeat(
this.config?.sections ?? [],
(section) => this._getSectionKey(section),
(section, idx) => {
const sectionPath = [...this.path, "sidebar", "sections", idx];
const sectionElement = html`
<hui-section
.config=${section}
.hass=${this.hass}
.preview=${this.lovelace.editMode}
.path=${sectionPath}
></hui-section>
`;
return renderSectionContainer(this.hass, section, sectionElement);
}
)}
</div>
`;
}
static styles = [
sectionContainerStyles,
css`
.container {
display: flex;
flex-direction: column;
gap: var(--row-gap, 8px);
width: 100%;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-view-sidebar": HuiViewSidebar;
}
interface HASSDomEvents {
"sidebar-visibility-changed": { visible: boolean };
}
}
@@ -398,6 +398,7 @@ export class SecurityViewStrategy extends ReactiveElement {
],
}
: {}),
mobile_position: "tabs",
content_label: hass.localize(
"ui.panel.lovelace.strategy.security.devices"
),
+28
View File
@@ -10158,6 +10158,10 @@
"saving_failed": "Saving dashboard configuration failed."
},
"views": {
"sidebar_tabs": {
"content": "Main",
"sidebar": "Sidebar"
},
"unnamed_view": "Unnamed view",
"delete_title": "Delete view",
"delete_named_view_only": "''{name}'' view will be deleted.",
@@ -10199,6 +10203,7 @@
"undo_redo_failed_to_apply_changes": "Unable to apply changes: {error}",
"menu": {
"open": "Open dashboard menu",
"add_sidebar": "Add sidebar",
"raw_editor": "Raw configuration editor",
"manage_dashboards": "Manage dashboards",
"manage_resources": "Manage resources"
@@ -10532,6 +10537,29 @@
"built_in": "Built-in sections",
"custom": "Community sections"
},
"sidebar": {
"title": "Sidebar",
"settings": "Sidebar settings",
"tab_settings": "[%key:ui::panel::lovelace::editor::edit_view::tab_settings%]",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
"menu": "Sidebar menu",
"saving_failed": "[%key:ui::panel::lovelace::editor::edit_view::saving_failed%]",
"position": "On large screens",
"position_options": {
"left": "Left",
"right": "Right"
},
"mobile_position": "On small screens",
"mobile_position_options": {
"tabs": "In a separate tab",
"top": "Above the content",
"bottom": "Below the content"
},
"content_label": "Main tab name",
"sidebar_label": "Sidebar tab name",
"delete_title": "Delete sidebar",
"delete_text": "The sidebar and all its sections will be deleted."
},
"edit_section": {
"header": "Section configuration",
"typed_header": "{type} section configuration",