1
0
mirror of https://github.com/home-assistant/frontend.git synced 2025-12-20 02:38:53 +00:00

Add sidebar to home dashboard (#28084)

This commit is contained in:
Paul Bottein
2025-11-26 09:58:31 +01:00
committed by GitHub
parent ae3a405a7b
commit 71a29aa97d
5 changed files with 386 additions and 164 deletions

View File

@@ -1,7 +1,10 @@
import type { MediaSelectorValue } from "../../selector"; import type { MediaSelectorValue } from "../../selector";
import type { LovelaceBadgeConfig } from "./badge"; import type { LovelaceBadgeConfig } from "./badge";
import type { LovelaceCardConfig } from "./card"; import type { LovelaceCardConfig } from "./card";
import type { LovelaceSectionRawConfig } from "./section"; import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
} from "./section";
import type { LovelaceStrategyConfig } from "./strategy"; import type { LovelaceStrategyConfig } from "./strategy";
export interface ShowViewConfig { export interface ShowViewConfig {
@@ -33,6 +36,12 @@ export interface LovelaceViewHeaderConfig {
badges_wrap?: "wrap" | "scroll"; badges_wrap?: "wrap" | "scroll";
} }
export interface LovelaceViewSidebarConfig {
sections?: LovelaceSectionConfig[];
content_label?: string;
sidebar_label?: string;
}
export interface LovelaceBaseViewConfig { export interface LovelaceBaseViewConfig {
index?: number; index?: number;
title?: string; title?: string;
@@ -56,6 +65,8 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
cards?: LovelaceCardConfig[]; cards?: LovelaceCardConfig[];
sections?: LovelaceSectionRawConfig[]; sections?: LovelaceSectionRawConfig[];
header?: LovelaceViewHeaderConfig; header?: LovelaceViewHeaderConfig;
// Only used for section view, it should move to a section view config type when the views will have dedicated editor.
sidebar?: LovelaceViewSidebarConfig;
} }
export interface LovelaceStrategyViewConfig extends LovelaceBaseViewConfig { export interface LovelaceStrategyViewConfig extends LovelaceBaseViewConfig {

View File

@@ -1,5 +1,6 @@
import { ReactiveElement } from "lit"; import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { import {
findEntities, findEntities,
@@ -23,8 +24,8 @@ import type {
WeatherForecastCardConfig, WeatherForecastCardConfig,
} from "../../cards/types"; } from "../../cards/types";
import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy"; import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries"; import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
import type { Condition } from "../../common/validate-condition";
export interface HomeOverviewViewStrategyConfig { export interface HomeOverviewViewStrategyConfig {
type: "home-overview"; type: "home-overview";
@@ -70,8 +71,12 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
const floorCount = home.floors.length + (home.areas.length ? 1 : 0); const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
// Allow between 2 and 3 columns (the max should be set to define the width of the header) const maxColumns = 3;
const maxColumns = 2;
const largeScreenCondition: Condition = {
condition: "screen",
media_query: "(min-width: 871px)",
};
const floorsSections: LovelaceSectionConfig[] = []; const floorsSections: LovelaceSectionConfig[] = [];
for (const floorStructure of home.floors) { for (const floorStructure of home.floors) {
@@ -126,12 +131,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}); });
} }
const favoriteSection: LovelaceSectionConfig = {
type: "grid",
column_span: maxColumns,
cards: [],
};
const favoriteEntities = (config.favorite_entities || []).filter( const favoriteEntities = (config.favorite_entities || []).filter(
(entityId) => hass.states[entityId] !== undefined (entityId) => hass.states[entityId] !== undefined
); );
@@ -176,74 +175,70 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
({ ({
type: "home-summary", type: "home-summary",
summary: "light", summary: "light",
vertical: true,
tap_action: { tap_action: {
action: "navigate", action: "navigate",
navigation_path: "/light?historyBack=1", navigation_path: "/light?historyBack=1",
}, },
grid_options: { grid_options: {
rows: 2, columns: 12,
columns: 4,
}, },
} satisfies HomeSummaryCard), } satisfies HomeSummaryCard),
hasClimate && hasClimate &&
({ ({
type: "home-summary", type: "home-summary",
summary: "climate", summary: "climate",
vertical: true,
tap_action: { tap_action: {
action: "navigate", action: "navigate",
navigation_path: "/climate?historyBack=1", navigation_path: "/climate?historyBack=1",
}, },
grid_options: { grid_options: {
rows: 2, columns: 12,
columns: 4,
}, },
} satisfies HomeSummaryCard), } satisfies HomeSummaryCard),
hasSecurity && hasSecurity &&
({ ({
type: "home-summary", type: "home-summary",
summary: "security", summary: "security",
vertical: true,
tap_action: { tap_action: {
action: "navigate", action: "navigate",
navigation_path: "/security?historyBack=1", navigation_path: "/security?historyBack=1",
}, },
grid_options: { grid_options: {
rows: 2, columns: 12,
columns: 4,
}, },
} satisfies HomeSummaryCard), } satisfies HomeSummaryCard),
hasMediaPlayers && hasMediaPlayers &&
({ ({
type: "home-summary", type: "home-summary",
summary: "media_players", summary: "media_players",
vertical: true,
tap_action: { tap_action: {
action: "navigate", action: "navigate",
navigation_path: "media-players", navigation_path: "media-players",
}, },
grid_options: { grid_options: {
rows: 2, columns: 12,
columns: 4,
}, },
} satisfies HomeSummaryCard), } satisfies HomeSummaryCard),
].filter(Boolean) as LovelaceCardConfig[]; ].filter(Boolean) as LovelaceCardConfig[];
const summarySection: LovelaceSectionConfig = { const forYouSection: LovelaceSectionConfig = {
type: "grid", type: "grid",
column_span: maxColumns, cards: [
{
type: "heading",
heading: hass.localize("ui.panel.lovelace.strategy.home.for_you"),
heading_style: "title",
visibility: [largeScreenCondition],
},
],
};
const widgetSection: LovelaceSectionConfig = {
cards: [], cards: [],
}; };
if (summaryCards.length) { if (summaryCards.length) {
summarySection.cards!.push( widgetSection.cards!.push(...summaryCards);
{
type: "heading",
heading: hass.localize("ui.panel.lovelace.strategy.home.summaries"),
},
...summaryCards
);
} }
const weatherFilter = generateEntityFilter(hass, { const weatherFilter = generateEntityFilter(hass, {
@@ -251,28 +246,16 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
entity_category: "none", entity_category: "none",
}); });
const widgetSection: LovelaceSectionConfig = {
type: "grid",
column_span: maxColumns,
cards: [],
};
const weatherEntity = Object.keys(hass.states) const weatherEntity = Object.keys(hass.states)
.filter(weatherFilter) .filter(weatherFilter)
.sort()[0]; .sort()[0];
if (weatherEntity) { if (weatherEntity) {
widgetSection.cards!.push( widgetSection.cards!.push({
{
type: "heading",
heading: "",
heading_style: "subtitle",
},
{
type: "weather-forecast", type: "weather-forecast",
entity: weatherEntity, entity: weatherEntity,
forecast_type: "daily", forecast_type: "daily",
} as WeatherForecastCardConfig } as WeatherForecastCardConfig);
);
} }
const energyPrefs = isComponentLoaded(hass, "energy") const energyPrefs = isComponentLoaded(hass, "energy")
@@ -299,11 +282,19 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
const sections = ( const sections = (
[ [
favoriteSection.cards && favoriteSection, {
type: "grid",
cards: [
// Heading to add some spacing on large screens
{
type: "heading",
heading_style: "subtitle",
visibility: [largeScreenCondition],
},
],
},
commonControlsSection, commonControlsSection,
summarySection.cards && summarySection,
...floorsSections, ...floorsSections,
widgetSection.cards && widgetSection,
] satisfies (LovelaceSectionRawConfig | undefined)[] ] satisfies (LovelaceSectionRawConfig | undefined)[]
).filter(Boolean) as LovelaceSectionRawConfig[]; ).filter(Boolean) as LovelaceSectionRawConfig[];
@@ -319,6 +310,11 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
content: `## ${hass.localize("ui.panel.lovelace.strategy.home.welcome_user", { user: "{{ user }}" })}`, content: `## ${hass.localize("ui.panel.lovelace.strategy.home.welcome_user", { user: "{{ user }}" })}`,
} satisfies MarkdownCardConfig, } satisfies MarkdownCardConfig,
}, },
sidebar: {
sections: [forYouSection, widgetSection],
content_label: hass.localize("ui.panel.lovelace.strategy.home.home"),
sidebar_label: hass.localize("ui.panel.lovelace.strategy.home.for_you"),
},
}; };
} }
} }

View File

@@ -31,6 +31,7 @@ import {
import type { HuiSection } from "../sections/hui-section"; import type { HuiSection } from "../sections/hui-section";
import type { Lovelace } from "../types"; import type { Lovelace } from "../types";
import "./hui-view-header"; import "./hui-view-header";
import "./hui-view-sidebar";
export const DEFAULT_MAX_COLUMNS = 4; export const DEFAULT_MAX_COLUMNS = 4;
@@ -46,6 +47,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public isStrategy = false; @property({ attribute: false }) public isStrategy = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public sections: HuiSection[] = []; @property({ attribute: false }) public sections: HuiSection[] = [];
@property({ attribute: false }) public cards: HuiCard[] = []; @property({ attribute: false }) public cards: HuiCard[] = [];
@@ -58,6 +61,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
@state() _dragging = false; @state() _dragging = false;
@state() private _showSidebar = false;
private _contentScrollTop = 0;
private _sidebarScrollTop = 0;
private _columnsController = new ResizeController(this, { private _columnsController = new ResizeController(this, {
callback: (entries) => { callback: (entries) => {
const totalWidth = entries[0]?.contentRect.width; const totalWidth = entries[0]?.contentRect.width;
@@ -135,16 +144,31 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const sections = this.sections; const sections = this.sections;
const totalSectionCount = const totalSectionCount =
this._sectionColumnCount + (this.lovelace?.editMode ? 1 : 0); this._sectionColumnCount +
(this.lovelace?.editMode ? 1 : 0) +
(this._config?.sidebar ? 1 : 0);
const editMode = this.lovelace.editMode; const editMode = this.lovelace.editMode;
const maxColumnCount = this._columnsController.value ?? 1; const maxColumnCount = this._columnsController.value ?? 1;
const columnCount = Math.min(maxColumnCount, totalSectionCount);
// On mobile with sidebar, use full width for whichever view is active
const contentColumnCount =
this._config?.sidebar && !this.narrow
? Math.max(1, columnCount - 1)
: columnCount;
return html` return html`
<div <div
class="wrapper ${classMap({ class="wrapper ${classMap({
"top-margin": Boolean(this._config?.top_margin), "top-margin": Boolean(this._config?.top_margin),
"has-sidebar": Boolean(this._config?.sidebar),
narrow: this.narrow,
})}" })}"
style=${styleMap({
"--column-count": columnCount,
"--content-column-count": contentColumnCount,
})}
> >
<hui-view-header <hui-view-header
.hass=${this.hass} .hass=${this.hass}
@@ -152,10 +176,29 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.lovelace=${this.lovelace} .lovelace=${this.lovelace}
.viewIndex=${this.index} .viewIndex=${this.index}
.config=${this._config?.header} .config=${this._config?.header}
style=${styleMap({
"--max-column-count": maxColumnCount,
})}
></hui-view-header> ></hui-view-header>
${this.narrow && this._config?.sidebar
? html`
<div class="mobile-tabs">
<ha-control-select
.value=${this._showSidebar ? "sidebar" : "content"}
@value-changed=${this._viewChanged}
.options=${[
{
value: "content",
label: this._config.sidebar.content_label,
},
{
value: "sidebar",
label: this._config.sidebar.sidebar_label,
},
]}
>
</ha-control-select>
</div>
`
: nothing}
<div class="container">
<ha-sortable <ha-sortable
.disabled=${!editMode} .disabled=${!editMode}
@item-moved=${this._sectionMoved} @item-moved=${this._sectionMoved}
@@ -165,13 +208,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.rollback=${false} .rollback=${false}
> >
<div <div
class="container ${classMap({ class="content ${classMap({
dense: Boolean(this._config?.dense_section_placement), dense: Boolean(this._config?.dense_section_placement),
"mobile-hidden": this.narrow && this._showSidebar,
})}" })}"
style=${styleMap({
"--total-section-count": totalSectionCount,
"--max-column-count": maxColumnCount,
})}
> >
${repeat( ${repeat(
sections, sections,
@@ -179,7 +219,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
(section, idx) => { (section, idx) => {
const columnSpan = Math.min( const columnSpan = Math.min(
section.config.column_span || 1, section.config.column_span || 1,
maxColumnCount contentColumnCount
); );
const rowSpan = section.config.row_span || 1; const rowSpan = section.config.row_span || 1;
@@ -243,6 +283,24 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
</ha-sortable> </ha-sortable>
` `
: nothing} : nothing}
</div>
</ha-sortable>
${this._config?.sidebar
? html`
<hui-view-sidebar
class=${classMap({
"mobile-hidden": this.narrow && !this._showSidebar,
})}
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.config=${this._config.sidebar}
></hui-view-sidebar>
`
: nothing}
</div>
<div class="imported-cards-section">
${editMode && this._config?.cards?.length ${editMode && this._config?.cards?.length
? html` ? html`
<div class="section imported-cards"> <div class="section imported-cards">
@@ -273,7 +331,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
` `
: nothing} : nothing}
</div> </div>
</ha-sortable>
</div> </div>
`; `;
} }
@@ -352,6 +409,34 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
this.lovelace!.saveConfig(newConfig); this.lovelace!.saveConfig(newConfig);
} }
private _viewChanged(ev: CustomEvent) {
const newValue = ev.detail.value;
const shouldShowSidebar = newValue === "sidebar";
if (shouldShowSidebar !== this._showSidebar) {
this._toggleView();
}
}
private _toggleView() {
// Save current scroll position
if (this._showSidebar) {
this._sidebarScrollTop = window.scrollY;
} else {
this._contentScrollTop = window.scrollY;
}
this._showSidebar = !this._showSidebar;
// Restore scroll position after view updates
this.updateComplete.then(() => {
const scrollY = this._showSidebar
? this._sidebarScrollTop
: this._contentScrollTop;
window.scrollTo(0, scrollY);
});
}
static styles = css` static styles = css`
:host { :host {
--row-height: var(--ha-view-sections-row-height, 56px); --row-height: var(--ha-view-sections-row-height, 56px);
@@ -369,14 +454,19 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
} }
} }
.wrapper.top-margin { .wrapper {
display: block; display: block;
margin-top: var(--top-margin); 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 > * { .wrapper.top-margin {
position: relative; margin-top: var(--top-margin);
width: 100%;
} }
.section { .section {
@@ -390,22 +480,92 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
} }
.container { .container {
--column-count: min(var(--max-column-count), var(--total-section-count)); display: grid;
grid-template-columns: [content-start] repeat(
var(--content-column-count),
1fr
);
gap: var(--row-gap) var(--column-gap);
padding: var(--row-gap) 0;
}
.wrapper.has-sidebar .container {
grid-template-columns:
[content-start] repeat(var(--content-column-count), 1fr)
[sidebar-start] 1fr;
}
/* On mobile with sidebar, content and sidebar both take full width */
.wrapper.narrow.has-sidebar .container {
grid-template-columns: 1fr;
}
hui-view-sidebar {
grid-column: sidebar-start / -1;
}
.wrapper.narrow hui-view-sidebar {
grid-column: 1 / -1;
padding-bottom: calc(
var(--ha-space-4) + 56px + var(--ha-space-4) +
env(safe-area-inset-bottom)
);
}
.mobile-hidden {
display: none !important;
}
.mobile-tabs {
position: fixed;
bottom: calc(var(--ha-space-4) + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
padding: 0;
z-index: 1;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15))
drop-shadow(0 4px 16px rgba(0, 0, 0, 0.1));
}
.mobile-tabs ha-control-select {
width: max-content;
min-width: 280px;
max-width: 90%;
--control-select-thickness: 56px;
--control-select-border-radius: var(--ha-border-radius-6xl);
--control-select-background: var(--card-background-color);
--control-select-background-opacity: 1;
--control-select-color: var(--primary-color);
--control-select-padding: 6px;
}
ha-sortable {
display: contents;
}
.content {
grid-column: content-start / sidebar-start;
grid-row: 1 / -1;
display: grid; display: grid;
align-items: start; align-items: start;
justify-content: center; justify-content: center;
grid-template-columns: repeat(var(--column-count), 1fr); grid-template-columns: repeat(var(--content-column-count), 1fr);
grid-auto-flow: row; grid-auto-flow: row;
gap: var(--row-gap) var(--column-gap); gap: var(--row-gap) var(--column-gap);
padding: var(--row-gap) var(--column-gap); }
box-sizing: content-box;
margin: 0 auto; .wrapper.narrow .content {
max-width: calc( grid-column: 1 / -1;
var(--column-count) * var(--column-max-width) + }
(var(--column-count) - 1) * var(--column-gap)
.wrapper.narrow.has-sidebar .content {
padding-bottom: calc(
var(--ha-space-4) + 56px + var(--ha-space-4) +
env(safe-area-inset-bottom)
); );
} }
.container.dense {
.content.dense {
grid-auto-flow: row dense; grid-auto-flow: row dense;
} }
@@ -483,13 +643,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
hui-view-header { hui-view-header {
display: block; display: block;
padding: 0 var(--column-gap);
padding-top: var(--row-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)
);
} }
.imported-cards { .imported-cards {

View File

@@ -0,0 +1,57 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import "../sections/hui-section";
import type { Lovelace } from "../types";
export const DEFAULT_VIEW_SIDEBAR_LAYOUT = "start";
@customElement("hui-view-sidebar")
export class HuiViewSidebar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public config?: LovelaceViewSidebarConfig;
@property({ attribute: false }) public viewIndex!: number;
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) => html`
<hui-section
.config=${section}
.hass=${this.hass}
.preview=${this.lovelace.editMode}
.viewIndex=${this.viewIndex}
></hui-section>
`
)}
</div>
`;
}
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--row-gap, 8px);
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-view-sidebar": HuiViewSidebar;
}
}

View File

@@ -7088,7 +7088,9 @@
"unamed_device": "Unnamed device", "unamed_device": "Unnamed device",
"others": "Others", "others": "Others",
"scenes": "Scenes", "scenes": "Scenes",
"automations": "Automations" "automations": "Automations",
"for_you": "For you",
"home": "Home"
}, },
"common_controls": { "common_controls": {
"not_loaded": "Usage Prediction integration is not loaded.", "not_loaded": "Usage Prediction integration is not loaded.",
@@ -7360,6 +7362,8 @@
"header": "View configuration", "header": "View configuration",
"header_name": "{name} view configuration", "header_name": "{name} view configuration",
"add": "Add view", "add": "Add view",
"show_sidebar": "Show sidebar",
"show_content": "Show content",
"background": { "background": {
"settings": "Background settings", "settings": "Background settings",
"image": "Background image", "image": "Background image",