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._renderAllPanels()} - ${this._renderDivider()} - ${this._renderNotifications()} - ${this._renderUserItem()} -
+
+ +
  • + ${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"] {