diff --git a/src/components/ha-sidebar-edit-panels.ts b/src/components/ha-sidebar-edit-panels.ts
new file mode 100644
index 000000000000..7fe9df43c715
--- /dev/null
+++ b/src/components/ha-sidebar-edit-panels.ts
@@ -0,0 +1,187 @@
+import { mdiClose, mdiPlus } from "@mdi/js";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import { fireEvent } from "../common/dom/fire_event";
+import type { SortableInstance } from "../resources/sortable";
+import { HomeAssistant } from "../types";
+import "./ha-icon";
+import "./ha-icon-button";
+import "./ha-svg-icon";
+
+const styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ padding: 0 12px;
+ }
+ .panel {
+ --rgb-text: var(--rgb-sidebar-text-color);
+ background-color: transparent;
+ color: rgb(var(--rgb-text));
+ font-family: inherit;
+ border: none;
+ cursor: pointer;
+ width: 100%;
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 20px;
+
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ border-radius: var(--sidebar-item-radius, 25px);
+ height: 50px;
+ }
+ .panel > .icon {
+ display: flex;
+ width: 36px;
+ text-align: left;
+ }
+ .panel > ha-icon-button {
+ margin-left: auto;
+ margin-right: -12px;
+ margin-inline: auto -12px;
+ }
+ .panel:hover {
+ color: rgb(var(--rgb-text));
+ background-color: rgba(var(--rgb-text), 0.08);
+ }
+ .panel:focus-visible,
+ .panel:active {
+ color: rgb(var(--rgb-text));
+ background-color: rgba(var(--rgb-text), 0.12);
+ }
+ #sortable {
+ overflow: visible;
+ }
+ #sortable .panel {
+ cursor: grab;
+ }
+ .sortable-fallback {
+ display: none;
+ }
+ .sortable-ghost {
+ opacity: 0.4;
+ }
+ #sortable .panel:nth-child(even) {
+ animation: keyframes1 infinite 0.37s;
+ transform-origin: 50% 10%;
+ }
+ #sortable .panel:nth-child(odd) {
+ animation: keyframes2 infinite alternate 0.5s 0.15s;
+ transform-origin: 30% 5%;
+ }
+ @keyframes keyframes1 {
+ 0% {
+ transform: rotate(-1deg);
+ animation-timing-function: ease-in;
+ }
+
+ 50% {
+ transform: rotate(1.5deg);
+ animation-timing-function: ease-out;
+ }
+ }
+
+ @keyframes keyframes2 {
+ 0% {
+ transform: rotate(1deg);
+ animation-timing-function: ease-in;
+ }
+
+ 50% {
+ transform: rotate(-1.5deg);
+ animation-timing-function: ease-out;
+ }
+ }
+`;
+
+@customElement("ha-sidebar-edit-panels")
+class HaSidebarEditPanels extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property() public panels: any[] = [];
+
+ @property() public hiddenPanels: any[] = [];
+
+ private _sortable?: SortableInstance;
+
+ protected render() {
+ const renderPanel = (panel) =>
+ html`
+
+ ${panel.icon
+ ? html``
+ : html``}
+
+ ${panel.name}
+
+
`;
+ const renderHiddenPanel = (panel) =>
+ html``;
+ return html`${this.panels.map(renderPanel)}
+ ${this.hiddenPanels.map(renderHiddenPanel)}`;
+ }
+
+ protected async firstUpdated() {
+ const { default: Sortable } = await import("../resources/sortable");
+ this._sortable = new Sortable(
+ this.shadowRoot!.getElementById("sortable")!,
+ {
+ animation: 150,
+ dataIdAttr: "data-panel",
+ handle: "div",
+ onSort: () => {
+ fireEvent(this, "panel-reorder", this._sortable!.toArray());
+ },
+ }
+ );
+ }
+
+ private _hidePanel(ev: CustomEvent) {
+ const panel = (ev.target as HTMLElement)
+ .closest("[data-panel]")
+ ?.getAttribute("data-panel");
+ if (!panel) return;
+ fireEvent(this, "panel-hide", panel);
+ }
+
+ private _showPanel(ev: CustomEvent) {
+ const panel = (ev.target as HTMLElement)
+ .closest("[data-panel]")
+ ?.getAttribute("data-panel");
+ if (!panel) return;
+ fireEvent(this, "panel-show", panel);
+ }
+
+ static styles = styles;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-edit-panels": HaSidebarEditPanels;
+ }
+ interface HASSDomEvents {
+ "panel-reorder": string[];
+ "panel-hide": string;
+ "panel-show": string;
+ }
+}
diff --git a/src/components/ha-sidebar-panel-config.ts b/src/components/ha-sidebar-panel-config.ts
new file mode 100644
index 000000000000..addfdaf9bb38
--- /dev/null
+++ b/src/components/ha-sidebar-panel-config.ts
@@ -0,0 +1,90 @@
+import "@material/mwc-button/mwc-button";
+import { mdiCog } from "@mdi/js";
+import { UnsubscribeFunc } from "home-assistant-js-websocket";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { throttle } from "../common/util/throttle";
+import { subscribeRepairsIssueRegistry } from "../data/repairs";
+import { updateCanInstall, UpdateEntity } from "../data/update";
+import { haStyleSidebarItem } from "../resources/styles";
+import { HomeAssistant } from "../types";
+import "./ha-svg-icon";
+import { keydown, keyup } from "../resources/button-handlers";
+
+const styles = css`
+ .item.expanded {
+ width: 100%;
+ }
+`;
+
+@customElement("ha-sidebar-panel-config")
+class HaSidebarPanelConfig extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property() public name = "";
+
+ @property({ type: Boolean }) public expanded = false;
+
+ @property({ type: Boolean }) public selected = false;
+
+ @state() private _updatesCount = 0;
+
+ @state() private _issuesCount = 0;
+
+ protected render() {
+ const notices = this._updatesCount + this._issuesCount;
+ return html`
+
+
+
+ ${!this.expanded && notices > 0
+ ? html`${notices}`
+ : ""}
+
+ ${this.name}
+ ${this.expanded && notices > 0
+ ? html`${notices}`
+ : ""}
+ `;
+ }
+
+ protected updated(changedProps: PropertyValues) {
+ super.updated(changedProps);
+ this._checkUpdates();
+ }
+
+ private _checkUpdates = throttle(() => {
+ this._updatesCount = Object.keys(this.hass.states).filter(
+ (e) =>
+ e.startsWith("update.") &&
+ updateCanInstall(this.hass.states[e] as UpdateEntity)
+ ).length;
+ }, 5000);
+
+ public hassSubscribe(): UnsubscribeFunc[] {
+ return this.hass.user?.is_admin
+ ? [
+ subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => {
+ this._issuesCount = repairs.issues.filter(
+ (issue) => !issue.ignored
+ ).length;
+ }),
+ ]
+ : [];
+ }
+
+ static styles = [haStyleSidebarItem, styles];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-panel-config": HaSidebarPanelConfig;
+ }
+}
diff --git a/src/components/ha-sidebar-panel-ext-config.ts b/src/components/ha-sidebar-panel-ext-config.ts
new file mode 100644
index 000000000000..e6f8443476d8
--- /dev/null
+++ b/src/components/ha-sidebar-panel-ext-config.ts
@@ -0,0 +1,51 @@
+import "@material/mwc-button/mwc-button";
+import { mdiCellphoneCog } from "@mdi/js";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import { haStyleSidebarItem } from "../resources/styles";
+import "./ha-icon";
+import "./ha-svg-icon";
+import { HomeAssistant } from "../types";
+
+const styles = css`
+ .item {
+ width: 100%;
+ }
+`;
+
+@customElement("ha-sidebar-panel-ext-config")
+class HaSidebarPanelExtConfig extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property() public name = "";
+
+ @property({ type: Boolean }) public expanded = false;
+
+ protected render() {
+ return html``;
+ }
+
+ private _showConfig() {
+ this.hass.auth.external!.fireMessage({
+ type: "config_screen/show",
+ });
+ }
+
+ static styles = [haStyleSidebarItem, styles];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-panel-ext-config": HaSidebarPanelExtConfig;
+ }
+}
diff --git a/src/components/ha-sidebar-panel-notifications.ts b/src/components/ha-sidebar-panel-notifications.ts
new file mode 100644
index 000000000000..564f29f9fc2a
--- /dev/null
+++ b/src/components/ha-sidebar-panel-notifications.ts
@@ -0,0 +1,70 @@
+import "@material/mwc-button/mwc-button";
+import { mdiBell } from "@mdi/js";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { fireEvent } from "../common/dom/fire_event";
+import {
+ PersistentNotification,
+ subscribeNotifications,
+} from "../data/persistent_notification";
+import { haStyleSidebarItem } from "../resources/styles";
+import { HomeAssistant } from "../types";
+import "./ha-svg-icon";
+
+const styles = css`
+ .item {
+ width: 100%;
+ }
+`;
+
+@customElement("ha-sidebar-panel-notifications")
+class HaSidebarPanelNotifications extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public expanded = false;
+
+ @state() private _notifications?: PersistentNotification[];
+
+ protected render() {
+ const notificationCount = this._notifications
+ ? this._notifications.length
+ : 0;
+ return html``;
+ }
+
+ protected firstUpdated(changedProps: PropertyValues) {
+ super.firstUpdated(changedProps);
+ subscribeNotifications(this.hass.connection, (notifications) => {
+ this._notifications = notifications;
+ });
+ }
+
+ private _showNotifications() {
+ fireEvent(this, "hass-show-notifications");
+ }
+
+ static styles = [haStyleSidebarItem, styles];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-panel-notifications": HaSidebarPanelNotifications;
+ }
+}
diff --git a/src/components/ha-sidebar-panel-user.ts b/src/components/ha-sidebar-panel-user.ts
new file mode 100644
index 000000000000..562758ab4454
--- /dev/null
+++ b/src/components/ha-sidebar-panel-user.ts
@@ -0,0 +1,58 @@
+import "@material/mwc-button/mwc-button";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import { haStyleSidebarItem } from "../resources/styles";
+import { HomeAssistant } from "../types";
+import "./user/ha-user-badge";
+import { keydown, keyup } from "../resources/button-handlers";
+
+const styles = css`
+ .icon {
+ height: 24px;
+ }
+ .user-icon {
+ display: inline-flex;
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%) scale(0.8);
+ }
+`;
+
+@customElement("ha-sidebar-panel-user")
+class HaSidebarPanelUser extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public expanded = false;
+
+ @property({ type: Boolean }) public selected = false;
+
+ protected render() {
+ return html`
+
+
+
+
+
+
+ ${this.hass.user ? this.hass.user.name : ""}
+ `;
+ }
+
+ static styles = [haStyleSidebarItem, styles];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-panel-user": HaSidebarPanelUser;
+ }
+}
diff --git a/src/components/ha-sidebar-panel.ts b/src/components/ha-sidebar-panel.ts
new file mode 100644
index 000000000000..3347826b7d34
--- /dev/null
+++ b/src/components/ha-sidebar-panel.ts
@@ -0,0 +1,58 @@
+import "@material/mwc-button/mwc-button";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators";
+import { haStyleSidebarItem } from "../resources/styles";
+import "./ha-svg-icon";
+import "./ha-icon";
+import { keydown, keyup } from "../resources/button-handlers";
+
+const styles = css`
+ .item.expanded {
+ width: 100%;
+ }
+ .count {
+ margin-left: auto;
+ }
+`;
+
+@customElement("ha-sidebar-panel")
+class HaSidebarPanel extends LitElement {
+ @property() public path = "";
+
+ @property() public name = "";
+
+ @property() public icon = "";
+
+ @property() public iconPath = "";
+
+ @property({ type: Boolean }) public expanded = false;
+
+ @property({ type: Boolean }) public selected = false;
+
+ protected render() {
+ return html`
+
+
+ ${this.iconPath
+ ? html``
+ : html``}
+
+ ${this.name}
+ `;
+ }
+
+ static styles = [haStyleSidebarItem, styles];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-panel": HaSidebarPanel;
+ }
+}
diff --git a/src/components/ha-sidebar-panels.ts b/src/components/ha-sidebar-panels.ts
new file mode 100644
index 000000000000..0c5490f8b2f1
--- /dev/null
+++ b/src/components/ha-sidebar-panels.ts
@@ -0,0 +1,342 @@
+import "@material/mwc-button/mwc-button";
+import {
+ mdiCalendar,
+ mdiChartBox,
+ mdiClipboardList,
+ mdiFormatListBulletedType,
+ mdiHammer,
+ mdiLightningBolt,
+ mdiPlayBoxMultiple,
+ mdiTooltipAccount,
+ mdiViewDashboard,
+} from "@mdi/js";
+import { LitElement, PropertyValues, css, html } from "lit";
+import { customElement, property } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { storage } from "../common/decorators/storage";
+import { fireEvent } from "../common/dom/fire_event";
+import { stringCompare } from "../common/string/compare";
+import { HomeAssistant, PanelInfo } from "../types";
+import "./ha-sidebar-edit-panels";
+import "./ha-sidebar-panel";
+import "./ha-sidebar-panel-config";
+import "./ha-sidebar-panel-ext-config";
+
+const styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ }
+ .spacer {
+ flex: 1;
+ }
+`;
+
+const SHOW_AFTER_SPACER = ["config", "developer-tools"];
+const SORT_VALUE_URL_PATHS = {
+ energy: 1,
+ map: 2,
+ logbook: 3,
+ history: 4,
+ "developer-tools": 9,
+ config: 11,
+};
+const PANEL_ICONS = {
+ calendar: mdiCalendar,
+ "developer-tools": mdiHammer,
+ energy: mdiLightningBolt,
+ history: mdiChartBox,
+ logbook: mdiFormatListBulletedType,
+ lovelace: mdiViewDashboard,
+ map: mdiTooltipAccount,
+ "media-browser": mdiPlayBoxMultiple,
+ todo: mdiClipboardList,
+};
+const panelSorter = (
+ a: PanelInfo,
+ b: PanelInfo,
+ panelOrder: string[],
+ defaultPanel: string,
+ language: string
+) => {
+ const indexA = panelOrder.indexOf(a.url_path);
+ const indexB = panelOrder.indexOf(b.url_path);
+ const order = indexA - indexB;
+ if (order) return order;
+ return defaultPanelSorter(defaultPanel, a, b, language);
+};
+const defaultPanelSorter = (
+ defaultPanel: string,
+ a: PanelInfo,
+ b: PanelInfo,
+ language: string
+) => {
+ if (a.url_path === defaultPanel) return -1;
+ if (b.url_path === defaultPanel) return 1;
+
+ const aLovelace = a.component_name === "lovelace";
+ const bLovelace = b.component_name === "lovelace";
+
+ if (aLovelace && bLovelace)
+ return stringCompare(a.title!, b.title!, language);
+ if (aLovelace) return -1;
+ if (bLovelace) return 1;
+
+ const aBuiltIn = a.url_path in SORT_VALUE_URL_PATHS;
+ const bBuiltIn = b.url_path in SORT_VALUE_URL_PATHS;
+
+ if (aBuiltIn && bBuiltIn) {
+ return SORT_VALUE_URL_PATHS[a.url_path] - SORT_VALUE_URL_PATHS[b.url_path];
+ }
+ if (aBuiltIn) return -1;
+ if (bBuiltIn) return 1;
+
+ return stringCompare(a.title!, b.title!, language);
+};
+const _computePanels = (
+ panels: HomeAssistant["panels"],
+ defaultPanel: HomeAssistant["defaultPanel"],
+ panelsOrder: string[],
+ hiddenPanels: string[],
+ locale: HomeAssistant["locale"]
+): [PanelInfo[], PanelInfo[]] => {
+ if (!panels) {
+ return [[], []];
+ }
+
+ const beforeSpacer: PanelInfo[] = [];
+ const afterSpacer: PanelInfo[] = [];
+
+ Object.values(panels).forEach((panel) => {
+ if (
+ hiddenPanels.includes(panel.url_path) ||
+ (!panel.title && panel.url_path !== defaultPanel)
+ ) {
+ return;
+ }
+ (SHOW_AFTER_SPACER.includes(panel.url_path)
+ ? afterSpacer
+ : beforeSpacer
+ ).push(panel);
+ });
+
+ beforeSpacer.sort((a, b) =>
+ panelSorter(a, b, panelsOrder, defaultPanel, locale.language)
+ );
+ afterSpacer.sort((a, b) =>
+ panelSorter(a, b, panelsOrder, defaultPanel, locale.language)
+ );
+ return [beforeSpacer, afterSpacer];
+};
+const computePanels = memoizeOne(_computePanels);
+
+@customElement("ha-sidebar-panels")
+class HaSidebarPanels extends LitElement {
+ static shadowRootOptions = {
+ ...LitElement.shadowRootOptions,
+ delegatesFocus: true,
+ };
+
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public editMode = false;
+
+ @property({ type: Boolean }) public expanded = false;
+
+ @property({ type: String }) public currentPanel = "";
+
+ @storage({
+ key: "sidebarPanelOrder",
+ state: true,
+ subscribe: true,
+ })
+ private _panelOrder: string[] = [];
+
+ @storage({
+ key: "sidebarHiddenPanels",
+ state: true,
+ subscribe: true,
+ })
+ private _hiddenPanels: string[] = [];
+
+ private searchKeys = "";
+
+ private searchKeysUpdated = 0;
+
+ constructor() {
+ super();
+ const getCurrentFocus = () => {
+ let activeItem = this.shadowRoot?.activeElement;
+ activeItem = activeItem?.shadowRoot?.activeElement;
+ if (!(activeItem instanceof HTMLElement)) return null;
+
+ const items = this._listItems();
+ return { items, index: items.indexOf(activeItem) };
+ };
+ this.addEventListener("keydown", (e) => {
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
+ const focus = getCurrentFocus();
+ if (!focus) return;
+ const next = e.key === "ArrowUp" ? focus.index - 1 : focus.index + 1;
+ if (next >= 0 && next < focus.items.length) {
+ e.preventDefault();
+ const item = focus.items[next];
+ item.focus();
+ }
+ });
+ this.addEventListener("keydown", (e) => {
+ if (e.key.length > 1) return;
+ if (Date.now() - this.searchKeysUpdated > 500) {
+ this.searchKeys = "";
+ }
+ this.searchKeys += e.key.toLowerCase();
+ this.searchKeysUpdated = Date.now();
+
+ const items = this._listItems();
+ const item = items.find((i) => {
+ const name = i.getAttribute("aria-label");
+ return name && name.toLowerCase().startsWith(this.searchKeys);
+ });
+ if (item) {
+ item.focus();
+ }
+ });
+ }
+
+ protected render() {
+ const [beforeSpacer, afterSpacer] = computePanels(
+ this.hass.panels,
+ this.hass.defaultPanel,
+ this._panelOrder,
+ this._hiddenPanels,
+ this.hass.locale
+ );
+ const getName = (panel: PanelInfo) =>
+ panel.url_path === this.hass.defaultPanel
+ ? panel.title || this.hass.localize("panel.states")
+ : this.hass.localize(`panel.${panel.title}`) || panel.title || "";
+ const getIconPath = (panel: PanelInfo) =>
+ panel.url_path === this.hass.defaultPanel && !panel.icon
+ ? PANEL_ICONS.lovelace
+ : panel.url_path in PANEL_ICONS
+ ? PANEL_ICONS[panel.url_path]
+ : undefined;
+ const renderPanel = (panel: PanelInfo) =>
+ panel.url_path === "config"
+ ? html``
+ : html``;
+ return [
+ this.editMode
+ ? html` ({
+ url_path: p.url_path,
+ name: getName(p),
+ icon: p.icon,
+ iconPath: getIconPath(p),
+ }))}
+ .hiddenPanels=${this._hiddenPanels.map((p) => {
+ const panel = this.hass.panels[p];
+ if (!panel) return {};
+ return {
+ url_path: p,
+ name: getName(panel),
+ icon: panel.icon,
+ iconPath: getIconPath(panel),
+ };
+ })}
+ @panel-reorder=${this._panelReorder}
+ @panel-hide=${this._panelHide}
+ @panel-show=${this._panelShow}
+ >`
+ : html`${beforeSpacer.map(renderPanel)}`,
+ html``,
+ ...afterSpacer.map(renderPanel),
+ ...(!this.hass.user?.is_admin &&
+ this.hass.auth.external?.config.hasSettingsScreen
+ ? [
+ html``,
+ ]
+ : []),
+ ];
+ }
+
+ protected shouldUpdate(changedProps: PropertyValues) {
+ const nonOrderProps = [...changedProps.keys()].filter(
+ (p) => p !== "_panelOrder"
+ );
+ return nonOrderProps.length > 0;
+ }
+
+ private _panelOver(ev: CustomEvent) {
+ fireEvent(this, "panel-hover", ev.target as HTMLElement);
+ }
+
+ private _panelLeave() {
+ fireEvent(this, "panel-leave");
+ }
+
+ private _panelReorder(ev: CustomEvent) {
+ this._panelOrder = ev.detail;
+ }
+
+ private _panelHide(ev: CustomEvent) {
+ this._hiddenPanels = [...this._hiddenPanels, ev.detail];
+ }
+
+ private _panelShow(ev: CustomEvent) {
+ this._hiddenPanels = this._hiddenPanels.filter((p) => p !== ev.detail);
+ }
+
+ private _listItems() {
+ return [...this.shadowRoot!.children].reduce(
+ (acc, child) => {
+ if (child.shadowRoot) {
+ const item = child.shadowRoot.firstElementChild;
+ if (item instanceof HTMLElement) acc.push(item);
+ }
+ return acc;
+ },
+ []
+ );
+ }
+
+ static styles = styles;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-panels": HaSidebarPanels;
+ }
+ interface HASSDomEvents {
+ "panel-hover": HTMLElement;
+ "panel-leave": undefined;
+ }
+}
diff --git a/src/components/ha-sidebar-title.ts b/src/components/ha-sidebar-title.ts
new file mode 100644
index 000000000000..6acab15b607b
--- /dev/null
+++ b/src/components/ha-sidebar-title.ts
@@ -0,0 +1,110 @@
+import "@material/mwc-button/mwc-button";
+import { mdiMenu, mdiMenuOpen } from "@mdi/js";
+import { LitElement, css, html } from "lit";
+import { customElement, property } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { fireEvent } from "../common/dom/fire_event";
+import { computeRTL } from "../common/util/compute_rtl";
+import { type ActionHandlerDetail } from "../data/lovelace/action_handler";
+import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
+import { HomeAssistant } from "../types";
+import "./ha-icon-button";
+
+const styles = css`
+ .menu {
+ display: flex;
+ flex-shrink: 0;
+ height: var(--header-height);
+ box-sizing: border-box;
+ align-items: center;
+ white-space: nowrap;
+ }
+ .menu .title {
+ color: var(--secondary-text-color);
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 20px;
+ margin-left: 16px;
+ }
+ .menu ha-icon-button {
+ margin: 0 auto;
+ color: var(--sidebar-icon-color);
+ }
+ .menu.rtl ha-icon-button {
+ transform: scaleX(-1);
+ }
+ .menu mwc-button {
+ width: 100%;
+ }
+ .menu.expanded {
+ padding: calc((var(--header-height) - 20px) / 5) 12px 0 12px;
+ }
+ .menu.expanded ha-icon-button {
+ margin-inline-end: 0;
+ }
+`;
+
+@customElement("ha-sidebar-title")
+class HaSidebarTitle extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ type: Boolean }) public narrow = false;
+
+ @property({ type: Boolean }) public expanded = false;
+
+ @property({ type: Boolean }) public editMode = false;
+
+ protected render() {
+ const classes = classMap({
+ menu: true,
+ expanded: this.expanded,
+ rtl: computeRTL(this.hass),
+ });
+ const saveEdits = html`
+ ${this.hass.localize("ui.sidebar.done")}
+ `;
+ const sidebarToggle = html`
+
+ `;
+ return html`
+ ${this.editMode ? saveEdits : ""}
+ ${!this.expanded || this.editMode
+ ? ""
+ : html`Home Assistant`}
+ ${this.narrow || this.editMode ? "" : sidebarToggle}
+
`;
+ }
+
+ private _toggleSidebar(ev: CustomEvent) {
+ if (ev.detail.action !== "tap") return;
+ fireEvent(this, "hass-toggle-menu");
+ }
+
+ private _editModeOn(ev: CustomEvent) {
+ if (ev.detail.action !== "hold") return;
+ fireEvent(this, "hass-edit-sidebar", { editMode: true });
+ }
+
+ private _editModeOff() {
+ fireEvent(this, "hass-edit-sidebar", { editMode: false });
+ }
+
+ static styles = styles;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-title": HaSidebarTitle;
+ }
+}
diff --git a/src/components/ha-sidebar-tooltip.ts b/src/components/ha-sidebar-tooltip.ts
new file mode 100644
index 000000000000..f8c2b0bb72ca
--- /dev/null
+++ b/src/components/ha-sidebar-tooltip.ts
@@ -0,0 +1,36 @@
+import "@material/mwc-button/mwc-button";
+import { css, html, LitElement } from "lit";
+import { customElement } from "lit/decorators";
+
+const styles = css`
+ div {
+ position: fixed;
+ background-color: var(--sidebar-text-color);
+ color: var(--sidebar-background-color);
+ padding: 4px;
+ border-radius: 2px;
+ transform: translateY(-50%);
+ white-space: nowrap;
+ display: none;
+ }
+`;
+
+@customElement("ha-sidebar-tooltip")
+class HaSidebarTooltip extends LitElement {
+ protected render() {
+ return html``;
+ }
+
+ static styles = styles;
+}
+
+export interface TooltipPosition {
+ shown: boolean;
+ x: number;
+ y: number;
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-sidebar-tooltip": HaSidebarTooltip;
+ }
+}
diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts
index 50b5e1cba96c..89f6b8b7e895 100644
--- a/src/components/ha-sidebar.ts
+++ b/src/components/ha-sidebar.ts
@@ -1,191 +1,35 @@
-import "@material/mwc-button/mwc-button";
-import {
- mdiBell,
- mdiCalendar,
- mdiCellphoneCog,
- mdiChartBox,
- mdiClipboardList,
- mdiClose,
- mdiCog,
- mdiFormatListBulletedType,
- mdiHammer,
- mdiLightningBolt,
- mdiMenu,
- mdiMenuOpen,
- mdiPlayBoxMultiple,
- mdiPlus,
- mdiTooltipAccount,
- mdiViewDashboard,
-} from "@mdi/js";
-import "@polymer/paper-item/paper-icon-item";
-import type { PaperIconItemElement } from "@polymer/paper-item/paper-icon-item";
-import "@polymer/paper-item/paper-item";
-import "@polymer/paper-listbox/paper-listbox";
-import { UnsubscribeFunc } from "home-assistant-js-websocket";
-import {
- CSSResult,
- CSSResultGroup,
- LitElement,
- PropertyValues,
- css,
- html,
- nothing,
-} from "lit";
-import { customElement, eventOptions, property, state } from "lit/decorators";
-import { classMap } from "lit/directives/class-map";
-import { guard } from "lit/directives/guard";
-import memoizeOne from "memoize-one";
-import { storage } from "../common/decorators/storage";
-import { fireEvent } from "../common/dom/fire_event";
-import { toggleAttribute } from "../common/dom/toggle_attribute";
-import { stringCompare } from "../common/string/compare";
-import { computeRTL } from "../common/util/compute_rtl";
-import { throttle } from "../common/util/throttle";
-import { ActionHandlerDetail } from "../data/lovelace/action_handler";
-import {
- PersistentNotification,
- subscribeNotifications,
-} from "../data/persistent_notification";
-import { subscribeRepairsIssueRegistry } from "../data/repairs";
-import { UpdateEntity, updateCanInstall } from "../data/update";
+import { css, html, LitElement, nothing } from "lit";
+import { customElement, property } from "lit/decorators";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
-import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
-import type { SortableInstance } from "../resources/sortable";
import { haStyleScrollbar } from "../resources/styles";
-import type { HomeAssistant, PanelInfo, Route } from "../types";
-import "./ha-icon";
-import "./ha-icon-button";
-import "./ha-menu-button";
-import "./ha-svg-icon";
-import "./user/ha-user-badge";
-
-const SHOW_AFTER_SPACER = ["config", "developer-tools"];
-
-const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
-
-const SORT_VALUE_URL_PATHS = {
- energy: 1,
- map: 2,
- logbook: 3,
- history: 4,
- "developer-tools": 9,
- config: 11,
-};
-
-const PANEL_ICONS = {
- calendar: mdiCalendar,
- "developer-tools": mdiHammer,
- energy: mdiLightningBolt,
- history: mdiChartBox,
- logbook: mdiFormatListBulletedType,
- lovelace: mdiViewDashboard,
- map: mdiTooltipAccount,
- "media-browser": mdiPlayBoxMultiple,
- todo: mdiClipboardList,
-};
-
-const panelSorter = (
- reverseSort: string[],
- defaultPanel: string,
- a: PanelInfo,
- b: PanelInfo,
- language: string
-) => {
- const indexA = reverseSort.indexOf(a.url_path);
- const indexB = reverseSort.indexOf(b.url_path);
- if (indexA !== indexB) {
- if (indexA < indexB) {
- return 1;
- }
- return -1;
- }
- return defaultPanelSorter(defaultPanel, a, b, language);
-};
-
-const defaultPanelSorter = (
- defaultPanel: string,
- a: PanelInfo,
- b: PanelInfo,
- language: string
-) => {
- // Put all the Lovelace at the top.
- const aLovelace = a.component_name === "lovelace";
- const bLovelace = b.component_name === "lovelace";
-
- if (a.url_path === defaultPanel) {
- return -1;
- }
- if (b.url_path === defaultPanel) {
- return 1;
- }
-
- if (aLovelace && bLovelace) {
- return stringCompare(a.title!, b.title!, language);
- }
- if (aLovelace && !bLovelace) {
- return -1;
- }
- if (bLovelace) {
- return 1;
- }
-
- const aBuiltIn = a.url_path in SORT_VALUE_URL_PATHS;
- const bBuiltIn = b.url_path in SORT_VALUE_URL_PATHS;
-
- if (aBuiltIn && bBuiltIn) {
- return SORT_VALUE_URL_PATHS[a.url_path] - SORT_VALUE_URL_PATHS[b.url_path];
- }
- if (aBuiltIn) {
- return -1;
- }
- if (bBuiltIn) {
- return 1;
- }
- // both not built in, sort by title
- return stringCompare(a.title!, b.title!, language);
-};
-
-const computePanels = memoizeOne(
- (
- panels: HomeAssistant["panels"],
- defaultPanel: HomeAssistant["defaultPanel"],
- panelsOrder: string[],
- hiddenPanels: string[],
- locale: HomeAssistant["locale"]
- ): [PanelInfo[], PanelInfo[]] => {
- if (!panels) {
- return [[], []];
- }
-
- const beforeSpacer: PanelInfo[] = [];
- const afterSpacer: PanelInfo[] = [];
-
- Object.values(panels).forEach((panel) => {
- if (
- hiddenPanels.includes(panel.url_path) ||
- (!panel.title && panel.url_path !== defaultPanel)
- ) {
- return;
- }
- (SHOW_AFTER_SPACER.includes(panel.url_path)
- ? afterSpacer
- : beforeSpacer
- ).push(panel);
- });
-
- const reverseSort = [...panelsOrder].reverse();
-
- beforeSpacer.sort((a, b) =>
- panelSorter(reverseSort, defaultPanel, a, b, locale.language)
- );
- afterSpacer.sort((a, b) =>
- panelSorter(reverseSort, defaultPanel, a, b, locale.language)
- );
-
- return [beforeSpacer, afterSpacer];
- }
-);
-
+import type { HomeAssistant, Route } from "../types";
+import "./ha-sidebar-panel-notifications";
+import "./ha-sidebar-panel-user";
+import "./ha-sidebar-panels";
+import "./ha-sidebar-title";
+import "./ha-sidebar-tooltip";
+
+const styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ }
+ .items {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ padding-bottom: 12px;
+ overflow: hidden;
+ }
+ hr {
+ margin-inline: 12px;
+ height: 1px;
+ border: none;
+ background-color: var(--divider-color);
+ }
+`;
@customElement("ha-sidebar")
class HaSidebar extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -198,941 +42,84 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public editMode = false;
- @state() private _notifications?: PersistentNotification[];
-
- @state() private _updatesCount = 0;
-
- @state() private _issuesCount = 0;
-
- @state() private _renderEmptySortable = false;
-
- private _mouseLeaveTimeout?: number;
-
- private _tooltipHideTimeout?: number;
-
- private _recentKeydownActiveUntil = 0;
-
- private sortableStyleLoaded = false;
-
- @storage({
- key: "sidebarPanelOrder",
- state: true,
- subscribe: true,
- })
- private _panelOrder: string[] = [];
-
- @storage({
- key: "sidebarHiddenPanels",
- state: true,
- subscribe: true,
- })
- private _hiddenPanels: string[] = [];
-
- private _sortable?: SortableInstance;
-
- public hassSubscribe(): UnsubscribeFunc[] {
- return this.hass.user?.is_admin
- ? [
- subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => {
- this._issuesCount = repairs.issues.filter(
- (issue) => !issue.ignored
- ).length;
- }),
- ]
- : [];
- }
+ static styles = [haStyleScrollbar, styles];
protected render() {
if (!this.hass) {
return nothing;
}
-
- // prettier-ignore
- return html`
- ${this._renderHeader()}
- ${this._renderAllPanels()}
- ${this._renderDivider()}
- ${this._renderNotifications()}
- ${this._renderUserItem()}
-
-
- `;
- }
-
- protected shouldUpdate(changedProps: PropertyValues): boolean {
- if (
- changedProps.has("expanded") ||
- changedProps.has("narrow") ||
- changedProps.has("alwaysExpand") ||
- changedProps.has("_externalConfig") ||
- changedProps.has("_updatesCount") ||
- changedProps.has("_issuesCount") ||
- changedProps.has("_notifications") ||
- changedProps.has("editMode") ||
- changedProps.has("_renderEmptySortable") ||
- changedProps.has("_hiddenPanels") ||
- (changedProps.has("_panelOrder") && !this.editMode)
- ) {
- return true;
- }
- if (!this.hass || !changedProps.has("hass")) {
- return false;
- }
- const oldHass = changedProps.get("hass") as HomeAssistant;
- if (!oldHass) {
- return true;
- }
- const hass = this.hass;
- return (
- hass.panels !== oldHass.panels ||
- hass.panelUrl !== oldHass.panelUrl ||
- hass.user !== oldHass.user ||
- hass.localize !== oldHass.localize ||
- hass.locale !== oldHass.locale ||
- hass.states !== oldHass.states ||
- hass.defaultPanel !== oldHass.defaultPanel
- );
- }
-
- protected firstUpdated(changedProps: PropertyValues) {
- super.firstUpdated(changedProps);
- subscribeNotifications(this.hass.connection, (notifications) => {
- this._notifications = notifications;
- });
- }
-
- protected updated(changedProps) {
- super.updated(changedProps);
- if (changedProps.has("alwaysExpand")) {
- toggleAttribute(this, "expanded", this.alwaysExpand);
- }
- if (changedProps.has("editMode")) {
- if (this.editMode) {
- this._activateEditMode();
- } else {
- this._deactivateEditMode();
- }
- }
- if (!changedProps.has("hass")) {
- return;
- }
-
- const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
- if (!oldHass || oldHass.locale !== this.hass.locale) {
- toggleAttribute(this, "rtl", computeRTL(this.hass));
- }
-
- this._calculateCounts();
-
- if (!SUPPORT_SCROLL_IF_NEEDED) {
- return;
- }
- if (!oldHass || oldHass.panelUrl !== this.hass.panelUrl) {
- const selectedEl = this.shadowRoot!.querySelector(".iron-selected");
- if (selectedEl) {
- // @ts-ignore
- selectedEl.scrollIntoViewIfNeeded();
- }
- }
- }
-
- private _calculateCounts = throttle(() => {
- let updateCount = 0;
-
- for (const entityId of Object.keys(this.hass.states)) {
- if (
- entityId.startsWith("update.") &&
- updateCanInstall(this.hass.states[entityId] as UpdateEntity)
- ) {
- updateCount++;
- }
- }
-
- this._updatesCount = updateCount;
- }, 5000);
-
- private _renderHeader() {
- return html``;
- }
-
- private _renderAllPanels() {
- const [beforeSpacer, afterSpacer] = computePanels(
- this.hass.panels,
- this.hass.defaultPanel,
- this._panelOrder,
- this._hiddenPanels,
- this.hass.locale
- );
-
- // Show the supervisor as beeing part of configuration
- const selectedPanel = this.route.path?.startsWith("/hassio/")
+ const currentPanel = this.route.path?.startsWith("/hassio/")
? "config"
: this.hass.panelUrl;
- // prettier-ignore
return html`
-
- ${this.editMode
- ? this._renderPanelsEdit(beforeSpacer)
- : this._renderPanels(beforeSpacer)}
- ${this._renderSpacer()}
- ${this._renderPanels(afterSpacer)}
- ${this._renderExternalConfiguration()}
-
- `;
- }
-
- private _renderPanels(panels: PanelInfo[]) {
- return panels.map((panel) =>
- this._renderPanel(
- panel.url_path,
- panel.url_path === this.hass.defaultPanel
- ? panel.title || this.hass.localize("panel.states")
- : this.hass.localize(`panel.${panel.title}`) || panel.title,
- panel.icon,
- panel.url_path === this.hass.defaultPanel && !panel.icon
- ? PANEL_ICONS.lovelace
- : panel.url_path in PANEL_ICONS
- ? PANEL_ICONS[panel.url_path]
- : undefined
- )
- );
- }
-
- private _renderPanel(
- urlPath: string,
- title: string | null,
- icon?: string | null,
- iconPath?: string | null
- ) {
- return urlPath === "config"
- ? this._renderConfiguration(title)
- : html`
-
-
- ${iconPath
- ? html``
- : html``}
- ${title}
-
- ${this.editMode
- ? html``
- : ""}
-
- `;
- }
-
- private _renderPanelsEdit(beforeSpacer: PanelInfo[]) {
- // prettier-ignore
- return html`
- ${guard([this._hiddenPanels, this._renderEmptySortable], () =>
- this._renderEmptySortable ? "" : this._renderPanels(beforeSpacer)
- )}
-
- ${this._renderSpacer()}
- ${this._renderHiddenPanels()} `;
- }
-
- private _renderHiddenPanels() {
- return html`${this._hiddenPanels.length
- ? html`${this._hiddenPanels.map((url) => {
- const panel = this.hass.panels[url];
- if (!panel) {
- return "";
- }
- return html`
- ${panel.url_path === this.hass.defaultPanel && !panel.icon
- ? html``
- : panel.url_path in PANEL_ICONS
- ? html``
- : html``}
- ${panel.url_path === this.hass.defaultPanel
- ? this.hass.localize("panel.states")
- : this.hass.localize(`panel.${panel.title}`) ||
- panel.title}
-
- `;
- })}
- ${this._renderSpacer()}`
- : ""}`;
- }
-
- private _renderDivider() {
- return html``;
- }
-
- private _renderSpacer() {
- return html``;
- }
-
- private _renderConfiguration(title: string | null) {
- return html`
-
-
- ${!this.alwaysExpand &&
- (this._updatesCount > 0 || this._issuesCount > 0)
- ? html`
-
- ${this._updatesCount + this._issuesCount}
-
- `
- : ""}
- ${title}
- ${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0)
- ? html`
- ${this._updatesCount + this._issuesCount}
- `
- : ""}
-
- `;
- }
-
- private _renderNotifications() {
- const notificationCount = this._notifications
- ? this._notifications.length
- : 0;
-
- return html`
-
-
- ${!this.alwaysExpand && notificationCount > 0
- ? html`
-
- ${notificationCount}
-
- `
- : ""}
-
- ${this.hass.localize("ui.notification_drawer.title")}
-
- ${this.alwaysExpand && notificationCount > 0
- ? html` ${notificationCount} `
- : ""}
-
-
`;
- }
-
- private _renderUserItem() {
- return html`
-
-
+
+
-
-
- ${this.hass.user ? this.hass.user.name : ""}
-
-
- `;
- }
-
- private _renderExternalConfiguration() {
- return html`${!this.hass.user?.is_admin &&
- this.hass.auth.external?.config.hasSettingsScreen
- ? html`
-
-
-
-
- ${this.hass.localize("ui.sidebar.external_app_configuration")}
-
-
-
- `
- : ""}`;
- }
-
- private _handleExternalAppConfiguration(ev: Event) {
- ev.preventDefault();
- this.hass.auth.external!.fireMessage({
- type: "config_screen/show",
- });
- }
-
- private get _tooltip() {
- return this.shadowRoot!.querySelector(".tooltip")! as HTMLDivElement;
- }
-
- private _handleAction(ev: CustomEvent) {
- if (ev.detail.action !== "hold") {
- return;
- }
-
- fireEvent(this, "hass-edit-sidebar", { editMode: true });
- }
-
- private async _activateEditMode() {
- await Promise.all([this._loadSortableStyle(), this._createSortable()]);
- }
-
- private async _loadSortableStyle() {
- if (this.sortableStyleLoaded) return;
-
- const sortStylesImport = await import("../resources/ha-sortable-style");
-
- const style = document.createElement("style");
- style.innerHTML = (sortStylesImport.sortableStyles as CSSResult).cssText;
- this.shadowRoot!.appendChild(style);
-
- this.sortableStyleLoaded = true;
- await this.updateComplete;
- }
-
- private async _createSortable() {
- const Sortable = (await import("../resources/sortable")).default;
- this._sortable = new Sortable(
- this.shadowRoot!.getElementById("sortable")!,
- {
- animation: 150,
- fallbackClass: "sortable-fallback",
- dataIdAttr: "data-panel",
- handle: "paper-icon-item",
- onSort: async () => {
- this._panelOrder = this._sortable!.toArray();
- },
- }
- );
- }
-
- private _deactivateEditMode() {
- this._sortable?.destroy();
- this._sortable = undefined;
- }
-
- private _closeEditMode() {
- fireEvent(this, "hass-edit-sidebar", { editMode: false });
- }
-
- private async _hidePanel(ev: Event) {
- ev.preventDefault();
- const panel = (ev.currentTarget as any).panel;
- if (this._hiddenPanels.includes(panel)) {
- return;
- }
- // Make a copy for Memoize
- this._hiddenPanels = [...this._hiddenPanels, panel];
- this._renderEmptySortable = true;
- await this.updateComplete;
- const container = this.shadowRoot!.getElementById("sortable")!;
- while (container.lastElementChild) {
- container.removeChild(container.lastElementChild);
- }
- this._renderEmptySortable = false;
- }
-
- private async _unhidePanel(ev: Event) {
- ev.preventDefault();
- const panel = (ev.currentTarget as any).panel;
- this._hiddenPanels = this._hiddenPanels.filter(
- (hidden) => hidden !== panel
- );
- this._renderEmptySortable = true;
- await this.updateComplete;
- const container = this.shadowRoot!.getElementById("sortable")!;
- while (container.lastElementChild) {
- container.removeChild(container.lastElementChild);
- }
- this._renderEmptySortable = false;
- }
-
- private _itemMouseEnter(ev: MouseEvent) {
- // On keypresses on the listbox, we're going to ignore mouse enter events
- // for 100ms so that we ignore it when pressing down arrow scrolls the
- // sidebar causing the mouse to hover a new icon
- if (
- this.alwaysExpand ||
- new Date().getTime() < this._recentKeydownActiveUntil
- ) {
- return;
- }
- if (this._mouseLeaveTimeout) {
- clearTimeout(this._mouseLeaveTimeout);
- this._mouseLeaveTimeout = undefined;
- }
- this._showTooltip(ev.currentTarget as PaperIconItemElement);
+ .expanded=${this.alwaysExpand}
+ .currentPanel=${currentPanel}
+ .editMode=${this.editMode}
+ @panel-hover=${this._panelHover}
+ @panel-leave=${this._mouseLeave}
+ class="ha-scrollbar"
+ >
+
+
+
+
+
+ `;
}
- private _itemMouseLeave() {
- if (this._mouseLeaveTimeout) {
- clearTimeout(this._mouseLeaveTimeout);
- }
- this._mouseLeaveTimeout = window.setTimeout(() => {
- this._hideTooltip();
- }, 500);
+ private _panelHover(ev: CustomEvent) {
+ this._mouseOverItem({ target: ev.detail });
}
- private _listboxFocusIn(ev) {
- if (this.alwaysExpand || ev.target.nodeName !== "A") {
+ private _mouseOverItem(ev: { target: HTMLElement }) {
+ const tooltipRoot =
+ this.shadowRoot?.querySelector("ha-sidebar-tooltip")?.shadowRoot;
+ const tooltip = tooltipRoot?.firstElementChild as HTMLElement;
+ if (!tooltip) return;
+ if (this.alwaysExpand) {
+ tooltip.style.display = "none";
return;
}
- this._showTooltip(ev.target.querySelector("paper-icon-item"));
- }
-
- private _listboxFocusOut() {
- this._hideTooltip();
- }
-
- @eventOptions({
- passive: true,
- })
- private _listboxScroll() {
- // On keypresses on the listbox, we're going to ignore scroll events
- // for 100ms so that if pressing down arrow scrolls the sidebar, the tooltip
- // will not be hidden.
- if (new Date().getTime() < this._recentKeydownActiveUntil) {
+ const target = ev.target;
+ const targetPos = target.getBoundingClientRect();
+ const label = target.shadowRoot?.querySelector(".name")?.innerHTML;
+ if (!label) {
+ tooltip.style.display = "none";
return;
}
- this._hideTooltip();
- }
-
- private _listboxKeydown() {
- this._recentKeydownActiveUntil = new Date().getTime() + 100;
- }
-
- private _showTooltip(item: PaperIconItemElement) {
- if (this._tooltipHideTimeout) {
- clearTimeout(this._tooltipHideTimeout);
- this._tooltipHideTimeout = undefined;
- }
- const tooltip = this._tooltip;
- const listbox = this.shadowRoot!.querySelector("paper-listbox")!;
- let top = item.offsetTop + 11;
- if (listbox.contains(item)) {
- top -= listbox.scrollTop;
- }
- tooltip.innerHTML = item.querySelector(".item-text")!.innerHTML;
+ tooltip.innerHTML = label;
tooltip.style.display = "block";
- tooltip.style.position = "fixed";
- tooltip.style.top = `${top}px`;
- tooltip.style.left = `${item.offsetLeft + item.clientWidth + 4}px`;
+ tooltip.style.left = this.offsetLeft + this.clientWidth + 4 + "px";
+ tooltip.style.top = targetPos.top + targetPos.height / 2 + "px";
}
- private _hideTooltip() {
- // Delay it a little in case other events are pending processing.
- if (!this._tooltipHideTimeout) {
- this._tooltipHideTimeout = window.setTimeout(() => {
- this._tooltipHideTimeout = undefined;
- this._tooltip.style.display = "none";
- }, 10);
- }
- }
-
- private _handleShowNotificationDrawer() {
- fireEvent(this, "hass-show-notifications");
- }
-
- private _toggleSidebar(ev: CustomEvent) {
- if (ev.detail.action !== "tap") {
- return;
- }
- fireEvent(this, "hass-toggle-menu");
- }
-
- static get styles(): CSSResultGroup {
- return [
- haStyleScrollbar,
- css`
- :host {
- overflow: visible;
- height: 100%;
- display: block;
- overflow: hidden;
- -ms-user-select: none;
- -webkit-user-select: none;
- -moz-user-select: none;
- background-color: var(--sidebar-background-color);
- width: 100%;
- box-sizing: border-box;
- }
- .menu {
- height: var(--header-height);
- box-sizing: border-box;
- display: flex;
- padding: 0 4px;
- border-bottom: 1px solid transparent;
- white-space: nowrap;
- font-weight: 400;
- color: var(--sidebar-menu-button-text-color, --primary-text-color);
- border-bottom: 1px solid var(--divider-color);
- background-color: var(
- --sidebar-menu-button-background-color,
- --primary-background-color
- );
- font-size: 20px;
- align-items: center;
- padding-left: calc(4px + env(safe-area-inset-left));
- }
- :host([rtl]) .menu {
- padding-left: 4px;
- padding-right: calc(4px + env(safe-area-inset-right));
- }
- :host([expanded]) .menu {
- width: calc(256px + env(safe-area-inset-left));
- }
- :host([rtl][expanded]) .menu {
- width: calc(256px + env(safe-area-inset-right));
- }
- .menu ha-icon-button {
- color: var(--sidebar-icon-color);
- }
- .title {
- margin-left: 19px;
- width: 100%;
- display: none;
- }
- :host([rtl]) .title {
- margin-left: 0;
- margin-right: 19px;
- }
- :host([narrow]) .title {
- margin: 0;
- padding: 0 16px;
- }
- :host([expanded]) .title {
- display: initial;
- }
- :host([expanded]) .menu mwc-button {
- margin: 0 8px;
- }
- .menu mwc-button {
- width: 100%;
- }
- #sortable,
- .hidden-panel {
- display: none;
- }
-
- paper-listbox {
- padding: 4px 0;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
- height: calc(100% - var(--header-height) - 132px);
- height: calc(
- 100% - var(--header-height) - 132px - env(safe-area-inset-bottom)
- );
- overflow-x: hidden;
- background: none;
- margin-left: env(safe-area-inset-left);
- }
-
- :host([rtl]) paper-listbox {
- margin-left: initial;
- margin-right: env(safe-area-inset-right);
- }
-
- a {
- text-decoration: none;
- color: var(--sidebar-text-color);
- font-weight: 500;
- font-size: 14px;
- position: relative;
- display: block;
- outline: 0;
- }
-
- paper-icon-item {
- box-sizing: border-box;
- margin: 4px;
- padding-left: 12px;
- border-radius: 4px;
- --paper-item-min-height: 40px;
- width: 48px;
- }
- :host([expanded]) paper-icon-item {
- width: 248px;
- }
- :host([rtl]) paper-icon-item {
- padding-left: auto;
- padding-right: 12px;
- }
-
- ha-icon[slot="item-icon"],
- ha-svg-icon[slot="item-icon"] {
- color: var(--sidebar-icon-color);
- }
-
- .iron-selected paper-icon-item::before,
- a:not(.iron-selected):focus::before {
- border-radius: 4px;
- position: absolute;
- top: 0;
- right: 2px;
- bottom: 0;
- left: 2px;
- pointer-events: none;
- content: "";
- transition: opacity 15ms linear;
- will-change: opacity;
- }
- .iron-selected paper-icon-item::before {
- background-color: var(--sidebar-selected-icon-color);
- opacity: 0.12;
- }
- a:not(.iron-selected):focus::before {
- background-color: currentColor;
- opacity: var(--dark-divider-opacity);
- margin: 4px 8px;
- }
- .iron-selected paper-icon-item:focus::before,
- .iron-selected:focus paper-icon-item::before {
- opacity: 0.2;
- }
-
- .iron-selected paper-icon-item[pressed]:before {
- opacity: 0.37;
- }
-
- paper-icon-item span {
- color: var(--sidebar-text-color);
- font-weight: 500;
- font-size: 14px;
- }
-
- a.iron-selected paper-icon-item ha-icon,
- a.iron-selected paper-icon-item ha-svg-icon {
- color: var(--sidebar-selected-icon-color);
- }
-
- a.iron-selected .item-text {
- color: var(--sidebar-selected-text-color);
- }
-
- paper-icon-item .item-text {
- display: none;
- max-width: calc(100% - 56px);
- }
- :host([expanded]) paper-icon-item .item-text {
- display: block;
- }
-
- .divider {
- bottom: 112px;
- padding: 10px 0;
- }
- .divider::before {
- content: " ";
- display: block;
- height: 1px;
- background-color: var(--divider-color);
- }
- .notifications-container,
- .configuration-container {
- display: flex;
- margin-left: env(safe-area-inset-left);
- }
- :host([rtl]) .notifications-container,
- :host([rtl]) .configuration-container {
- margin-left: initial;
- margin-right: env(safe-area-inset-right);
- }
- .notifications {
- cursor: pointer;
- }
- .notifications .item-text,
- .configuration .item-text {
- flex: 1;
- }
- .profile {
- margin-left: env(safe-area-inset-left);
- }
- :host([rtl]) .profile {
- margin-left: initial;
- margin-right: env(safe-area-inset-right);
- }
- .profile paper-icon-item {
- padding-left: 4px;
- }
- :host([rtl]) .profile paper-icon-item {
- padding-left: auto;
- padding-right: 4px;
- }
- .profile .item-text {
- margin-left: 8px;
- }
- :host([rtl]) .profile .item-text {
- margin-right: 8px;
- }
-
- .notification-badge,
- .configuration-badge {
- position: absolute;
- left: calc(var(--app-drawer-width, 248px) - 42px);
- min-width: 20px;
- box-sizing: border-box;
- border-radius: 50%;
- font-weight: 400;
- background-color: var(--accent-color);
- line-height: 20px;
- text-align: center;
- padding: 0px 2px;
- color: var(--text-accent-color, var(--text-primary-color));
- }
- ha-svg-icon + .notification-badge,
- ha-svg-icon + .configuration-badge {
- position: absolute;
- bottom: 14px;
- left: 26px;
- font-size: 0.65em;
- }
-
- .spacer {
- flex: 1;
- pointer-events: none;
- }
-
- .subheader {
- color: var(--sidebar-text-color);
- font-weight: 500;
- font-size: 14px;
- padding: 16px;
- white-space: nowrap;
- }
-
- .dev-tools {
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- padding: 0 8px;
- width: 256px;
- box-sizing: border-box;
- }
-
- .dev-tools a {
- color: var(--sidebar-icon-color);
- }
-
- .tooltip {
- display: none;
- position: absolute;
- opacity: 0.9;
- border-radius: 2px;
- white-space: nowrap;
- color: var(--sidebar-background-color);
- background-color: var(--sidebar-text-color);
- padding: 4px;
- font-weight: 500;
- }
-
- :host([rtl]) .menu ha-icon-button {
- -webkit-transform: scaleX(-1);
- transform: scaleX(-1);
- }
- `,
- ];
+ private _mouseLeave() {
+ const tooltipRoot =
+ this.shadowRoot?.querySelector("ha-sidebar-tooltip")?.shadowRoot;
+ const tooltip = tooltipRoot?.firstElementChild as HTMLElement;
+ if (!tooltip) return;
+ tooltip.style.display = "none";
}
}
diff --git a/src/layouts/home-assistant-main.ts b/src/layouts/home-assistant-main.ts
index 956c4c8eedd0..36e174c9a397 100644
--- a/src/layouts/home-assistant-main.ts
+++ b/src/layouts/home-assistant-main.ts
@@ -175,7 +175,7 @@ export class HomeAssistantMain extends LitElement {
color: var(--primary-text-color);
/* remove the grey tap highlights in iOS on the fullscreen touch targets */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
- --mdc-drawer-width: 56px;
+ --mdc-drawer-width: 75px;
--mdc-top-app-bar-width: calc(100% - var(--mdc-drawer-width));
}
:host([expanded]) {
diff --git a/src/resources/button-handlers.ts b/src/resources/button-handlers.ts
new file mode 100644
index 000000000000..17b654a5e73c
--- /dev/null
+++ b/src/resources/button-handlers.ts
@@ -0,0 +1,10 @@
+export const keydown = (e: KeyboardEvent & { currentTarget: HTMLElement }) => {
+ if (e.key === " ") e.preventDefault();
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.currentTarget.click();
+ }
+};
+export const keyup = (e: KeyboardEvent & { currentTarget: HTMLElement }) => {
+ if (e.key === " ") e.currentTarget.click();
+};
diff --git a/src/resources/ha-sortable-style.ts b/src/resources/ha-sortable-style.ts
index 8c7a5ded7c7b..8b745f5adf5e 100644
--- a/src/resources/ha-sortable-style.ts
+++ b/src/resources/ha-sortable-style.ts
@@ -1,37 +1,11 @@
import { css } from "lit";
export const sortableStyles = css`
- #sortable a:nth-of-type(2n) paper-icon-item {
- animation-name: keyframes1;
- animation-iteration-count: infinite;
- transform-origin: 50% 10%;
- animation-delay: -0.75s;
- animation-duration: 0.25s;
- }
-
- #sortable a:nth-of-type(2n-1) paper-icon-item {
- animation-name: keyframes2;
- animation-iteration-count: infinite;
- animation-direction: alternate;
- transform-origin: 30% 5%;
- animation-delay: -0.5s;
- animation-duration: 0.33s;
- }
-
- #sortable a {
- height: 48px;
- display: flex;
- }
-
#sortable {
outline: none;
display: block !important;
}
- .hidden-panel {
- display: flex !important;
- }
-
.sortable-fallback {
display: none;
}
@@ -39,71 +13,4 @@ export const sortableStyles = css`
.sortable-ghost {
opacity: 0.4;
}
-
- .sortable-fallback {
- opacity: 0;
- }
-
- @keyframes keyframes1 {
- 0% {
- transform: rotate(-1deg);
- animation-timing-function: ease-in;
- }
-
- 50% {
- transform: rotate(1.5deg);
- animation-timing-function: ease-out;
- }
- }
-
- @keyframes keyframes2 {
- 0% {
- transform: rotate(1deg);
- animation-timing-function: ease-in;
- }
-
- 50% {
- transform: rotate(-1.5deg);
- animation-timing-function: ease-out;
- }
- }
-
- .show-panel,
- .hide-panel {
- display: none;
- position: absolute;
- top: 0;
- right: 4px;
- --mdc-icon-button-size: 40px;
- }
-
- :host([rtl]) .show-panel {
- right: initial;
- left: 4px;
- }
-
- .hide-panel {
- top: 4px;
- right: 8px;
- }
-
- :host([rtl]) .hide-panel {
- right: initial;
- left: 8px;
- }
-
- :host([expanded]) .hide-panel {
- display: block;
- }
-
- :host([expanded]) .show-panel {
- display: inline-flex;
- }
-
- paper-icon-item.hidden-panel,
- paper-icon-item.hidden-panel span,
- paper-icon-item.hidden-panel ha-icon[slot="item-icon"] {
- color: var(--secondary-text-color);
- cursor: pointer;
- }
`;
diff --git a/src/resources/styles-data.ts b/src/resources/styles-data.ts
index 87114d688515..97446e4d32c7 100644
--- a/src/resources/styles-data.ts
+++ b/src/resources/styles-data.ts
@@ -60,9 +60,12 @@ export const derivedStyles = {
"state-unavailable-color":
"var(--state-icon-unavailable-color, var(--disabled-text-color))",
"sidebar-text-color": "var(--primary-text-color)",
+ "rgb-sidebar-text-color": "var(--rgb-primary-text-color)",
"sidebar-background-color": "var(--card-background-color)",
- "sidebar-selected-text-color": "var(--primary-color)",
- "sidebar-selected-icon-color": "var(--primary-color)",
+ "sidebar-selected-color":
+ "var(--sidebar-selected-text-color, var(--primary-color))",
+ "rgb-sidebar-selected-color":
+ "var(--rgb-sidebar-selected-text-color, var(--rgb-primary-color))",
"sidebar-icon-color": "rgba(var(--rgb-primary-text-color), 0.6)",
"switch-checked-color": "var(--primary-color)",
"switch-checked-button-color":
diff --git a/src/resources/styles.ts b/src/resources/styles.ts
index 80eef864e7e0..697579c94827 100644
--- a/src/resources/styles.ts
+++ b/src/resources/styles.ts
@@ -180,6 +180,106 @@ export const haStyleDialog = css`
}
`;
+export const haStyleSidebarItem = css`
+ :host {
+ padding-inline: 12px;
+ }
+ .item {
+ --rgb-text: var(--rgb-sidebar-text-color);
+ background-color: transparent;
+ color: rgb(var(--rgb-text));
+ text-decoration: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ font-family: inherit;
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 20px;
+
+ position: relative;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--sidebar-item-radius, 25px);
+ height: 50px;
+ }
+ .item .icon {
+ position: relative;
+ text-align: left;
+ width: 24px;
+ }
+ .item .name {
+ display: none;
+ white-space: nowrap;
+ }
+ .item:hover {
+ color: rgb(var(--rgb-text));
+ background-color: rgba(var(--rgb-text), 0.08);
+ }
+ .item:focus-visible {
+ outline: none;
+ }
+ .item:focus-visible,
+ .item:active {
+ color: rgb(var(--rgb-text));
+ background-color: rgba(var(--rgb-text), 0.12);
+ }
+ .item[aria-current="page"] {
+ --rgb-text: var(--rgb-sidebar-selected-color);
+ background-color: rgba(var(--rgb-text), 0.12);
+ }
+ .item[aria-current="page"]:focus-visible {
+ background-color: rgba(var(--rgb-text), 0.2);
+ }
+ .item.expanded {
+ padding-inline-start: 16px;
+ padding-inline-end: 24px;
+ justify-content: initial;
+ }
+ .item.expanded .icon {
+ margin-inline-end: 12px;
+ }
+ .item.expanded .name {
+ display: initial;
+ }
+ .target {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -12px;
+ right: -12px;
+ }
+ .badge {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 16px;
+ height: 16px;
+ transform: translateX(50%);
+ border-radius: 8px;
+ background-color: var(--accent-color);
+ color: var(--text-accent-color, var(--text-primary-color));
+ font-weight: 500;
+ font-size: 11px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .count {
+ margin-inline-start: auto;
+ margin-inline-end: -9px;
+ background-color: var(--accent-color);
+ color: var(--text-accent-color, var(--text-primary-color));
+ padding: 0 6px;
+ min-width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ box-sizing: border-box;
+ }
+`;
+
export const haStyleScrollbar = css`
.ha-scrollbar::-webkit-scrollbar {
width: 0.4rem;