Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 5.5 fbb5cca794 Split the grouped Settings sections into individual entries
Devices & services, Automations & scenes and Areas, labels & zones are
replaced by one Settings entry per page, grouped in three cards. These pages
now pass only their own page as tab.

Settings cards without any visible entries are hidden, the Apps entry now
uses the mdiApps icon, every Settings entry has a distinct colour, and
"blueprint" is added to the demo config.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 6ddbb68444 Show page tabs in a row under the toolbar on narrow screens
On narrow screens tabs now render as a scrollable tab row below the toolbar
instead of a bottom bar, and the bottom bar code is removed. Wide screens are
unchanged.

Narrow pages without a FAB now pad their content for the bottom safe area.
Pages that already clear it themselves, like data tables, opt out by setting
--tabs-subpage-content-padding-bottom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 906cd7bbec Use the active tab name as the page title on narrow screens
Tabbed subpages without an explicit header show the active tab name in the
toolbar on narrow screens. Which tabs are shown is now computed in willUpdate
instead of during render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 30467c3a51 Add a bottom bar height variable for bottom offsets
Introduce --ha-bottom-bar-height and express the bottom offsets of subpage
content, FABs, data table empty rows and toasts with it. Remove the unused
#fab[is-wide] and #fab.tabs selectors. No visible change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 fc760b9325 Move PageNavigation type to its own data module
PageNavigation is used far beyond the tabs subpage layout, so it now lives in
src/data/page_navigation.ts. No behavior change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
45 changed files with 640 additions and 404 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../data/page_navigation";
import type { HomeAssistant, HomeAssistantConfig } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../data/page_navigation";
import type { HomeAssistant } from "../../types";
import { canShowPage } from "./can_show_page";
+1 -1
View File
@@ -5,7 +5,7 @@ import "./ha-icon-next";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../data/page_navigation";
import type { HomeAssistant } from "../types";
@customElement("ha-config-navigation-list")
+2 -28
View File
@@ -8,8 +8,6 @@ import "./ha-ripple";
export class HaTab extends LitElement {
@property({ type: Boolean, reflect: true }) public active = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property() public name?: string;
// A short note next to the name, like how many updates wait there
@@ -47,7 +45,7 @@ export class HaTab extends LitElement {
div {
padding: 0 32px;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
text-align: center;
box-sizing: border-box;
align-items: center;
@@ -59,11 +57,6 @@ export class HaTab extends LitElement {
outline: none;
}
:host(:not([narrow])) div {
flex-direction: row;
gap: var(--ha-space-2);
}
.badge {
padding: 0 var(--ha-space-2);
border-radius: var(--ha-border-radius-pill);
@@ -85,29 +78,10 @@ export class HaTab extends LitElement {
color: var(--primary-color);
}
:host(:not([narrow])[active]) div {
:host([active]) div {
border-bottom: 2px solid var(--primary-color);
}
:host([narrow]) {
min-width: 0;
display: flex;
justify-content: center;
overflow: hidden;
}
:host([narrow]) div {
padding: 0 4px;
}
::slotted([slot="icon"]) {
margin-bottom: var(--ha-space-1);
}
:host(:not([narrow])) ::slotted([slot="icon"]) {
margin-bottom: 0;
}
div:focus-visible:before {
position: absolute;
display: block;
+5 -2
View File
@@ -232,8 +232,11 @@ export class HaToast extends LitElement {
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--ha-toast-bottom-offset, 0px)
max(
var(--safe-area-inset-bottom, 0px),
var(--ha-bottom-bar-height, 0px)
) +
var(--ha-space-4) + var(--ha-toast-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
+20
View File
@@ -0,0 +1,20 @@
import type { HomeAssistant } from "../types";
export interface PageNavigation {
path: string;
translationKey?: string;
component?: string | string[];
name?: string;
core?: boolean;
/** Hide from non-admin users in filtered navigation and quick bar. */
adminOnly?: boolean;
iconPath?: string;
iconSecondaryPath?: string;
iconViewBox?: string;
description?: string;
iconColor?: string;
// Shown next to the name of the tab
badge?: string;
info?: any;
filter?: (hass: Pick<HomeAssistant, "entities">) => boolean;
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
mdiPlayBoxMultiple,
} from "@mdi/js";
import type { LocalizeKeys } from "../common/translations/localize";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { PageNavigation } from "./page_navigation";
import type { HomeAssistant, PanelInfo } from "../types";
export const APP_PANEL = "app";
@@ -109,8 +109,8 @@ export const getConfigSubpageTitle = (
): string | undefined => {
const sections = Object.entries(configSections);
// Prefer pages with a full translation key or a name. The dashboard
// sections also list pages like /config/integrations under a broader title.
// Prefer pages with a full translation key or a name. The Settings page
// entries list pages like /config/integrations again with a short key.
for (const [, pages] of sections) {
const pageNav = pages.find((nav) => path.startsWith(nav.path));
if (!pageNav) {
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from "../common/config/filter_navigation_pages";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { PageNavigation } from "./page_navigation";
import { configSections } from "../panels/config/config-sections";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
+1
View File
@@ -57,6 +57,7 @@ export const demoConfig: HassConfig = {
"date",
"fan",
"automation",
"blueprint",
"weather",
"climate",
"stt",
+18 -12
View File
@@ -155,9 +155,18 @@ class HassSubpage extends LitElement {
1px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
) - max(
var(--safe-area-inset-bottom, 0px),
var(--ha-bottom-bar-height, 0px)
)
);
padding-bottom: max(
0px,
var(--safe-area-inset-bottom, 0px) - var(
--ha-bottom-bar-height,
0px
)
);
padding-bottom: var(--safe-area-inset-bottom, 0px);
margin-right: var(--safe-area-inset-right);
overflow-y: auto;
overflow: auto;
@@ -184,7 +193,13 @@ class HassSubpage extends LitElement {
right: calc(16px + var(--safe-area-inset-right, 0px));
inset-inline-end: calc(16px + var(--safe-area-inset-right, 0px));
inset-inline-start: initial;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
bottom: calc(
16px +
max(
var(--safe-area-inset-bottom, 0px),
var(--ha-bottom-bar-height, 0px)
)
);
z-index: 1;
display: flex;
flex-wrap: wrap;
@@ -192,15 +207,6 @@ class HassSubpage extends LitElement {
gap: var(--ha-space-2);
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
:host([narrow]) #fab.tabs {
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
}
#fab[is-wide] {
bottom: calc(24px + var(--safe-area-inset-bottom, 0px));
right: calc(24px + var(--safe-area-inset-right, 0px));
inset-inline-end: calc(24px + var(--safe-area-inset-right, 0px));
inset-inline-start: initial;
}
`,
];
}
+20 -48
View File
@@ -13,11 +13,10 @@ import {
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
} from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { canShowPage } from "../common/config/can_show_page";
import { consume } from "../common/decorators/consume";
import { fireEvent, type HASSDomTargetEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -41,15 +40,11 @@ import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import "../components/input/ha-input-search";
import type { HaInputSearch } from "../components/input/ha-input-search";
import {
configContext,
entitiesContext,
internationalizationContext,
} from "../data/context";
import { internationalizationContext } from "../data/context";
import type { PageNavigation } from "../data/page_navigation";
import { KeyboardShortcutMixin } from "../mixins/keyboard-shortcut-mixin";
import type { HomeAssistant, Route } from "../types";
import "./hass-tabs-subpage";
import type { PageNavigation } from "./hass-tabs-subpage";
@customElement("hass-tabs-subpage-data-table")
export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@@ -58,14 +53,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@property({ attribute: false }) public localizeFunc?: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -109,13 +96,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@property({ attribute: "has-fab", type: Boolean, reflect: true })
public hasFab = false;
/**
* Show tabs on top or at bottom (narrow) of the page.
* @type {Boolean}
*/
@property({ attribute: "show-tabs", type: Boolean, reflect: true })
public showTabs = false;
/**
* Add an extra row at the bottom of the data table
* @type {TemplateResult}
@@ -243,18 +223,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
this._dataTable.clearSelection();
}
protected willUpdate(changedProperties: PropertyValues) {
if (
changedProperties.has("tabs") ||
changedProperties.has("_hassConfig") ||
changedProperties.has("_entities")
) {
this.showTabs =
this.tabs.filter((page) =>
canShowPage({ ...this._hassConfig, entities: this._entities }, page)
).length > 1;
}
protected willUpdate() {
if (this.hasUpdated) {
return;
}
@@ -786,7 +755,10 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
width: 100%;
height: 100%;
--data-table-border-width: 0;
--data-table-empty-row-height: var(--safe-area-inset-bottom, 0px);
--data-table-empty-row-height: max(
0px,
var(--safe-area-inset-bottom, 0px) - var(--ha-bottom-bar-height, 0px)
);
/* Same insets as the content padding of hass-tabs-subpage */
--data-table-inset-right: var(--safe-area-inset-right, 0px);
}
@@ -805,22 +777,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
display: block;
}
/* Last content row should keep the same padding above the fab as the fab
has to the bottom (16px standard fab bottom padding) + the safe-area inset. */
has to the bottom (16px standard fab bottom padding) + the safe-area inset
not already covered by a bottom bar. */
:host([has-fab]) ha-data-table {
--data-table-empty-row-height: calc(
48px + 16px * 2 + var(--safe-area-inset-bottom, 0px)
48px + 16px * 2 +
max(
0px,
var(--safe-area-inset-bottom, 0px) - var(
--ha-bottom-bar-height,
0px
)
)
);
}
/* In narrow view with tabs shown at the bottom, the tab bar already
accounts for safe-area-inset-bottom. No extra empty-row height is needed. */
:host([narrow][show-tabs]:not([has-fab])) ha-data-table {
--data-table-empty-row-height: 0px;
}
/* Reserve space for fab + doubled narrow-mode bottom padding (28px * 2)
when using narrow layout with bottom tabs. */
:host([narrow][show-tabs][has-fab]) ha-data-table {
--data-table-empty-row-height: calc(48px + 28px * 2);
}
.pane-content {
height: calc(
@@ -836,6 +806,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
:host([narrow]) hass-tabs-subpage {
--main-title-margin: 0;
/* The empty row of the data table clears the safe area */
--tabs-subpage-content-padding-bottom: 0px;
}
:host([narrow]) {
--expansion-panel-summary-padding: 0 16px;
+184 -113
View File
@@ -16,19 +16,25 @@ import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { getHistoryState, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
import "../components/ha-tab";
import "../components/ha-tab-group";
import "../components/ha-tab-group-tab";
import {
configContext,
entitiesContext,
internationalizationContext,
narrowViewportContext,
} from "../data/context";
import type { PageNavigation } from "../data/page_navigation";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
@@ -37,25 +43,6 @@ const normalizePathname = (pathname: string): string =>
? pathname.slice(0, -1)
: pathname;
export interface PageNavigation {
path: string;
translationKey?: string;
component?: string | string[];
name?: string;
core?: boolean;
/** Hide from non-admin users in filtered navigation and quick bar. */
adminOnly?: boolean;
iconPath?: string;
iconSecondaryPath?: string;
iconViewBox?: string;
description?: string;
iconColor?: string;
// Shown next to the name of the tab
badge?: string;
info?: any;
filter?: (hass: Pick<HomeAssistant, "entities">) => boolean;
}
@customElement("hass-tabs-subpage")
export class HassTabsSubpage extends LitElement {
// Unread, kept for callers that still pass it until they move to contexts
@@ -102,7 +89,6 @@ export class HassTabsSubpage extends LitElement {
/**
* Whether tabs are shown (2 or more tabs visible).
* When both, show-tabs and narrow are true, tabs are shown as bottom bar.
* @type {Boolean}
*/
@property({ type: Boolean, attribute: "show-tabs", reflect: true })
@@ -115,45 +101,31 @@ export class HassTabsSubpage extends LitElement {
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
private _getTabs = memoizeOne(
private _shownTabs = memoizeOne(
(
tabs: PageNavigation[],
activeTab: PageNavigation | undefined,
_components,
_language,
_userData,
_narrow,
localizeFunc,
entities: ContextType<typeof entitiesContext>
) => {
const shownTabs = tabs.filter((page) =>
) =>
tabs.filter((page) =>
canShowPage({ ...this._hassConfig, entities }, page)
);
)
);
if (shownTabs.length < 2) {
this.showTabs = false;
if (shownTabs.length === 1) {
const page = shownTabs[0];
return [
page.translationKey ? localizeFunc(page.translationKey) : page.name,
];
}
return [""];
}
this.showTabs = true;
return shownTabs.map(
private _renderTabs = memoizeOne(
(
shownTabs: PageNavigation[],
activeTab: PageNavigation | undefined,
localizeFunc: LocalizeFunc
) =>
shownTabs.map(
(page) => html`
<a href=${page.path} @click=${this._tabClicked}>
<ha-tab
.active=${page.path === activeTab?.path}
.narrow=${this._narrow}
.badge=${page.badge}
.name=${
page.translationKey
? localizeFunc(page.translationKey)
: page.name
}
.name=${this._tabName(page, localizeFunc)}
>
${
page.iconPath
@@ -166,37 +138,83 @@ export class HassTabsSubpage extends LitElement {
</ha-tab>
</a>
`
);
}
)
);
private _renderTabRow = memoizeOne(
(
shownTabs: PageNavigation[],
activeTab: PageNavigation | undefined,
localizeFunc: LocalizeFunc
) => html`
<ha-tab-group
class="tab-row"
activation="manual"
without-scroll-controls
@wa-tab-show=${this._tabShown}
>
${shownTabs.map(
(page) => html`
<ha-tab-group-tab
slot="nav"
.panel=${page.path}
.active=${page.path === activeTab?.path}
>
<a href=${page.path} tabindex="-1" @click=${this._tabRowClicked}>
${this._tabName(page, localizeFunc)}
${
page.badge
? html`<span class="badge">${page.badge}</span>`
: nothing
}
</a>
</ha-tab-group-tab>
`
)}
</ha-tab-group>
`
);
private _tabName(page: PageNavigation, localizeFunc: LocalizeFunc) {
return page.translationKey
? localizeFunc(page.translationKey as LocalizeKeys)
: page.name;
}
private _getShownTabs() {
return this._shownTabs(
this.tabs,
this._hassConfig.config.components,
this._hassConfig.userData,
this._entities
);
}
public willUpdate(changedProperties: PropertyValues<this>) {
this.toggleAttribute("narrow", this._narrow);
if (changedProperties.has("route")) {
if (changedProperties.has("route") || changedProperties.has("tabs")) {
const currentPath = `${this.route.prefix}${this.route.path}`;
this._activeTab = this.tabs.find((tab) =>
this._isActiveTabPath(tab.path, currentPath)
);
}
this.showTabs = this._getShownTabs().length > 1;
super.willUpdate(changedProperties);
}
protected render(): TemplateResult {
const tabs = this._getTabs(
this.tabs,
this._activeTab,
this._hassConfig.config.components,
this._i18n.language,
this._hassConfig.userData,
this._narrow,
this.localizeFunc || this._i18n.localize,
this._entities
);
const localizeFunc = this.localizeFunc || this._i18n.localize;
const shownTabs = this._getShownTabs();
const titleTab = this.showTabs ? this._activeTab : shownTabs[0];
const title = titleTab ? this._tabName(titleTab, localizeFunc) : "";
const tabRow = this.showTabs && this._narrow;
const backPath = sanitizeNavigationPath(this.backPath);
return html`
<div class="toolbar ${classMap({ narrow: this._narrow })}">
<div
class="toolbar ${classMap({ narrow: this._narrow, "has-tab-row": tabRow })}"
>
<slot name="toolbar">
<div class="toolbar-content">
${
@@ -212,13 +230,19 @@ export class HassTabsSubpage extends LitElement {
${
this._narrow || !this.showTabs
? html`<div class="main-title">
<slot name="header">${!this.showTabs ? tabs[0] : ""}</slot>
<slot name="header">${title}</slot>
</div>`
: ""
}
${
this.showTabs && !this._narrow
? html`<div id="tabbar">${tabs}</div>`
? html`<div id="tabbar">
${this._renderTabs(
shownTabs,
this._activeTab,
localizeFunc
)}
</div>`
: ""
}
<div id="toolbar-icon">
@@ -226,12 +250,12 @@ export class HassTabsSubpage extends LitElement {
</div>
</div>
</slot>
${
this.showTabs && this._narrow
? html`<div id="tabbar" class="bottom-bar">${tabs}</div>`
: ""
}
</div>
${
tabRow
? this._renderTabRow(shownTabs, this._activeTab, localizeFunc)
: nothing
}
<div class="container">
${
this.pane
@@ -302,12 +326,37 @@ export class HassTabsSubpage extends LitElement {
await navigate(href, { replace: true });
}
// Plain clicks are left to the tab group, which ignores the click that ends a
// drag scroll. Other clicks keep the browser's link behavior.
private _tabRowClicked(ev: MouseEvent): void {
const href = isNavigationClick(ev);
if (!href) {
ev.stopPropagation();
return;
}
// Without a matching tab the group marks the first tab active, so it
// would not fire wa-tab-show for it.
if (!this._activeTab) {
ev.stopPropagation();
navigate(href, { replace: true });
}
}
// Click and keyboard activation in the tab row
private _tabShown(ev: CustomEvent<{ name: string }>) {
const path = ev.detail.name;
if (path && path !== this._activeTab?.path) {
navigate(path, { replace: true });
}
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: block;
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--primary-background-color);
}
@@ -319,9 +368,8 @@ export class HassTabsSubpage extends LitElement {
.container {
display: flex;
height: calc(
100% - var(--header-height, 0px) - var(--safe-area-inset-top, 0px)
);
flex: 1;
min-height: 0;
}
ha-menu-button {
@@ -341,6 +389,10 @@ export class HassTabsSubpage extends LitElement {
font-weight: var(--ha-font-weight-normal);
border-bottom: 1px solid var(--divider-color);
box-sizing: border-box;
flex-shrink: 0;
}
.toolbar.has-tab-row {
border-bottom: none;
}
:host([narrow]) .toolbar {
padding-left: var(--safe-area-inset-left);
@@ -359,12 +411,10 @@ export class HassTabsSubpage extends LitElement {
color: var(--sidebar-text-color);
text-decoration: none;
}
.bottom-bar a {
width: 25%;
}
#tabbar {
display: flex;
flex: 1;
justify-content: center;
font-size: var(--ha-font-size-m);
overflow: hidden;
}
@@ -374,25 +424,35 @@ export class HassTabsSubpage extends LitElement {
max-width: 45%;
}
#tabbar.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
padding: 0 calc(16px + var(--safe-area-inset-right))
var(--safe-area-inset-bottom)
calc(16px + var(--safe-area-inset-left));
box-sizing: border-box;
.tab-row {
flex-shrink: 0;
padding-left: var(--safe-area-inset-left, 0px);
padding-right: var(--safe-area-inset-right, 0px);
color: var(--sidebar-text-color);
background-color: var(--sidebar-background-color);
border-top: 1px solid var(--divider-color);
justify-content: space-around;
z-index: 2;
font-size: var(--ha-font-size-s);
width: 100%;
border-bottom: 1px solid var(--divider-color);
--ha-tab-track-color: transparent;
}
#tabbar:not(.bottom-bar) {
flex: 1;
justify-content: center;
.tab-row ha-tab-group-tab::part(base) {
padding: 0;
}
.tab-row a {
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-4);
color: inherit;
text-decoration: none;
white-space: nowrap;
}
.badge {
padding: 0 var(--ha-space-2);
border-radius: var(--ha-border-radius-pill);
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-normal);
white-space: nowrap;
color: var(--text-primary-color);
background-color: var(--warning-color);
}
:host(:not([narrow])) #toolbar-icon {
@@ -427,23 +487,37 @@ export class HassTabsSubpage extends LitElement {
padding-right: var(--safe-area-inset-right);
overflow: auto;
-webkit-overflow-scrolling: touch;
margin-bottom: var(--ha-bottom-bar-height, 0px);
}
:host([narrow]) .content {
padding-left: var(--safe-area-inset-left);
}
:host([narrow][show-tabs]) .content {
/* Bottom bar reuses header height */
margin-bottom: calc(
var(--header-height, 0px) + var(--safe-area-inset-bottom, 0px)
/* The fab spacer already clears the safe area. Pages that clear it
themselves, like data tables, set the variable to 0px. */
:host([narrow]:not([has-fab])) .content {
padding-bottom: var(
--tabs-subpage-content-padding-bottom,
max(
0px,
var(--safe-area-inset-bottom, 0px) - var(
--ha-bottom-bar-height,
0px
)
)
);
}
.content .fab-bottom-space {
height: calc(64px + var(--safe-area-inset-bottom, 0px));
}
:host([narrow][show-tabs]) .content .fab-bottom-space {
height: calc(80px + var(--safe-area-inset-bottom, 0px));
height: calc(
64px +
max(
0px,
var(--safe-area-inset-bottom, 0px) - var(
--ha-bottom-bar-height,
0px
)
)
);
}
#fab {
@@ -451,7 +525,13 @@ export class HassTabsSubpage extends LitElement {
right: calc(16px + var(--safe-area-inset-right, 0px));
inset-inline-end: calc(16px + var(--safe-area-inset-right));
inset-inline-start: initial;
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
bottom: calc(
16px +
max(
var(--safe-area-inset-bottom, 0px),
var(--ha-bottom-bar-height, 0px)
)
);
z-index: 1;
display: flex;
flex-wrap: wrap;
@@ -459,15 +539,6 @@ export class HassTabsSubpage extends LitElement {
gap: var(--ha-space-2);
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
:host([narrow][show-tabs]) #fab {
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
}
#fab[is-wide] {
bottom: 24px;
right: 24px;
inset-inline-end: 24px;
inset-inline-start: initial;
}
.pane {
border-right: 1px solid var(--divider-color);
+5 -2
View File
@@ -291,8 +291,11 @@ class NotificationManager extends LitElement {
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
max(
var(--safe-area-inset-bottom, 0px),
var(--ha-bottom-bar-height, 0px)
) +
var(--ha-space-4) + var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
@@ -156,7 +156,7 @@ export class HaConfigApplicationCredentials extends LitElement {
.narrow=${this.narrow}
.route=${this.route}
back-path="/config"
.tabs=${configSections.devices}
.tabs=${configSections.integration_credentials}
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._getApplicationCredentials(
@@ -22,7 +22,7 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../data/page_navigation";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
@@ -54,7 +54,7 @@ import {
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -167,7 +167,7 @@ export class HaConfigAreasDashboard extends LitElement {
.hass=${this.hass}
.isWide=${this.isWide}
back-path="/config"
.tabs=${configSections.areas}
.tabs=${configPageTabs["/config/areas"]}
.route=${this.route}
has-fab
>
@@ -121,7 +121,7 @@ import {
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
@@ -500,7 +500,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
back-path="/config"
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
.tabs=${configPageTabs["/config/automation"]}
.searchLabel=${this.hass.localize(
"ui.panel.config.automation.picker.search",
{ number: automations.length }
@@ -54,7 +54,7 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
type BlueprintMetaDataPath = BlueprintMetaData & {
@@ -329,7 +329,7 @@ class HaBlueprintOverview extends LitElement {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.tabs=${configPageTabs["/config/blueprint"]}
.columns=${this._columns(this.hass.localize)}
.data=${this._processedBlueprints(
this.blueprints,
+148 -126
View File
@@ -1,6 +1,7 @@
import {
mdiAccessPointNetwork,
mdiAccount,
mdiApps,
mdiBackupRestore,
mdiBadgeAccountHorizontal,
mdiBluetooth,
@@ -41,7 +42,7 @@ import {
mdiZWave,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../data/page_navigation";
import type { HomeAssistant } from "../../types";
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
@@ -55,36 +56,144 @@ const getHasDomainCheck = (domain: string) => {
checkRegistry(hass.entities);
};
// Pages that the Settings page lists one by one, with their entry's
// translation key under ui.panel.config.dashboard
const dashboardEntryKeys = {
"/config/integrations": "integrations",
"/config/devices": "device_list",
"/config/entities": "entities",
"/config/helpers": "helpers",
"/config/automation": "automation_list",
"/config/scene": "scenes",
"/config/script": "scripts",
"/config/blueprint": "blueprints",
"/config/areas": "area_list",
"/config/labels": "labels",
"/config/zone": "zones",
} as const;
export type ConfigEntryPagePath = keyof typeof dashboardEntryKeys;
type ConfigEntryPage = PageNavigation & { path: ConfigEntryPagePath };
const devicesPages: ConfigEntryPage[] = [
{
component: "integrations",
path: "/config/integrations",
translationKey: "ui.panel.config.integrations.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "devices",
path: "/config/devices",
translationKey: "ui.panel.config.devices.caption",
iconPath: mdiDevices,
iconColor: "#0277BD",
core: true,
adminOnly: true,
},
{
component: "entities",
path: "/config/entities",
translationKey: "ui.panel.config.entities.caption",
iconPath: mdiShape,
iconColor: "#7E57C2",
core: true,
adminOnly: true,
},
{
component: "helpers",
path: "/config/helpers",
translationKey: "ui.panel.config.helpers.caption",
iconPath: mdiTools,
iconColor: "#4D2EA4",
core: true,
adminOnly: true,
},
];
const automationsPages: ConfigEntryPage[] = [
{
component: "automation",
path: "/config/automation",
translationKey: "ui.panel.config.automation.caption",
iconPath: mdiRobot,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "scene",
path: "/config/scene",
translationKey: "ui.panel.config.scene.caption",
iconPath: mdiPalette,
iconColor: "#00796B",
adminOnly: true,
},
{
component: "script",
path: "/config/script",
translationKey: "ui.panel.config.script.caption",
iconPath: mdiScriptText,
iconColor: "#1B5E20",
adminOnly: true,
},
{
component: "blueprint",
path: "/config/blueprint",
translationKey: "ui.panel.config.blueprint.caption",
iconPath: mdiPaletteSwatch,
iconColor: "#827717",
adminOnly: true,
},
];
const areasPages: ConfigEntryPage[] = [
{
component: "areas",
path: "/config/areas",
translationKey: "ui.panel.config.areas.caption",
iconPath: mdiSofa,
iconColor: "#E48629",
core: true,
adminOnly: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#D84315",
core: true,
adminOnly: true,
},
{
component: "zone",
path: "/config/zone",
translationKey: "ui.panel.config.zone.caption",
iconPath: mdiMapMarkerRadius,
iconColor: "#A1662F",
adminOnly: true,
},
];
const dashboardEntries = (pages: ConfigEntryPage[]): PageNavigation[] =>
pages.map((page) => ({
...page,
translationKey: dashboardEntryKeys[page.path],
}));
export const configSections: Record<string, PageNavigation[]> = {
dashboard_devices: dashboardEntries(devicesPages),
dashboard_automations: dashboardEntries(automationsPages),
dashboard_areas: dashboardEntries(areasPages),
dashboard: [
{
path: "/config/integrations",
translationKey: "devices",
iconPath: mdiDevices,
iconColor: "#0D47A1",
core: true,
adminOnly: true,
},
{
path: "/config/automation",
translationKey: "automations",
iconPath: mdiRobot,
iconColor: "#518C43",
core: true,
adminOnly: true,
},
{
path: "/config/areas",
translationKey: "areas",
iconPath: mdiSofa,
iconColor: "#E48629",
component: "zone",
adminOnly: true,
},
{
path: "/config/apps",
translationKey: "apps",
iconPath: mdiPuzzle,
iconPath: mdiApps,
iconColor: "#F1C447",
core: true,
adminOnly: true,
@@ -287,78 +396,8 @@ export const configSections: Record<string, PageNavigation[]> = {
adminOnly: true,
},
],
devices: [
{
component: "integrations",
path: "/config/integrations",
translationKey: "ui.panel.config.integrations.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "devices",
path: "/config/devices",
translationKey: "ui.panel.config.devices.caption",
iconPath: mdiDevices,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "entities",
path: "/config/entities",
translationKey: "ui.panel.config.entities.caption",
iconPath: mdiShape,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "helpers",
path: "/config/helpers",
translationKey: "ui.panel.config.helpers.caption",
iconPath: mdiTools,
iconColor: "#4D2EA4",
core: true,
adminOnly: true,
},
],
automations: [
{
component: "automation",
path: "/config/automation",
translationKey: "ui.panel.config.automation.caption",
iconPath: mdiRobot,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "scene",
path: "/config/scene",
translationKey: "ui.panel.config.scene.caption",
iconPath: mdiPalette,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "script",
path: "/config/script",
translationKey: "ui.panel.config.script.caption",
iconPath: mdiScriptText,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "blueprint",
path: "/config/blueprint",
translationKey: "ui.panel.config.blueprint.caption",
iconPath: mdiPaletteSwatch,
iconColor: "#518C43",
adminOnly: true,
},
],
devices: devicesPages,
automations: automationsPages,
tags: [
{
component: "tag",
@@ -476,34 +515,7 @@ export const configSections: Record<string, PageNavigation[]> = {
adminOnly: true,
},
],
areas: [
{
component: "areas",
path: "/config/areas",
translationKey: "ui.panel.config.areas.caption",
iconPath: mdiSofa,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "zone",
path: "/config/zone",
translationKey: "ui.panel.config.zone.caption",
iconPath: mdiMapMarkerRadius,
iconColor: "#E48629",
adminOnly: true,
},
],
areas: areasPages,
general: [
{
path: "/config/general",
@@ -611,3 +623,13 @@ export const configSections: Record<string, PageNavigation[]> = {
},
],
};
const singlePageTabs = (pages: ConfigEntryPage[]) =>
Object.fromEntries(pages.map((page) => [page.path, [page]]));
// Pages reached directly from the Settings page, each as its own only tab
export const configPageTabs = {
...singlePageTabs(devicesPages),
...singlePageTabs(automationsPages),
...singlePageTabs(areasPages),
} as Record<ConfigEntryPagePath, PageNavigation[]>;
@@ -10,6 +10,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ctrlOrCmdLabel } from "../../../common/keyboard/ctrl-or-cmd";
import "../../../components/ha-card";
@@ -36,7 +37,7 @@ import {
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../data/page_navigation";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
@@ -180,6 +181,9 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
? pages.filter((page) => page.path !== "/config/apps")
: pages;
return [
configSections.dashboard_devices,
configSections.dashboard_automations,
configSections.dashboard_areas,
isCloudLoaded
? filterApps([
{
@@ -349,7 +353,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
this.hass.userData?.apps_info_dismissed,
isComponentLoaded(this.hass.config, "hassio")
).map((categoryPages) =>
categoryPages.length === 0
filterNavigationPages(this.hass, categoryPages).length === 0
? nothing
: html`
<ha-card outlined>
@@ -8,7 +8,7 @@ import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import type { CloudStatus } from "../../../data/cloud";
import { getConfigEntries } from "../../../data/config_entries";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../data/page_navigation";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
@@ -97,7 +97,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -876,7 +876,7 @@ export class HaConfigDeviceDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.tabs=${configSections.devices}
.tabs=${configPageTabs["/config/devices"]}
.route=${this.route}
.searchLabel=${this.hass.localize(
"ui.panel.config.devices.picker.search",
+1 -1
View File
@@ -38,7 +38,7 @@ import "./components/ha-energy-gas-settings";
import "./components/ha-energy-water-settings";
import { fileDownload } from "../../../util/file_download";
import { showToast } from "../../../util/toast";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../data/page_navigation";
import { showEnergyCustomiseDialog } from "./dialogs/show-dialog-energy-customise";
const INITIAL_CONFIG: EnergyPreferences = {
@@ -116,7 +116,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import type { Helper } from "../helpers/const";
import { isHelperDomain } from "../helpers/const";
import "../integrations/ha-integration-overflow-menu";
@@ -811,7 +811,7 @@ export class HaConfigEntities extends LitElement {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.tabs=${configPageTabs["/config/entities"]}
.columns=${this._columns(this.hass.localize, filteredEntities)}
.data=${filteredEntities}
.searchLabel=${this.hass.localize(
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../data/page_navigation";
import type { HomeAssistant } from "../../../types";
export const hardwareTabs = (hass: HomeAssistant): PageNavigation[] => {
@@ -120,7 +120,7 @@ import {
getEntityIdTableColumn,
getLabelsTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
import "../integrations/ha-integration-overflow-menu";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -650,7 +650,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.tabs=${configPageTabs["/config/helpers"]}
.searchLabel=${this.hass.localize(
"ui.panel.config.helpers.picker.search",
{ number: helpers.length }
@@ -68,7 +68,7 @@ import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { isHelperDomain } from "../helpers/const";
import "./ha-config-flow-card";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
@@ -532,7 +532,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
.hass=${this.hass}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.tabs=${configPageTabs["/config/integrations"]}
has-fab
>
${
@@ -24,7 +24,7 @@ import {
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../data/page_navigation";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
@@ -21,7 +21,7 @@ import {
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../data/page_navigation";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
@@ -13,7 +13,7 @@ import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-relative-time";
import type { InfraredDevice } from "../../../../../data/infrared";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../data/page_navigation";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
@@ -17,7 +17,7 @@ import {
type RadioFrequencyTransmitter,
} from "../../../../../data/radio_frequency";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../data/page_navigation";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
@@ -19,7 +19,7 @@ import { fetchZHADevice } from "../../../../../data/zha";
import "../../../../../layouts/hass-error-screen";
import "../../../../../layouts/hass-subpage";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../data/page_navigation";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "./device-page/zha-device-bindings-pane";
@@ -299,8 +299,7 @@ class ZHADevicePage extends LitElement {
max-width: 1400px;
width: 100%;
margin: 0 auto;
padding: var(--ha-space-4) var(--ha-space-4)
calc(var(--ha-space-20) + var(--safe-area-inset-bottom, 0px));
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-20);
}
.loading {
@@ -14,7 +14,7 @@ import "../../../../../components/ha-button";
import "../../../../../components/ha-icon-button";
import type { ZHAGroup } from "../../../../../data/zha";
import { fetchGroups } from "../../../../../data/zha";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../data/page_navigation";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
+2 -2
View File
@@ -57,7 +57,7 @@ import {
getCreatedAtTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { showLabelDetailDialog } from "./show-dialog-label-detail";
type ConfigTranslationKey = FlattenObjectKeys<
@@ -257,7 +257,7 @@ export class HaConfigLabels extends LitElement {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.areas}
.tabs=${configPageTabs["/config/labels"]}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${this._data(this._labels)}
.loading=${this._loading}
@@ -107,7 +107,7 @@ import {
getLabelsTableColumn,
renderRelativeTimeColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
@@ -460,7 +460,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.tabs=${configPageTabs["/config/scene"]}
.searchLabel=${this.hass.localize(
"ui.panel.config.scene.picker.search",
{ number: scenes.length }
+2 -2
View File
@@ -112,7 +112,7 @@ import {
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
@@ -431,7 +431,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.tabs=${configPageTabs["/config/script"]}
.searchLabel=${this.hass.localize(
"ui.panel.config.script.picker.search",
{ number: scripts.length }
+2 -2
View File
@@ -53,7 +53,7 @@ import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
import { configSections } from "../config-sections";
import { configPageTabs } from "../config-sections";
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
@@ -361,7 +361,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.route=${this.route}
back-path="/config"
.tabs=${configSections.areas}
.tabs=${configPageTabs["/config/zone"]}
has-fab
>
${
@@ -44,7 +44,7 @@ import type { HaIconButton } from "../../../components/ha-icon-button";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-svg-icon";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../data/page_navigation";
import type { HomeAssistantUI, Route } from "../../../types";
import { showMarketplaceAddFromLink } from "../tools/add-from-link";
import "../components/ha-marketplace-discover";
+1 -1
View File
@@ -12,7 +12,7 @@ import "./ha-pick-theme-row";
import { isExternal } from "../../data/external";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "../../layouts/hass-subpage";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../data/page_navigation";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { showEditProfileDialog } from "./show-dialog-edit-profile";
+3
View File
@@ -8,6 +8,9 @@ export const mainStyles = css`
/* for header */
--header-height: 56px;
/* Space taken by bottom chrome, including the safe-area inset it covers */
--ha-bottom-bar-height: 0px;
/* opacity for dark text on a light background */
--dark-divider-opacity: 0.12;
--dark-disabled-opacity: 0.38; /* or hint text or icon */
+41 -9
View File
@@ -2825,13 +2825,37 @@
},
"header": "Configure Home Assistant",
"dashboard": {
"devices": {
"main": "Devices & services",
"secondary": "Integrations, devices, entities, and helpers"
"integrations": {
"main": "Integrations",
"secondary": "Connect your devices and services"
},
"automations": {
"main": "Automations & scenes",
"secondary": "Automations, scenes, scripts, and blueprints"
"device_list": {
"main": "Devices",
"secondary": "Browse and manage your devices"
},
"entities": {
"main": "Entities",
"secondary": "Manage sensors, switches, and other entities"
},
"helpers": {
"main": "Helpers",
"secondary": "Create toggles, timers, and other building blocks"
},
"automation_list": {
"main": "Automations",
"secondary": "Make your home respond to events"
},
"scenes": {
"main": "Scenes",
"secondary": "Capture and restore the state of your devices"
},
"scripts": {
"main": "Scripts",
"secondary": "Run sequences of actions on demand"
},
"blueprints": {
"main": "Blueprints",
"secondary": "Create automations and scripts from reusable templates"
},
"backup": {
"main": "Backup",
@@ -2869,9 +2893,17 @@
"main": "People",
"secondary": "Manage who can access your home"
},
"areas": {
"main": "Areas, labels & zones",
"secondary": "Manage locations in and around your house"
"area_list": {
"main": "Areas",
"secondary": "Organize your devices by room and floor"
},
"labels": {
"main": "Labels",
"secondary": "Group your devices, entities, and automations"
},
"zones": {
"main": "Zones",
"secondary": "Define locations like home, work, or school"
},
"companion": {
"main": "Companion app",
+1 -1
View File
@@ -4,7 +4,7 @@ import {
isLoadedIntegration,
isCore,
} from "../../../src/common/config/can_show_page";
import type { PageNavigation } from "../../../src/layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../src/data/page_navigation";
import type { HomeAssistant } from "../../../src/types";
describe("canShowPage", () => {
+4 -4
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { getConfigSubpageTitle } from "../../src/data/panel";
import type { PageNavigation } from "../../src/layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../src/data/page_navigation";
import type { HomeAssistant } from "../../src/types";
const translations: Record<string, string> = {
"ui.panel.config.dashboard.devices.main": "Devices & services",
"ui.panel.config.dashboard.integrations.main": "Integrations entry",
"ui.panel.config.dashboard.system.main": "System",
"ui.panel.config.dashboard.matter.main": "Matter",
"ui.panel.config.integrations.caption": "Integrations",
@@ -18,8 +18,8 @@ const hass = {
// Mirrors how config-sections.ts mixes short and full translation keys.
const configSections: Record<string, PageNavigation[]> = {
dashboard: [
{ path: "/config/integrations", translationKey: "devices" },
dashboard_devices: [
{ path: "/config/integrations", translationKey: "integrations" },
] as PageNavigation[],
dashboard_3: [
{ path: "/config/system", translationKey: "system" },
+11 -3
View File
@@ -15,9 +15,17 @@ import { e2ePanelRouteAssertions } from "./ha-test-panels";
// ── Config dashboard links ───────────────────────────────────────────────────
export const configLinks: LinkSmokeCase[] = [
{ href: "/config/integrations", label: "Devices & services" },
{ href: "/config/automation", label: "Automations & scenes" },
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/integrations", label: "Integrations" },
{ href: "/config/devices", label: "Devices" },
{ href: "/config/entities", label: "Entities" },
{ href: "/config/helpers", label: "Helpers" },
{ href: "/config/automation", label: "Automations" },
{ href: "/config/scene", label: "Scenes" },
{ href: "/config/script", label: "Scripts" },
{ href: "/config/blueprint", label: "Blueprints" },
{ href: "/config/areas", label: "Areas" },
{ href: "/config/labels", label: "Labels" },
{ href: "/config/zone", label: "Zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/connectivity", label: "Connectivity" },
+128 -10
View File
@@ -1,5 +1,8 @@
import { ContextProvider } from "@lit/context";
import { LitElement } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { narrowViewportContext } from "../../src/data/context";
import type { PageNavigation } from "../../src/data/page_navigation";
import { provideHass } from "../../src/fake_data/provide_hass";
import type { HassTabsSubpage } from "../../src/layouts/hass-tabs-subpage";
@@ -7,27 +10,46 @@ import type { HassTabsSubpage } from "../../src/layouts/hass-tabs-subpage";
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
vi.mock("../../src/components/ha-tab", () => ({}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
customElements.define("ha-tab", class extends LitElement {});
vi.mock("../../src/components/ha-tab-group", () => ({}));
vi.mock("../../src/components/ha-tab-group-tab", () => ({}));
[
"ha-icon-button-arrow-prev",
"ha-menu-button",
"ha-tab",
"ha-tab-group",
"ha-tab-group-tab",
].forEach((tag) => customElements.define(tag, class extends LitElement {}));
vi.mock("../../src/common/navigate", () => ({
getHistoryState: () => undefined,
navigate: vi.fn(),
}));
const { navigate } = await import("../../src/common/navigate");
await import("../../src/layouts/hass-tabs-subpage");
let host: HTMLDivElement | undefined;
const mount = async (backPath: string) => {
const mountElement = async (
props: Partial<HassTabsSubpage>,
narrow = false
) => {
host = document.createElement("div");
provideHass(host, { localize: (key: string) => key });
provideHass(host, { localize: (key: string) => `localized:${key}` });
new ContextProvider(host, {
context: narrowViewportContext,
initialValue: narrow,
});
document.body.append(host);
const element = document.createElement(
"hass-tabs-subpage"
) as HassTabsSubpage;
Object.assign(element, {
route: { prefix: "", path: "" },
tabs: [],
backPath,
});
Object.assign(element, { route: { prefix: "", path: "" }, tabs: [] }, props);
host.append(element);
await element.updateComplete;
return element;
};
const mount = async (backPath: string) => {
const element = await mountElement({ backPath });
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev") as
(LitElement & { href?: string }) | null;
};
@@ -52,3 +74,99 @@ describe("hass-tabs-subpage back path", () => {
}
);
});
describe("hass-tabs-subpage tabs", () => {
const areas: PageNavigation = {
path: "/config/areas/dashboard",
translationKey: "areas",
core: true,
};
const floors: PageNavigation = {
path: "/config/floors",
name: "Floors",
core: true,
};
it("uses the tab matching the route as narrow title", async () => {
const element = await mountElement(
{
tabs: [areas, floors],
route: { prefix: "/config/floors", path: "" },
},
true
);
const title = () =>
element.shadowRoot!.querySelector(".main-title")!.textContent!.trim();
expect(title()).toEqual("Floors");
element.route = { prefix: "/config/areas", path: "/dashboard" };
await element.updateComplete;
expect(title()).toEqual("localized:areas");
});
it.each<[string, PageNavigation[], boolean]>([
["one tab", [areas], false],
["two tabs", [areas, floors], true],
[
"a tab hidden by its integration",
[areas, { ...floors, core: false, component: "not_loaded" }],
false,
],
[
"a tab hidden by its filter",
[areas, { ...floors, filter: () => false }],
false,
],
])("shows tabs with %s", async (_label, tabs, expected) => {
const element = await mountElement({ tabs });
expect(element.showTabs).toBe(expected);
expect(element.hasAttribute("show-tabs")).toBe(expected);
});
const showInTabRow = async (path: string) => {
vi.mocked(navigate).mockClear();
const element = await mountElement(
{
tabs: [areas, floors],
route: { prefix: "/config/floors", path: "" },
},
true
);
element
.shadowRoot!.querySelector(".tab-row")!
.dispatchEvent(
new CustomEvent("wa-tab-show", { detail: { name: path } })
);
};
it("does not navigate when the tab row shows the active tab", async () => {
await showInTabRow("/config/floors");
expect(navigate).not.toHaveBeenCalled();
});
it("navigates when the tab row shows another tab", async () => {
await showInTabRow("/config/areas/dashboard");
expect(navigate).toHaveBeenCalledExactlyOnceWith(
"/config/areas/dashboard",
{
replace: true,
}
);
});
it("navigates on a tab row click when no tab matches the route", async () => {
vi.mocked(navigate).mockClear();
const element = await mountElement(
{
tabs: [areas, floors],
route: { prefix: "/config/other", path: "" },
},
true
);
element.shadowRoot!.querySelector<HTMLAnchorElement>(".tab-row a")!.click();
expect(navigate).toHaveBeenCalledExactlyOnceWith(
"/config/areas/dashboard",
{ replace: true }
);
});
});