diff --git a/src/data/lovelace/config/view.ts b/src/data/lovelace/config/view.ts index a8d5ecbedd..28e6b17e79 100644 --- a/src/data/lovelace/config/view.ts +++ b/src/data/lovelace/config/view.ts @@ -1,7 +1,10 @@ import type { MediaSelectorValue } from "../../selector"; import type { LovelaceBadgeConfig } from "./badge"; import type { LovelaceCardConfig } from "./card"; -import type { LovelaceSectionRawConfig } from "./section"; +import type { + LovelaceSectionConfig, + LovelaceSectionRawConfig, +} from "./section"; import type { LovelaceStrategyConfig } from "./strategy"; export interface ShowViewConfig { @@ -33,6 +36,12 @@ export interface LovelaceViewHeaderConfig { badges_wrap?: "wrap" | "scroll"; } +export interface LovelaceViewSidebarConfig { + sections?: LovelaceSectionConfig[]; + content_label?: string; + sidebar_label?: string; +} + export interface LovelaceBaseViewConfig { index?: number; title?: string; @@ -56,6 +65,8 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig { cards?: LovelaceCardConfig[]; sections?: LovelaceSectionRawConfig[]; 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 { diff --git a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts index 13fe357aa6..d9be846de8 100644 --- a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts @@ -1,5 +1,6 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; +import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; import { findEntities, @@ -23,8 +24,8 @@ import type { WeatherForecastCardConfig, } from "../../cards/types"; 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 type { Condition } from "../../common/validate-condition"; export interface HomeOverviewViewStrategyConfig { type: "home-overview"; @@ -70,8 +71,12 @@ export class HomeOverviewViewStrategy extends ReactiveElement { 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 = 2; + const maxColumns = 3; + + const largeScreenCondition: Condition = { + condition: "screen", + media_query: "(min-width: 871px)", + }; const floorsSections: LovelaceSectionConfig[] = []; 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( (entityId) => hass.states[entityId] !== undefined ); @@ -176,74 +175,70 @@ export class HomeOverviewViewStrategy extends ReactiveElement { ({ type: "home-summary", summary: "light", - vertical: true, tap_action: { action: "navigate", navigation_path: "/light?historyBack=1", }, grid_options: { - rows: 2, - columns: 4, + columns: 12, }, } satisfies HomeSummaryCard), hasClimate && ({ type: "home-summary", summary: "climate", - vertical: true, tap_action: { action: "navigate", navigation_path: "/climate?historyBack=1", }, grid_options: { - rows: 2, - columns: 4, + columns: 12, }, } satisfies HomeSummaryCard), hasSecurity && ({ type: "home-summary", summary: "security", - vertical: true, tap_action: { action: "navigate", navigation_path: "/security?historyBack=1", }, grid_options: { - rows: 2, - columns: 4, + columns: 12, }, } satisfies HomeSummaryCard), hasMediaPlayers && ({ type: "home-summary", summary: "media_players", - vertical: true, tap_action: { action: "navigate", navigation_path: "media-players", }, grid_options: { - rows: 2, - columns: 4, + columns: 12, }, } satisfies HomeSummaryCard), ].filter(Boolean) as LovelaceCardConfig[]; - const summarySection: LovelaceSectionConfig = { + const forYouSection: LovelaceSectionConfig = { 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: [], }; if (summaryCards.length) { - summarySection.cards!.push( - { - type: "heading", - heading: hass.localize("ui.panel.lovelace.strategy.home.summaries"), - }, - ...summaryCards - ); + widgetSection.cards!.push(...summaryCards); } const weatherFilter = generateEntityFilter(hass, { @@ -251,28 +246,16 @@ export class HomeOverviewViewStrategy extends ReactiveElement { entity_category: "none", }); - const widgetSection: LovelaceSectionConfig = { - type: "grid", - column_span: maxColumns, - cards: [], - }; const weatherEntity = Object.keys(hass.states) .filter(weatherFilter) .sort()[0]; if (weatherEntity) { - widgetSection.cards!.push( - { - type: "heading", - heading: "", - heading_style: "subtitle", - }, - { - type: "weather-forecast", - entity: weatherEntity, - forecast_type: "daily", - } as WeatherForecastCardConfig - ); + widgetSection.cards!.push({ + type: "weather-forecast", + entity: weatherEntity, + forecast_type: "daily", + } as WeatherForecastCardConfig); } const energyPrefs = isComponentLoaded(hass, "energy") @@ -299,11 +282,19 @@ export class HomeOverviewViewStrategy extends ReactiveElement { const sections = ( [ - favoriteSection.cards && favoriteSection, + { + type: "grid", + cards: [ + // Heading to add some spacing on large screens + { + type: "heading", + heading_style: "subtitle", + visibility: [largeScreenCondition], + }, + ], + }, commonControlsSection, - summarySection.cards && summarySection, ...floorsSections, - widgetSection.cards && widgetSection, ] satisfies (LovelaceSectionRawConfig | undefined)[] ).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 }}" })}`, } 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"), + }, }; } } diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index 27f4895d73..7c24baac8f 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -31,6 +31,7 @@ import { import type { HuiSection } from "../sections/hui-section"; import type { Lovelace } from "../types"; import "./hui-view-header"; +import "./hui-view-sidebar"; export const DEFAULT_MAX_COLUMNS = 4; @@ -46,6 +47,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement { @property({ attribute: false }) public isStrategy = false; + @property({ type: Boolean }) public narrow = false; + @property({ attribute: false }) public sections: HuiSection[] = []; @property({ attribute: false }) public cards: HuiCard[] = []; @@ -58,6 +61,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement { @state() _dragging = false; + @state() private _showSidebar = false; + + private _contentScrollTop = 0; + + private _sidebarScrollTop = 0; + private _columnsController = new ResizeController(this, { callback: (entries) => { const totalWidth = entries[0]?.contentRect.width; @@ -135,16 +144,31 @@ export class SectionsView extends LitElement implements LovelaceViewElement { const sections = this.sections; 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 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`
- -
+ + +
+ ` + : nothing} +
+ - ${repeat( - sections, - (section) => this._getSectionKey(section), - (section, idx) => { - const columnSpan = Math.min( - section.config.column_span || 1, - maxColumnCount - ); - const rowSpan = section.config.row_span || 1; +
+ ${repeat( + sections, + (section) => this._getSectionKey(section), + (section, idx) => { + const columnSpan = Math.min( + section.config.column_span || 1, + contentColumnCount + ); + const rowSpan = section.config.row_span || 1; - return html` + return html`
`; - } - )} - ${editMode - ? html` - -
- - - ` - : nothing} - ${editMode && this._config?.cards?.length - ? html` -
-
-

- - ${this.hass.localize( - "ui.panel.lovelace.editor.section.imported_cards_title" - )} -

-

- ${this.hass.localize( - "ui.panel.lovelace.editor.section.imported_cards_description" - )} -

-
- + ` + : nothing} +
+ + ${this._config?.sidebar + ? html` + + ` + : nothing} +
+
+ ${editMode && this._config?.cards?.length + ? html` +
+
+

+ + ${this.hass.localize( + "ui.panel.lovelace.editor.section.imported_cards_title" )} - .viewIndex=${this.index} - preview - import-only - > +

+

+ ${this.hass.localize( + "ui.panel.lovelace.editor.section.imported_cards_description" + )} +

- ` - : nothing} -
- + +
+ ` + : nothing} +
`; } @@ -352,6 +409,34 @@ export class SectionsView extends LitElement implements LovelaceViewElement { 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` :host { --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; - 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 > * { - position: relative; - width: 100%; + .wrapper.top-margin { + margin-top: var(--top-margin); } .section { @@ -390,22 +480,92 @@ export class SectionsView extends LitElement implements LovelaceViewElement { } .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; align-items: start; justify-content: center; - grid-template-columns: repeat(var(--column-count), 1fr); + grid-template-columns: repeat(var(--content-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) + } + + .wrapper.narrow .content { + grid-column: 1 / -1; + } + + .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; } @@ -483,13 +643,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { hui-view-header { display: block; - 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) - ); } .imported-cards { diff --git a/src/panels/lovelace/views/hui-view-sidebar.ts b/src/panels/lovelace/views/hui-view-sidebar.ts new file mode 100644 index 0000000000..2bcbd6bf9f --- /dev/null +++ b/src/panels/lovelace/views/hui-view-sidebar.ts @@ -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` +
+ ${repeat( + this.config?.sections || [], + (section) => html` + + ` + )} +
+ `; + } + + static styles = css` + .container { + display: flex; + flex-direction: column; + gap: var(--row-gap, 8px); + width: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-view-sidebar": HuiViewSidebar; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 7839169979..aab2e083ec 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7088,7 +7088,9 @@ "unamed_device": "Unnamed device", "others": "Others", "scenes": "Scenes", - "automations": "Automations" + "automations": "Automations", + "for_you": "For you", + "home": "Home" }, "common_controls": { "not_loaded": "Usage Prediction integration is not loaded.", @@ -7360,6 +7362,8 @@ "header": "View configuration", "header_name": "{name} view configuration", "add": "Add view", + "show_sidebar": "Show sidebar", + "show_content": "Show content", "background": { "settings": "Background settings", "image": "Background image",