diff --git a/src/components/ha-divider-list-item.ts b/src/components/ha-divider-list-item.ts new file mode 100644 index 0000000000..5f3353b104 --- /dev/null +++ b/src/components/ha-divider-list-item.ts @@ -0,0 +1,25 @@ +import { ListItem } from "@material/mwc-list/mwc-list-item"; +import { css, CSSResult, customElement } from "lit-element"; + +@customElement("ha-divider-list-item") +export class HaDividerListItem extends ListItem { + noninteractive = true; + + static get styles(): CSSResult[] { + return [ + super.styles, + css` + :host { + height: var(--ha-divider-height, 48px); + border: 1px solid black; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-divider-list-item": HaDividerListItem; + } +} diff --git a/src/components/ha-sidebar-header.ts b/src/components/ha-sidebar-header.ts new file mode 100644 index 0000000000..903408d9f3 --- /dev/null +++ b/src/components/ha-sidebar-header.ts @@ -0,0 +1,76 @@ +import "./ha-sidebar-panel-list"; +import "./ha-clickable-list-item"; +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-list/mwc-list"; +import "@material/mwc-button/mwc-button"; +import "@material/mwc-icon-button"; +import { mdiMenu, mdiMenuOpen } from "@mdi/js"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; + +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./ha-icon"; +import "./ha-menu-button"; +import "./ha-svg-icon"; +import "./user/ha-user-badge"; + +@customElement("ha-sidebar-header") +class HaSidebarHeader extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @property() public text: TemplateResult | string = ""; + + @property() public toggleButtonCallback?: (ev: CustomEvent) => void; + + // property used only in css + // @ts-ignore + @property({ type: Boolean, reflect: true }) public rtl = false; + + protected render() { + if (!this.hass) { + return html``; + } + + return html` + ${!this.narrow + ? html` + + + + ` + : ""} +
+ ${this.text} +
+ `; + } + + static get styles(): CSSResult[] { + return [haStyleScrollbar, css``]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-header": HaSidebarHeader; + } +} diff --git a/src/components/ha-sidebar-overhaul.ts b/src/components/ha-sidebar-overhaul.ts new file mode 100644 index 0000000000..936314380f --- /dev/null +++ b/src/components/ha-sidebar-overhaul.ts @@ -0,0 +1,331 @@ +import "./ha-sidebar-header"; +import "./ha-sidebar-panel-list"; +import "./ha-clickable-list-item"; +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-list/mwc-list"; +import "@material/mwc-button/mwc-button"; +import "@material/mwc-icon-button"; +import { mdiBell, mdiMenu, mdiMenuOpen } from "@mdi/js"; +import { + css, + CSSResult, + customElement, + eventOptions, + html, + internalProperty, + LitElement, + property, + PropertyValues, +} from "lit-element"; +import { classMap } from "lit-html/directives/class-map"; +import { fireEvent } from "../common/dom/fire_event"; +import { computeDomain } from "../common/entity/compute_domain"; +import { computeRTL } from "../common/util/compute_rtl"; +import { ActionHandlerDetail } from "../data/lovelace"; +import { + PersistentNotification, + subscribeNotifications, +} from "../data/persistent_notification"; +import { getExternalConfig } from "../external_app/external_config"; +import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./ha-icon"; +import "./ha-menu-button"; +import "./ha-svg-icon"; +import "./user/ha-user-badge"; +import { sidebarStyles } from "./ha-sidebar"; + +const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body; + +let Sortable; + +@customElement("ha-sidebar-overhaul") +class HaSidebarOverhaul extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @property({ type: Boolean }) public alwaysExpand = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @property({ type: Boolean }) public editMode = false; + + @internalProperty() private _notifications?: PersistentNotification[]; + + // property used only in css + // @ts-ignore + @property({ type: Boolean, reflect: true }) public rtl = false; + + private _sortable?; + + protected render() { + const hass = this.hass; + if (!this.hass) { + return html``; + } + + // prettier-ignore + return html` ${this._renderHeader()} +
+
+ + Panel 1 + Panel 2 + Panel 3 + Divider + Utility + +
+
+
User stuff
+
`; + } + + private _renderHeader() { + return html``; + } + + private _editDoneButton() { + return html` this._closeEditMode()}> + ${this.hass.localize("ui.sidebar.done")} + `; + } + + protected updated(changedProps) { + super.updated(changedProps); + if (changedProps.has("alwaysExpand")) { + 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.language !== this.hass.language) { + this.rtl = computeRTL(this.hass); + } + + 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 async _activateEditMode() { + if (!Sortable) { + const [sortableImport, sortStylesImport] = await Promise.all([ + import("sortablejs/modular/sortable.core.esm"), + import("../resources/ha-sortable-style-ha-clickable"), + ]); + + const style = document.createElement("style"); + style.innerHTML = sortStylesImport.sortableStyles.cssText; + this.shadowRoot!.appendChild(style); + + Sortable = sortableImport.Sortable; + Sortable.mount(sortableImport.OnSpill); + Sortable.mount(sortableImport.AutoScroll()); + } + + await this.updateComplete; + + this._createSortable(); + } + + private _createSortable() { + this._sortable = new Sortable(this.shadowRoot!.getElementById("sortable"), { + animation: 150, + fallbackClass: "sortable-fallback", + // fallbackTolerance: 15, + dataIdAttr: "data-panel", + handle: "span", + onSort: async () => {}, + }); + } + + private _deactivateEditMode() { + this._sortable?.destroy(); + this._sortable = undefined; + } + + private _handleAction(ev: CustomEvent) { + if (ev.detail.action !== "hold") { + return; + } + + fireEvent(this, "hass-edit-sidebar", { editMode: true }); + } + + private _closeEditMode() { + fireEvent(this, "hass-edit-sidebar", { editMode: false }); + } + + private _toggleSidebar(ev: CustomEvent) { + if (ev.detail.action !== "tap") { + return; + } + fireEvent(this, "hass-toggle-menu"); + } + + static get styles(): CSSResult[] { + return [ + haStyleScrollbar, + css` + :host { + /* height: calc(100% - var(--header-height)); */ + height: 100%; + display: block; + overflow: hidden; + -ms-user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + border-right: 1px solid var(--divider-color); + background-color: var(--sidebar-background-color); + width: 64px; + } + :host([expanded]) { + width: 256px; + width: calc(256px + env(safe-area-inset-left)); + } + :host([rtl]) { + border-right: 0; + border-left: 1px solid var(--divider-color); + } + .menu { + height: var(--header-height); + display: flex; + padding: 0 8.5px; + border-bottom: 1px solid transparent; + white-space: nowrap; + font-weight: 400; + color: var(--primary-text-color); + border-bottom: 1px solid var(--divider-color); + background-color: var(--primary-background-color); + font-size: 20px; + align-items: center; + padding-left: calc(8.5px + env(safe-area-inset-left)); + } + + :host([rtl]) .menu { + padding-left: 8.5px; + padding-right: calc(8.5px + 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 mwc-icon-button { + color: var(--sidebar-icon-color); + } + :host([expanded]) .menu mwc-icon-button { + margin-right: 23px; + } + :host([expanded][rtl]) .menu mwc-icon-button { + margin-right: 0px; + margin-left: 23px; + } + + .title { + width: 100%; + display: none; + } + :host([narrow]) .title { + padding: 0 0px; + } + :host([expanded]) .title { + display: initial; + } + .title mwc-button { + width: 100%; + } + + #sortable, + .hidden-panel { + display: none; + } + + .all-panels { + border: 2px solid black; + height: calc(100% - var(--header-height) - 132px); + position: relative; + } + .panels { + border: 1px solid blue; + /* height: calc(100% - var(--header-height)); */ + } + + .divider { + border: 1px solid pink; + /* height: calc(100% - var(--header-height)); */ + } + .utilities { + border: 1px solid red; + position: absolute; + bottom: 0px; + width: 100%; + } + .user-stuff { + border: 1px solid green; + } + `, + ]; + } + + // static get styles(): CSSResult[] { + // return [haStyleScrollbar, sidebarStyles]; + // } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-overhaul": HaSidebarOverhaul; + } +} diff --git a/src/components/ha-sidebar-panel-list.ts b/src/components/ha-sidebar-panel-list.ts new file mode 100644 index 0000000000..878c912a66 --- /dev/null +++ b/src/components/ha-sidebar-panel-list.ts @@ -0,0 +1,888 @@ +import "./ha-divider-list-item"; +import "./ha-clickable-list-item"; +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-list/mwc-list"; +import "@material/mwc-button/mwc-button"; +import "@material/mwc-icon-button"; +import { mdiCellphoneCog, mdiClose, mdiPlus, mdiViewDashboard } from "@mdi/js"; +import { + css, + CSSResult, + customElement, + eventOptions, + html, + internalProperty, + LitElement, + property, + PropertyValues, +} from "lit-element"; +import { guard } from "lit-html/directives/guard"; +import memoizeOne from "memoize-one"; +import { LocalStorage } from "../common/decorators/local-storage"; +import { compare } from "../common/string/compare"; +import { computeRTL } from "../common/util/compute_rtl"; +import { + ExternalConfig, + getExternalConfig, +} from "../external_app/external_config"; +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"; + +const SHOW_AFTER_SPACER = ["config", "developer-tools", "hassio"]; + +const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body; + +const SORT_VALUE_URL_PATHS = { + map: 1, + logbook: 2, + history: 3, + "developer-tools": 9, + hassio: 10, + config: 11, +}; + +const panelSorter = ( + reverseSort: string[], + defaultPanel: string, + a: PanelInfo, + b: PanelInfo +) => { + 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); +}; + +const defaultPanelSorter = ( + defaultPanel: string, + a: PanelInfo, + b: PanelInfo +) => { + // 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 compare(a.title!, b.title!); + } + 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 compare(a.title!, b.title!); +}; + +const computePanels = memoizeOne( + ( + panels: HomeAssistant["panels"], + defaultPanel: HomeAssistant["defaultPanel"], + panelsOrder: string[], + hiddenPanels: string[] + ): [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)); + afterSpacer.sort((a, b) => panelSorter(reverseSort, defaultPanel, a, b)); + + return [beforeSpacer, afterSpacer]; + } +); + +let Sortable; + +@customElement("ha-sidebar-panel-list") +class HaSidebarPanelList extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @property({ type: Boolean }) public alwaysExpand = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @property({ type: Boolean }) public editMode = false; + + // property used only in css + // @ts-ignore + @property({ type: Boolean, reflect: true }) public rtl = false; + + @internalProperty() private _renderEmptySortable = false; + + @internalProperty() private _externalConfig?: ExternalConfig; + + private _mouseLeaveTimeout?: number; + + private _tooltipHideTimeout?: number; + + private _recentKeydownActiveUntil = 0; + + // @ts-ignore + @LocalStorage("sidebarPanelOrder", true, { + attribute: false, + }) + private _panelOrder: string[] = []; + + // @ts-ignore + @LocalStorage("sidebarHiddenPanels", true, { + attribute: false, + }) + private _hiddenPanels: string[] = []; + + private _sortable?; + + protected render() { + if (!this.hass) { + return html``; + } + + // prettier-ignore + return html` + ${this._renderNormalPanels()} + ${this._renderUtilityPanels()} +
+ `; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if ( + changedProps.has("expanded") || + changedProps.has("narrow") || + changedProps.has("alwaysExpand") || + changedProps.has("_externalConfig") || + 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.language !== oldHass.language || + hass.states !== oldHass.states || + hass.defaultPanel !== oldHass.defaultPanel + ); + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + + if (this.hass && this.hass.auth.external) { + getExternalConfig(this.hass.auth.external).then((conf) => { + this._externalConfig = conf; + }); + } + } + + protected updated(changedProps) { + super.updated(changedProps); + if (changedProps.has("alwaysExpand")) { + 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.language !== this.hass.language) { + this.rtl = computeRTL(this.hass); + } + + 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 _renderNormalPanels() { + const [beforeSpacer] = computePanels( + this.hass.panels, + this.hass.defaultPanel, + this._panelOrder, + this._hiddenPanels + ); + + // prettier-ignore + return html` + + ${this.editMode + ? this._renderPanelsEdit(beforeSpacer) + : this._renderPanels(beforeSpacer)} + + + + + + `; + } + + 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 + ? this.hass.localize("panel.states") + : this.hass.localize(`panel.${panel.title}`) || + panel.title} + + + + `; + })} + ${this._renderSpacer()}` + : ""}`; + } + + private _renderSpacer(enabled = true) { + return enabled + ? html`` + : html``; + } + + private get _tooltip() { + return this.shadowRoot!.querySelector(".tooltip")! as HTMLDivElement; + } + + private async _activateEditMode() { + if (!Sortable) { + const [sortableImport, sortStylesImport] = await Promise.all([ + import("sortablejs/modular/sortable.core.esm"), + import("../resources/ha-sortable-style-ha-clickable"), + ]); + + const style = document.createElement("style"); + style.innerHTML = sortStylesImport.sortableStyles.cssText; + this.shadowRoot!.appendChild(style); + + Sortable = sortableImport.Sortable; + Sortable.mount(sortableImport.OnSpill); + Sortable.mount(sortableImport.AutoScroll()); + } + + await this.updateComplete; + + this._createSortable(); + } + + private _createSortable() { + this._sortable = new Sortable(this.shadowRoot!.getElementById("sortable"), { + animation: 150, + fallbackClass: "sortable-fallback", + // fallbackTolerance: 15, + dataIdAttr: "data-panel", + handle: "ha-clickable-list-item", + onSort: async () => { + this._panelOrder = this._sortable.toArray(); + }, + }); + } + + private _deactivateEditMode() { + this._sortable?.destroy(); + this._sortable = undefined; + } + + 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.expanded || + new Date().getTime() < this._recentKeydownActiveUntil + ) { + return; + } + if (this._mouseLeaveTimeout) { + clearTimeout(this._mouseLeaveTimeout); + this._mouseLeaveTimeout = undefined; + } + this._showTooltip(ev.currentTarget); + } + + private _itemMouseLeave() { + if (this._mouseLeaveTimeout) { + clearTimeout(this._mouseLeaveTimeout); + } + this._mouseLeaveTimeout = window.setTimeout(() => { + this._hideTooltip(); + }, 500); + } + + private _listboxFocusIn(ev) { + if (this.expanded || ev.target.nodeName !== "A") { + return; + } + this._showTooltip(ev.target.querySelector("ha-clickable-list-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) { + return; + } + this._hideTooltip(); + } + + private _listboxKeydown() { + this._recentKeydownActiveUntil = new Date().getTime() + 100; + } + + private _showTooltip(item) { + if (this._tooltipHideTimeout) { + clearTimeout(this._tooltipHideTimeout); + this._tooltipHideTimeout = undefined; + } + const tooltip = this._tooltip; + const listbox = this.shadowRoot!.querySelector("mwc-list")!; + let top = item.offsetTop + 11; + if (listbox.contains(item)) { + top -= listbox.scrollTop; + } + tooltip.innerHTML = item.querySelector(".item-text")!.innerHTML; + tooltip.style.display = "block"; + tooltip.style.top = `${top}px`; + tooltip.style.left = `${item.offsetLeft + item.clientWidth + 4}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 _handleExternalAppConfiguration(ev: Event) { + ev.preventDefault(); + this.hass.auth.external!.fireMessage({ + type: "config_screen/show", + }); + } + + private _isUtilityPanel(panel: PanelInfo) { + return ( + panel.component_name === "developer-tools" || + panel.component_name === "config" || + panel.component_name === "external-config" + ); + } + + private _renderPanels(panels: PanelInfo[]) { + return panels + .filter((panel) => !this._isUtilityPanel(panel)) + .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 + ) + ); + } + + 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 _renderExternalConfiguration() { + return html`${this._externalConfig && this._externalConfig.hasSettingsScreen + ? html` + + + + ${this.hass.localize("ui.sidebar.external_app_configuration")} + + + ` + : ""}`; + } + + private _renderPanel( + urlPath: string, + title: string | null, + icon?: string | null, + iconPath?: string | null + ) { + return html` + + ${iconPath + ? html`` + : html``} + + ${title} + + ${this.editMode + ? html` + + ` + : ""} + + `; + } + + static get styles(): CSSResult[] { + return [ + haStyleScrollbar, + css` + :host { + height: calc(100% + var(--header-height)); + /* position: absolute; */ + position: relative; + display: block; + overflow: hidden; + -ms-user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + border-right: 1px solid var(--divider-color); + background-color: var(--sidebar-background-color); + width: 64px; + /* height: calc(100% - var(--header-height) - 132px); + height: calc( + 100% - var(--header-height) - 132px - env(safe-area-inset-bottom) + ); */ + } + :host([expanded]) { + width: 256px; + width: calc(256px + env(safe-area-inset-left)); + } + :host([rtl]) { + border-right: 0; + border-left: 1px solid var(--divider-color); + } + + :host([rtl]) .menu { + padding-left: 8.5px; + padding-right: calc(8.5px + 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 mwc-icon-button { + color: var(--sidebar-icon-color); + } + :host([expanded]) .menu mwc-icon-button { + margin-right: 23px; + } + :host([expanded][rtl]) .menu mwc-icon-button { + margin-right: 0px; + margin-left: 23px; + } + + .title { + width: 100%; + display: none; + } + :host([narrow]) .title { + padding: 0 0px; + } + :host([expanded]) .title { + display: initial; + } + .title mwc-button { + width: 100%; + } + #sortable, + .hidden-panel { + display: none; + } + + div.panels { + height: 100%; + position: relative; + } + + ha-divider-list-item { + /* --ha-divider-height: calc(100vh - var(--header-height)); */ + } + + mwc-list.ha-scrollbar { + height: 100%; + --mdc-list-vertical-padding: 4px 0; + padding: 4px 0; + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow-x: hidden; + background: none; + margin-left: env(safe-area-inset-left); + } + + mwc-list.utility-panels { + position: absolute; + bottom: 0px; + width: 100%; + } + + :host([rtl]) mwc-list { + 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; + outline: 0; + border: 1px solid black; + } + + mwc-list-item { + box-sizing: border-box; + margin: 4px 8px; + border-radius: 4px; + width: 48px; + --mdc-list-item-graphic-margin: 16px; + --mdc-list-item-meta-size: 32px; + } + :host([expanded]) mwc-list-item { + width: 240px; + } + :host([rtl]) mwc-list-item { + padding-left: auto; + padding-right: 12px; + } + + ha-icon[slot="graphic"], + ha-svg-icon[slot="graphic"] { + color: var(--sidebar-icon-color); + } + + [slot="graphic"] { + width: 100%; + } + + .iron-selected mwc-list-item::before, + a:not(.iron-selected):focus::before { + border-radius: 4px; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ""; + transition: opacity 15ms linear; + will-change: opacity; + } + .iron-selected mwc-list-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 mwc-list-item:focus::before, + .iron-selected:focus mwc-list-item::before { + opacity: 0.2; + } + + .iron-selected mwc-list-item[pressed]:before { + opacity: 0.37; + } + + mwc-list-item span { + color: var(--sidebar-text-color); + font-weight: 500; + font-size: 14px; + width: 100%; + } + + a.iron-selected mwc-list-item ha-icon, + a.iron-selected mwc-list-item ha-svg-icon { + color: var(--sidebar-selected-icon-color); + } + + a.iron-selected .item-text { + color: var(--sidebar-selected-text-color); + } + + mwc-list-item mwc-list-item .item-text, + mwc-list-item .item-text { + display: none; + max-width: calc(100% - 56px); + } + :host([expanded]) mwc-list-item .item-text { + display: block; + } + + .divider { + padding: 10px 0; + } + .divider::before { + display: block; + height: 100px; + background-color: var(--divider-color); + } + + .spacer { + flex: 1; + pointer-events: none; + } + + .bottom-spacer { + flex: 1; + pointer-events: none; + height: 100%; + } + + .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 mwc-icon-button { + -webkit-transform: scaleX(-1); + transform: scaleX(-1); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-panel-list": HaSidebarPanelList; + } +} diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 652da7a534..98746d1506 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -1,17 +1,10 @@ +import "./ha-sidebar-panel-list"; import "./ha-clickable-list-item"; import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list"; import "@material/mwc-button/mwc-button"; import "@material/mwc-icon-button"; -import { - mdiBell, - mdiCellphoneCog, - mdiClose, - mdiMenu, - mdiMenuOpen, - mdiPlus, - mdiViewDashboard, -} from "@mdi/js"; +import { mdiBell, mdiMenu, mdiMenuOpen } from "@mdi/js"; import { css, CSSResult, @@ -24,138 +17,25 @@ import { PropertyValues, } 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"; import { fireEvent } from "../common/dom/fire_event"; import { computeDomain } from "../common/entity/compute_domain"; -import { compare } from "../common/string/compare"; import { computeRTL } from "../common/util/compute_rtl"; import { ActionHandlerDetail } from "../data/lovelace"; import { PersistentNotification, subscribeNotifications, } from "../data/persistent_notification"; -import { - ExternalConfig, - getExternalConfig, -} from "../external_app/external_config"; +import { getExternalConfig } from "../external_app/external_config"; import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; import { haStyleScrollbar } from "../resources/styles"; -import type { HomeAssistant, PanelInfo } from "../types"; +import type { HomeAssistant } from "../types"; import "./ha-icon"; import "./ha-menu-button"; import "./ha-svg-icon"; import "./user/ha-user-badge"; -const SHOW_AFTER_SPACER = ["config", "developer-tools", "hassio"]; - const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body; -const SORT_VALUE_URL_PATHS = { - map: 1, - logbook: 2, - history: 3, - "developer-tools": 9, - hassio: 10, - config: 11, -}; - -const panelSorter = ( - reverseSort: string[], - defaultPanel: string, - a: PanelInfo, - b: PanelInfo -) => { - 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); -}; - -const defaultPanelSorter = ( - defaultPanel: string, - a: PanelInfo, - b: PanelInfo -) => { - // 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 compare(a.title!, b.title!); - } - 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 compare(a.title!, b.title!); -}; - -const computePanels = memoizeOne( - ( - panels: HomeAssistant["panels"], - defaultPanel: HomeAssistant["defaultPanel"], - panelsOrder: string[], - hiddenPanels: string[] - ): [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)); - afterSpacer.sort((a, b) => panelSorter(reverseSort, defaultPanel, a, b)); - - return [beforeSpacer, afterSpacer]; - } -); - let Sortable; @customElement("ha-sidebar") @@ -170,48 +50,54 @@ class HaSidebar extends LitElement { @property({ type: Boolean }) public editMode = false; - @internalProperty() private _externalConfig?: ExternalConfig; - @internalProperty() private _notifications?: PersistentNotification[]; // property used only in css // @ts-ignore @property({ type: Boolean, reflect: true }) public rtl = false; - @internalProperty() private _renderEmptySortable = false; - private _mouseLeaveTimeout?: number; private _tooltipHideTimeout?: number; private _recentKeydownActiveUntil = 0; - // @ts-ignore - @LocalStorage("sidebarPanelOrder", true, { - attribute: false, - }) - private _panelOrder: string[] = []; - - // @ts-ignore - @LocalStorage("sidebarHiddenPanels", true, { - attribute: false, - }) - private _hiddenPanels: string[] = []; - private _sortable?; protected render() { if (!this.hass) { return html``; } + const debug = false; + + return debug + ? html` + + ` + : this.render2(); + } + + protected render2() { + if (!this.hass) { + return html``; + } // prettier-ignore return html` - ${this._renderHeader()} - ${this._renderAllPanels()} - ${this._renderUtilityPanels()} - ${this._renderNotifications()} - ${this._renderUserItem()} + ${this._renderHeader()} ${this._renderAllPanels()} + + ${this._renderNotifications()} ${this._renderUserItem()} + ${this._renderSpacer()}
`; @@ -254,9 +140,7 @@ class HaSidebar extends LitElement { super.firstUpdated(changedProps); if (this.hass && this.hass.auth.external) { - getExternalConfig(this.hass.auth.external).then((conf) => { - this._externalConfig = conf; - }); + getExternalConfig(this.hass.auth.external).then(() => {}); } subscribeNotifications(this.hass.connection, (notifications) => { this._notifications = notifications; @@ -328,80 +212,19 @@ class HaSidebar extends LitElement { } private _renderAllPanels() { - const [beforeSpacer, afterSpacer] = computePanels( - this.hass.panels, - this.hass.defaultPanel, - this._panelOrder, - this._hiddenPanels - ); - - // prettier-ignore return html` - - ${this.editMode - ? this._renderPanelsEdit(beforeSpacer) - : this._renderPanels(beforeSpacer)} - ${this._renderSpacer()} - ${this._renderPanels(afterSpacer)} - ${this._renderExternalConfiguration()} - + `; } - 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 - ? this.hass.localize("panel.states") - : this.hass.localize(`panel.${panel.title}`) || - panel.title} - - - - `; - })} - ${this._renderSpacer()}` - : ""}`; - } - - private _renderSpacer() { - return html``; + private _renderSpacer(enabled = true) { + return enabled + ? html`` + : html``; } private _renderNotifications() { @@ -473,30 +296,6 @@ class HaSidebar extends LitElement { `; } - private _renderExternalConfiguration() { - return html`${this._externalConfig && this._externalConfig.hasSettingsScreen - ? html` - - - - ${this.hass.localize("ui.sidebar.external_app_configuration")} - - - ` - : ""}`; - } - private get _tooltip() { return this.shadowRoot!.querySelector(".tooltip")! as HTMLDivElement; } @@ -537,9 +336,7 @@ class HaSidebar extends LitElement { // fallbackTolerance: 15, dataIdAttr: "data-panel", handle: "ha-clickable-list-item", - onSort: async () => { - this._panelOrder = this._sortable.toArray(); - }, + onSort: async () => {}, }); } @@ -552,38 +349,6 @@ class HaSidebar extends LitElement { 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 @@ -669,13 +434,6 @@ class HaSidebar extends LitElement { fireEvent(this, "hass-show-notifications"); } - private _handleExternalAppConfiguration(ev: Event) { - ev.preventDefault(); - this.hass.auth.external!.fireMessage({ - type: "config_screen/show", - }); - } - private _toggleSidebar(ev: CustomEvent) { if (ev.detail.action !== "tap") { return; @@ -683,118 +441,12 @@ class HaSidebar extends LitElement { fireEvent(this, "hass-toggle-menu"); } - private _isUtilityPanel(panel: PanelInfo) { - return ( - panel.component_name === "developer-tools" || - panel.component_name === "config" || - panel.component_name === "external-config" - ); - } - - private _renderPanels(panels: PanelInfo[]) { - return panels - .filter((panel) => !this._isUtilityPanel(panel)) - .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 - ) - ); - } - - 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 - ) - )} - ${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 - ) - )} - - - `; - } - - private _renderPanel( - urlPath: string, - title: string | null, - icon?: string | null, - iconPath?: string | null - ) { - return html` - - ${iconPath - ? html`` - : html``} - - ${title} - - ${this.editMode - ? html` - - ` - : ""} - - `; - } - static get styles(): CSSResult[] { return [ haStyleScrollbar, css` :host { - height: 100%; + height: calc(100% - var(--header-height)); display: block; overflow: hidden; -ms-user-select: none; @@ -869,7 +521,8 @@ class HaSidebar extends LitElement { display: none; } - mwc-list.ha-scrollbar { + /* mwc-list.ha-scrollbar { + height: 100%; --mdc-list-vertical-padding: 4px 0; padding: 4px 0; display: flex; @@ -882,7 +535,8 @@ class HaSidebar extends LitElement { overflow-x: hidden; background: none; margin-left: env(safe-area-inset-left); - } + background-color: blue; + } */ :host([rtl]) mwc-list { margin-left: initial;