diff --git a/src/components/ha-clickable-list-item.ts b/src/components/ha-clickable-list-item.ts
new file mode 100644
index 000000000000..4e812609959b
--- /dev/null
+++ b/src/components/ha-clickable-list-item.ts
@@ -0,0 +1,61 @@
+import { style } from "@material/mwc-list/mwc-list-item-css";
+import { ListItemBase } from "@material/mwc-list/mwc-list-item-base";
+import { css, CSSResult, customElement, property } from "lit-element";
+import { html } from "lit-html";
+
+@customElement("ha-clickable-list-item")
+export class HaClickableListItem extends ListItemBase {
+ public href?: string;
+
+ public disableHref = false;
+
+ // property used only in css
+ @property({ type: Boolean, reflect: true }) public rtl = false;
+
+ public render() {
+ const r = super.render();
+ const href = this.href ? `/${this.href}` : "";
+
+ return html` ${this.renderRipple()}
+ ${this.disableHref
+ ? html`${r}`
+ : html`${r}`}`;
+ }
+
+ static get styles(): CSSResult[] {
+ return [
+ style,
+ css`
+ :host {
+ padding-left: 0px;
+ padding-right: 0px;
+ }
+
+ :host([rtl]) span {
+ margin-left: var(--mdc-list-item-graphic-margin, 20px) !important;
+ margin-right: 0px !important;
+ }
+
+ :host([graphic="avatar"]:not([twoLine])),
+ :host([graphic="icon"]:not([twoLine])) {
+ height: 48px;
+ }
+ a {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ padding-left: var(--mdc-list-side-padding, 20px);
+ padding-right: var(--mdc-list-side-padding, 20px);
+ font-weight: 500;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-clickable-list-item": HaClickableListItem;
+ }
+}
diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts
index 83502db7307a..ab0ba3e73327 100644
--- a/src/components/ha-sidebar.ts
+++ b/src/components/ha-sidebar.ts
@@ -1,5 +1,11 @@
+import "./ha-clickable-list-item";
+import "./ha-icon";
+import "./ha-menu-button";
+import "./ha-svg-icon";
+import "./user/ha-user-badge";
import "@material/mwc-button/mwc-button";
import "@material/mwc-icon-button";
+import { List } from "@material/mwc-list";
import {
mdiBell,
mdiCellphoneCog,
@@ -9,10 +15,6 @@ import {
mdiPlus,
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 {
css,
CSSResult,
@@ -23,8 +25,8 @@ import {
LitElement,
property,
PropertyValues,
+ query,
} from "lit-element";
-import { classMap } from "lit-html/directives/class-map";
import { guard } from "lit-html/directives/guard";
import memoizeOne from "memoize-one";
import { LocalStorage } from "../common/decorators/local-storage";
@@ -44,10 +46,8 @@ import {
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo } from "../types";
-import "./ha-icon";
-import "./ha-menu-button";
-import "./ha-svg-icon";
-import "./user/ha-user-badge";
+import { ListItem } from "@material/mwc-list/mwc-list-item";
+import { navigate } from "../common/navigate";
const SHOW_AFTER_SPACER = ["config", "developer-tools", "hassio"];
@@ -157,6 +157,12 @@ const computePanels = memoizeOne(
}
);
+const isListItem = (element: Element): element is ListItem =>
+ element.hasAttribute("mwc-list-item");
+
+const isNodeElement = (node: Node): node is Element =>
+ node.nodeType === Node.ELEMENT_NODE;
+
let Sortable;
@customElement("ha-sidebar")
@@ -181,6 +187,12 @@ class HaSidebar extends LitElement {
@internalProperty() private _renderEmptySortable = false;
+ @query("div.ha-scrollbar mwc-list.main-panels", false)
+ private _standardPanelList!: List;
+
+ @query("div.ha-scrollbar mwc-list.utility-panels", false)
+ private _utilityPanelList!: List;
+
private _mouseLeaveTimeout?: number;
private _tooltipHideTimeout?: number;
@@ -209,11 +221,21 @@ class HaSidebar extends LitElement {
// prettier-ignore
return html`
${this._renderHeader()}
+
+
+
+ ${this._renderNotifications()}
+ ${this._renderUserItem()}
+ ${this._renderSpacer()}
+
`;
}
@@ -289,7 +311,9 @@ class HaSidebar extends LitElement {
return;
}
if (!oldHass || oldHass.panelUrl !== this.hass.panelUrl) {
- const selectedEl = this.shadowRoot!.querySelector(".iron-selected");
+ const selectedEl = this.shadowRoot!.querySelector(
+ "ha-clickable-list-item[activated]"
+ );
if (selectedEl) {
// @ts-ignore
selectedEl.scrollIntoViewIfNeeded();
@@ -329,7 +353,15 @@ class HaSidebar extends LitElement {
}
private _renderAllPanels() {
- const [beforeSpacer, afterSpacer] = computePanels(
+ // prettier-ignore
+ return html`
+ ${this._renderNormalPanels()}
+ ${this._renderUtilityPanels()}
+ `;
+ }
+
+ private _renderNormalPanels() {
+ const [beforeSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
this._panelOrder,
@@ -338,10 +370,9 @@ class HaSidebar extends LitElement {
// prettier-ignore
return html`
-
+
`;
}
@@ -364,7 +392,7 @@ class HaSidebar extends LitElement {
this._renderEmptySortable ? "" : this._renderPanels(beforeSpacer)
)}
- ${this._renderSpacer()}
+ ${this._renderHiddenItemSpacer()}
${this._renderHiddenPanels()} `;
}
@@ -375,13 +403,15 @@ class HaSidebar extends LitElement {
if (!panel) {
return "";
}
- return html`
- `;
+ `;
})}
${this._renderSpacer()}`
: ""}`;
}
- private _renderDivider() {
- return html``;
+ private _renderUtilityPanels() {
+ const [, afterSpacer] = computePanels(
+ this.hass.panels,
+ this.hass.defaultPanel,
+ this._panelOrder,
+ this._hiddenPanels
+ );
+
+ // prettier-ignore
+ return html`
+
+ ${afterSpacer.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
+ ? mdiViewDashboard
+ : undefined
+ )
+ )}
+ ${this._renderExternalConfiguration()}
+
+ `;
}
private _renderSpacer() {
- return html``;
+ return html``;
+ }
+
+ private _renderHiddenItemSpacer() {
+ return html``;
}
private _renderNotifications() {
@@ -419,20 +483,20 @@ class HaSidebar extends LitElement {
}
}
- return html`
-
-
+
${!this.expanded && notificationCount > 0
? html`
-
+
${notificationCount}
`
@@ -441,45 +505,45 @@ class HaSidebar extends LitElement {
${this.hass.localize("ui.notification_drawer.title")}
${this.expanded && notificationCount > 0
- ? html` ${notificationCount} `
+ ? html`
+ ${notificationCount}
+ `
: ""}
-
-
`;
+
+ `;
}
private _renderUserItem() {
- return html`
-
-
-
-
- ${this.hass.user ? this.hass.user.name : ""}
-
-
- `;
+
+
+
+ ${this.hass.user?.name}
+
+ `;
}
private _renderExternalConfiguration() {
return html`${this._externalConfig && this._externalConfig.hasSettingsScreen
? html`
-
-
-
-
- ${this.hass.localize("ui.sidebar.external_app_configuration")}
-
-
-
+
+
+ ${this.hass.localize("ui.sidebar.external_app_configuration")}
+
+
`
- : ""}`;
+ : ""}
+ ${this._renderSpacer()} `;
}
private get _tooltip() {
@@ -520,7 +582,7 @@ class HaSidebar extends LitElement {
if (!Sortable) {
const [sortableImport, sortStylesImport] = await Promise.all([
import("sortablejs/modular/sortable.core.esm"),
- import("../resources/ha-sortable-style"),
+ import("../resources/ha-sortable-style-ha-clickable"),
]);
const style = document.createElement("style");
@@ -542,7 +604,6 @@ class HaSidebar extends LitElement {
animation: 150,
fallbackClass: "sortable-fallback",
dataIdAttr: "data-panel",
- handle: "paper-icon-item",
onSort: async () => {
this._panelOrder = this._sortable.toArray();
},
@@ -604,7 +665,7 @@ class HaSidebar extends LitElement {
clearTimeout(this._mouseLeaveTimeout);
this._mouseLeaveTimeout = undefined;
}
- this._showTooltip(ev.currentTarget as PaperIconItemElement);
+ this._showTooltip(ev.currentTarget);
}
private _itemMouseLeave() {
@@ -620,7 +681,7 @@ class HaSidebar extends LitElement {
if (this.expanded || ev.target.nodeName !== "A") {
return;
}
- this._showTooltip(ev.target.querySelector("paper-icon-item"));
+ this._showTooltip(ev.target.querySelector("ha-clickable-list-item"));
}
private _listboxFocusOut() {
@@ -640,17 +701,91 @@ class HaSidebar extends LitElement {
this._hideTooltip();
}
- private _listboxKeydown() {
+ private _getIndexOfTarget(evt: Event): number {
+ const listbox = evt.currentTarget as List;
+ const elements = listbox.items;
+ const path = evt.composedPath();
+
+ for (const pathItem of path as Node[]) {
+ let index = -1;
+ if (isNodeElement(pathItem) && isListItem(pathItem)) {
+ index = elements.indexOf(pathItem);
+ }
+
+ if (index !== -1) {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ private _getCurrentListPosition(ev: KeyboardEvent) {
+ return {
+ index: this._getIndexOfTarget(ev),
+ list: ev.currentTarget as List,
+ };
+ }
+
+ private _selectNextItem(ev: KeyboardEvent) {
+ const [beforeSpacer, afterSpacer] = computePanels(
+ this.hass.panels,
+ this.hass.defaultPanel,
+ this._panelOrder,
+ this._hiddenPanels
+ );
+
+ const { index, list } = this._getCurrentListPosition(ev);
+
+ if (list === this._standardPanelList && index === beforeSpacer.length - 1) {
+ this._setFocusPanelList(this._utilityPanelList, "top");
+ } else if (
+ list === this._utilityPanelList &&
+ index === afterSpacer.length - 1
+ ) {
+ this._setFocusPanelList(this._standardPanelList, "top");
+ }
+ }
+
+ private _selectPreviousItem(ev: KeyboardEvent) {
+ const { index, list } = this._getCurrentListPosition(ev);
+
+ if (list === this._standardPanelList && index === 0) {
+ this._setFocusPanelList(this._utilityPanelList, "bottom");
+ } else if (list === this._utilityPanelList && index === 0) {
+ this._setFocusPanelList(this._standardPanelList, "bottom");
+ }
+ }
+
+ private _listboxKeydown(ev: KeyboardEvent) {
+ if (ev.code === "ArrowDown") {
+ this._selectNextItem(ev);
+ } else if (ev.code === "ArrowUp") {
+ this._selectPreviousItem(ev);
+ } else if (ev.code === "Enter") {
+ (ev.target as ListItem)?.shadowRoot?.querySelector("a")?.click();
+ }
+
this._recentKeydownActiveUntil = new Date().getTime() + 100;
}
- private _showTooltip(item: PaperIconItemElement) {
+ private _setFocusPanelList(list: List, position: "top" | "bottom") {
+ let index = 0;
+
+ if (position === "bottom") {
+ index = list.querySelectorAll("ha-clickable-list-item").length - 1;
+ }
+
+ list.focusItemAtIndex(index);
+ }
+
+ private _showTooltip(item) {
if (this._tooltipHideTimeout) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = undefined;
}
const tooltip = this._tooltip;
- const listbox = this.shadowRoot!.querySelector("paper-listbox")!;
+ const listbox = this.shadowRoot!.querySelector("mwc-list")!;
let top = item.offsetTop + 11;
if (listbox.contains(item)) {
top -= listbox.scrollTop;
@@ -711,23 +846,22 @@ class HaSidebar extends LitElement {
iconPath?: string | null
) {
return html`
- navigate(this, `/${urlPath}`)}
+ graphic="icon"
+ .rtl=${this.rtl}
>
-
- ${iconPath
- ? html``
- : html``}
- ${title}
-
+ ${iconPath
+ ? html``
+ : html``}
+ ${title}
${this.editMode
? html`
`
: ""}
-
+
`;
}
@@ -795,6 +929,21 @@ class HaSidebar extends LitElement {
.menu mwc-icon-button {
color: var(--sidebar-icon-color);
}
+
+ ha-clickable-list-item {
+ margin: 4px;
+ border-radius: 4px;
+ height: 40px;
+ --mdc-list-side-padding: 12px;
+ --mdc-theme-text-icon-on-background: var(--sidebar-icon-color);
+ }
+
+ ha-clickable-list-item[activated] {
+ --mdc-theme-text-icon-on-background: var(
+ --sidebar-selected-icon-color
+ );
+ }
+
.title {
margin-left: 19px;
width: 100%;
@@ -806,7 +955,6 @@ class HaSidebar extends LitElement {
}
:host([narrow]) .title {
margin: 0;
- padding: 0 16px;
}
:host([expanded]) .title {
display: initial;
@@ -822,120 +970,46 @@ class HaSidebar extends LitElement {
display: none;
}
- paper-listbox {
- padding: 4px 0;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
- height: calc(100% - var(--header-height) - 132px);
+ .ha-scrollbar {
+ height: calc(100% - var(--header-height) - 105px);
height: calc(
- 100% - var(--header-height) - 132px - env(safe-area-inset-bottom)
+ 100% - var(--header-height) - 105px - 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;
+ display: flex;
+ justify-content: space-between;
+ flex-direction: column;
}
- .iron-selected paper-icon-item[pressed]:before {
- opacity: 0.37;
+ mwc-list {
+ width: var(--app-drawer-width);
+ --mdc-list-vertical-padding: 4px 0;
+ margin-left: env(safe-area-inset-left);
+ -ms-user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ background-color: var(--sidebar-background-color);
}
- paper-icon-item span {
- color: var(--sidebar-text-color);
- font-weight: 500;
- font-size: 14px;
+ :host([rtl]) mwc-list {
+ border-right: 0;
+ /* border-left: 1px solid var(--divider-color); */
}
- a.iron-selected paper-icon-item ha-icon,
- a.iron-selected paper-icon-item ha-svg-icon {
- color: var(--sidebar-selected-icon-color);
+ :host([expanded]) mwc-list {
+ width: 256px;
+ width: calc(256px + env(safe-area-inset-left));
}
- a.iron-selected .item-text {
- color: var(--sidebar-selected-text-color);
+ [slot="graphic"] {
+ width: 100%;
}
- paper-icon-item .item-text {
- display: none;
- max-width: calc(100% - 56px);
- }
- :host([expanded]) paper-icon-item .item-text {
- display: block;
+ :host([rtl]) mwc-list {
+ margin-left: initial;
+ margin-right: env(safe-area-inset-right);
}
- .divider {
- bottom: 112px;
- padding: 10px 0;
- }
- .divider::before {
- content: " ";
- display: block;
- height: 1px;
- background-color: var(--divider-color);
- }
.notifications-container {
display: flex;
margin-left: env(safe-area-inset-left);
@@ -951,22 +1025,19 @@ class HaSidebar extends LitElement {
flex: 1;
}
.profile {
- margin-left: env(safe-area-inset-left);
+ --mdc-list-item-graphic-margin: 16px;
+ --mdc-list-item-graphic-size: 40px;
+ --mdc-list-side-padding: 4px;
}
: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;
+ --mdc-list-item-graphic-size: 40px;
+ --mdc-list-side-padding: 4px;
}
+
.profile .item-text {
margin-left: 8px;
}
+
:host([rtl]) .profile .item-text {
margin-right: 8px;
}
@@ -977,21 +1048,31 @@ class HaSidebar extends LitElement {
border-radius: 50%;
font-weight: 400;
background-color: var(--accent-color);
- line-height: 20px;
+ line-height: 1.5rem;
text-align: center;
- padding: 0px 6px;
+ padding: 2px 6px;
color: var(--text-accent-color, var(--text-primary-color));
+ font-size: 14px;
}
+
ha-svg-icon + .notification-badge {
position: absolute;
- bottom: 14px;
- left: 26px;
- font-size: 0.65em;
+ bottom: 18px;
+ left: 25px;
+ padding: 0px 0px;
}
.spacer {
flex: 1;
pointer-events: none;
+ border: 0px;
+ }
+
+ .spacer-hidden {
+ flex: 1;
+ pointer-events: none;
+ height: 77px;
+ border: 0px;
}
.subheader {
@@ -1002,19 +1083,6 @@ class HaSidebar extends LitElement {
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;
diff --git a/src/resources/ha-sortable-style-ha-clickable.ts b/src/resources/ha-sortable-style-ha-clickable.ts
new file mode 100644
index 000000000000..427b4f9f3ec7
--- /dev/null
+++ b/src/resources/ha-sortable-style-ha-clickable.ts
@@ -0,0 +1,95 @@
+import { css } from "lit-element";
+
+export const sortableStyles = css`
+ #sortable ha-clickable-list-item:nth-of-type(2n) {
+ animation-name: keyframes1;
+ animation-iteration-count: infinite;
+ transform-origin: 50% 10%;
+ animation-delay: -0.75s;
+ animation-duration: 0.25s;
+ }
+
+ #sortable ha-clickable-list-item:nth-of-type(2n-1) {
+ 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;
+ opacity: 0;
+ }
+
+ .sortable-ghost {
+ opacity: 0.4;
+ }
+
+ @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: 0;
+ --mdc-icon-button-size: 40px;
+ }
+
+ .hide-panel {
+ top: 4px;
+ right: 8px;
+ }
+
+ :host([expanded]) .hide-panel {
+ display: block;
+ }
+
+ :host([expanded]) .show-panel {
+ display: inline-flex;
+ }
+
+ ha-clickable-list-item.hidden-panel,
+ ha-clickable-list-item.hidden-panel span {
+ color: var(--secondary-text-color);
+ cursor: pointer;
+ }
+`;
diff --git a/src/resources/ha-sortable-style.ts b/src/resources/ha-sortable-style.ts
index 5979f986e266..086c38551293 100644
--- a/src/resources/ha-sortable-style.ts
+++ b/src/resources/ha-sortable-style.ts
@@ -1,6 +1,7 @@
import { css } from "lit-element";
export const sortableStyles = css`
+ #sortable ha-clickable-list-item:nth-of-type(2n),
#sortable a:nth-of-type(2n) paper-icon-item {
animation-name: keyframes1;
animation-iteration-count: infinite;
@@ -9,6 +10,7 @@ export const sortableStyles = css`
animation-duration: 0.25s;
}
+ #sortable ha-clickable-list-item:nth-of-type(2n-1),
#sortable a:nth-of-type(2n-1) paper-icon-item {
animation-name: keyframes2;
animation-iteration-count: infinite;
@@ -34,16 +36,13 @@ export const sortableStyles = css`
.sortable-fallback {
display: none;
+ opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
- .sortable-fallback {
- opacity: 0;
- }
-
@keyframes keyframes1 {
0% {
transform: rotate(-1deg);
@@ -73,15 +72,10 @@ export const sortableStyles = css`
display: none;
position: absolute;
top: 0;
- right: 4px;
+ right: 0;
--mdc-icon-button-size: 40px;
}
- :host([rtl]) .show-panel {
- right: initial;
- left: 4px;
- }
-
.hide-panel {
top: 4px;
right: 8px;
@@ -100,6 +94,8 @@ export const sortableStyles = css`
display: inline-flex;
}
+ ha-clickable-list-item.hidden-panel,
+ ha-clickable-list-item.hidden-panel span,
paper-icon-item.hidden-panel,
paper-icon-item.hidden-panel span,
paper-icon-item.hidden-panel ha-icon[slot="item-icon"] {