From 3bd2e8dbf539ef92390a6a420818aac18902c996 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 1 Sep 2020 23:28:03 +0200 Subject: [PATCH] Allow to move and hide sidebar items (#6755) --- package.json | 1 + src/common/decorators/local-storage.ts | 68 ++++ src/components/ha-sidebar-sort-styles.ts | 77 +++++ src/components/ha-sidebar.ts | 300 ++++++++++++++---- src/data/lovelace.ts | 3 +- .../directives/action-handler-directive.ts | 108 +++++-- yarn.lock | 5 + 7 files changed, 474 insertions(+), 88 deletions(-) create mode 100644 src/common/decorators/local-storage.ts create mode 100644 src/components/ha-sidebar-sort-styles.ts diff --git a/package.json b/package.json index 5be452e00b..1410d6bf3b 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "regenerator-runtime": "^0.13.2", "resize-observer-polyfill": "^1.5.1", "roboto-fontface": "^0.10.0", + "sortablejs": "^1.10.2", "superstruct": "^0.10.12", "unfetch": "^4.1.0", "vue": "^2.6.11", diff --git a/src/common/decorators/local-storage.ts b/src/common/decorators/local-storage.ts new file mode 100644 index 0000000000..99cdebdf3d --- /dev/null +++ b/src/common/decorators/local-storage.ts @@ -0,0 +1,68 @@ +import type { ClassElement } from "../../types"; + +class Storage { + private _storage: any = {}; + + public addFromStorage(storageKey: any): void { + if (!this._storage[storageKey]) { + const data = window.localStorage.getItem(storageKey); + if (data) { + this._storage[storageKey] = JSON.parse(data); + } + } + } + + public hasKey(storageKey: string): any { + return storageKey in this._storage; + } + + public getValue(storageKey: string): any { + return this._storage[storageKey]; + } + + public setValue(storageKey: string, value: any): any { + this._storage[storageKey] = value; + try { + window.localStorage.setItem(storageKey, JSON.stringify(value)); + } catch (err) { + // Safari in private mode doesn't allow localstorage + } + } +} + +const storage = new Storage(); + +export const LocalStorage = (key?: string) => { + return (element: ClassElement, propName: string) => { + const storageKey = key || propName; + const initVal = element.initializer ? element.initializer() : undefined; + + storage.addFromStorage(storageKey); + + const getValue = (): any => { + return storage.hasKey(storageKey) + ? storage.getValue(storageKey) + : initVal; + }; + + const setValue = (val: any) => { + storage.setValue(storageKey, val); + }; + + return { + kind: "method", + placement: "own", + key: element.key, + descriptor: { + set(value) { + setValue(value); + }, + get() { + return getValue(); + }, + enumerable: true, + configurable: true, + }, + }; + }; +}; diff --git a/src/components/ha-sidebar-sort-styles.ts b/src/components/ha-sidebar-sort-styles.ts new file mode 100644 index 0000000000..b87b3c92ba --- /dev/null +++ b/src/components/ha-sidebar-sort-styles.ts @@ -0,0 +1,77 @@ +import { html } from "lit-element"; + +export const sortStyles = html` + +`; diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 4454ac122d..575cb4cc2f 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -1,9 +1,12 @@ +import "@material/mwc-button/mwc-button"; import "@material/mwc-icon-button"; import { mdiBell, mdiCellphoneCog, - mdiMenuOpen, + mdiClose, mdiMenu, + mdiMenuOpen, + mdiPlus, mdiViewDashboard, } from "@mdi/js"; import "@polymer/paper-item/paper-icon-item"; @@ -13,20 +16,24 @@ import "@polymer/paper-listbox/paper-listbox"; import { css, CSSResult, + customElement, eventOptions, html, - customElement, + internalProperty, LitElement, property, - internalProperty, PropertyValues, + TemplateResult, } 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 { getDefaultPanel } from "../data/panel"; +import { ActionHandlerDetail } from "../data/lovelace"; import { PersistentNotification, subscribeNotifications, @@ -35,6 +42,7 @@ import { ExternalConfig, getExternalConfig, } from "../external_app/external_config"; +import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; import type { HomeAssistant, PanelInfo } from "../types"; import "./ha-icon"; import "./ha-menu-button"; @@ -54,11 +62,39 @@ const SORT_VALUE_URL_PATHS = { config: 11, }; -const panelSorter = (a: PanelInfo, b: PanelInfo) => { +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!); } @@ -85,30 +121,45 @@ const panelSorter = (a: PanelInfo, b: PanelInfo) => { return compare(a.title!, b.title!); }; -const computePanels = (hass: HomeAssistant): [PanelInfo[], PanelInfo[]] => { - const panels = hass.panels; - if (!panels) { - return [[], []]; - } - - const beforeSpacer: PanelInfo[] = []; - const afterSpacer: PanelInfo[] = []; - - Object.values(panels).forEach((panel) => { - if (!panel.title || panel.url_path === hass.defaultPanel) { - return; +const computePanels = memoizeOne( + ( + panels: HomeAssistant["panels"], + defaultPanel: HomeAssistant["defaultPanel"], + panelsOrder: string[], + hiddenPanels: string[] + ): [PanelInfo[], PanelInfo[]] => { + if (!panels) { + return [[], []]; } - (SHOW_AFTER_SPACER.includes(panel.url_path) - ? afterSpacer - : beforeSpacer - ).push(panel); - }); - beforeSpacer.sort(panelSorter); - afterSpacer.sort(panelSorter); + const beforeSpacer: PanelInfo[] = []; + const afterSpacer: PanelInfo[] = []; - return [beforeSpacer, afterSpacer]; -}; + 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; + +let sortStyles: TemplateResult; @customElement("ha-sidebar") class HaSidebar extends LitElement { @@ -124,16 +175,30 @@ class HaSidebar extends LitElement { @internalProperty() private _notifications?: PersistentNotification[]; + @internalProperty() private _editMode = false; + // 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") + private _panelOrder: string[] = []; + + // @ts-ignore + @LocalStorage("sidebarHiddenPanels") + private _hiddenPanels: string[] = []; + + private _sortable?; + protected render() { const hass = this.hass; @@ -141,7 +206,12 @@ class HaSidebar extends LitElement { return html``; } - const [beforeSpacer, afterSpacer] = computePanels(hass); + const [beforeSpacer, afterSpacer] = computePanels( + hass.panels, + hass.defaultPanel, + this._panelOrder, + this._hiddenPanels + ); let notificationCount = this._notifications ? this._notifications.length @@ -152,9 +222,8 @@ class HaSidebar extends LitElement { } } - const defaultPanel = getDefaultPanel(hass); - return html` + ${this._editMode ? sortStyles : ""} - ${this._renderPanel( - defaultPanel.url_path, - defaultPanel.title || hass.localize("panel.states"), - defaultPanel.icon, - !defaultPanel.icon ? mdiViewDashboard : undefined - )} - ${beforeSpacer.map((panel) => - this._renderPanel( - panel.url_path, - hass.localize(`panel.${panel.title}`) || panel.title, - panel.icon, - undefined - ) - )} + ${this._editMode + ? html`
+ ${guard([this._hiddenPanels, this._renderEmptySortable], () => + this._renderEmptySortable + ? "" + : this._renderPanels(beforeSpacer) + )} +
` + : this._renderPanels(beforeSpacer)}
- - ${afterSpacer.map((panel) => - this._renderPanel( - panel.url_path, - hass.localize(`panel.${panel.title}`) || panel.title, - panel.icon, - undefined - ) - )} + ${this._editMode && this._hiddenPanels.length + ? html` + ${this._hiddenPanels.map((url) => { + const panel = this.hass.panels[url]; + return html` + + ${panel.url_path === "lovelace" + ? hass.localize("panel.states") + : hass.localize(`panel.${panel.title}`) || + panel.title} + + `; + })} +
+ ` + : ""} + ${this._renderPanels(afterSpacer)} ${this._externalConfig && this._externalConfig.hasSettingsScreen ? html` ) { + if (ev.detail.action !== "hold") { + return; + } + + if (!Sortable) { + const [sortableImport, sortStylesImport] = await Promise.all([ + import("sortablejs/modular/sortable.core.esm"), + import("./ha-sidebar-sort-styles"), + ]); + + sortStyles = sortStylesImport.sortStyles; + + Sortable = sortableImport.Sortable; + Sortable.mount(sortableImport.OnSpill); + Sortable.mount(sortableImport.AutoScroll()); + } + this._editMode = true; + + await this.updateComplete; + + this._createSortable(); + } + + private _createSortable() { + this._sortable = new Sortable(this.shadowRoot!.getElementById("sortable"), { + animation: 150, + fallbackClass: "sortable-fallback", + dataIdAttr: "data-panel", + onSort: async () => { + this._panelOrder = this._sortable.toArray(); + }, + }); + } + + private _closeEditMode() { + this._sortable?.destroy(); + this._sortable = undefined; + this._editMode = false; + } + + private async _hidePanel(ev: Event) { + ev.preventDefault(); + const panel = (ev.target 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; + this._renderEmptySortable = false; + } + + private async _unhidePanel(ev: Event) { + ev.preventDefault(); + const index = this._hiddenPanels.indexOf((ev.target as any).panel); + if (index < 0) { + return; + } + this._hiddenPanels.splice(index, 1); + // Make a copy for Memoize + this._hiddenPanels = [...this._hiddenPanels]; + this._renderEmptySortable = true; + await this.updateComplete; + 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 @@ -457,6 +624,19 @@ class HaSidebar extends LitElement { fireEvent(this, "hass-toggle-menu"); } + private _renderPanels(panels: PanelInfo[]) { + return panels.map((panel) => + this._renderPanel( + panel.url_path, + panel.url_path === "lovelace" + ? this.hass.localize("panel.states") + : this.hass.localize(`panel.${panel.title}`) || panel.title, + panel.url_path === "lovelace" ? undefined : panel.icon, + panel.url_path === "lovelace" ? mdiViewDashboard : undefined + ) + ); + } + private _renderPanel( urlPath: string, title: string | null, @@ -480,6 +660,14 @@ class HaSidebar extends LitElement { >` : html``} ${title} + ${this._editMode + ? html`` + : ""} `; @@ -542,11 +730,15 @@ class HaSidebar extends LitElement { } .title { + width: 100%; display: none; } :host([expanded]) .title { display: initial; } + .title mwc-button { + width: 100%; + } paper-listbox::-webkit-scrollbar { width: 0.4rem; diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 234825d018..4431730218 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -318,10 +318,11 @@ export interface WindowWithLovelaceProm extends Window { export interface ActionHandlerOptions { hasHold?: boolean; hasDoubleClick?: boolean; + disabled?: boolean; } export interface ActionHandlerDetail { - action: string; + action: "hold" | "tap" | "double_tap"; } export type ActionHandlerEvent = HASSDomEvent; diff --git a/src/panels/lovelace/common/directives/action-handler-directive.ts b/src/panels/lovelace/common/directives/action-handler-directive.ts index badd2b0f91..1a837b5743 100644 --- a/src/panels/lovelace/common/directives/action-handler-directive.ts +++ b/src/panels/lovelace/common/directives/action-handler-directive.ts @@ -2,6 +2,7 @@ import "@material/mwc-ripple"; import type { Ripple } from "@material/mwc-ripple"; import { directive, PropertyPart } from "lit-html"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { deepEqual } from "../../../../common/util/deep-equal"; import { ActionHandlerDetail, ActionHandlerOptions, @@ -17,10 +18,18 @@ interface ActionHandler extends HTMLElement { bind(element: Element, options): void; } interface ActionHandlerElement extends HTMLElement { - actionHandler?: boolean; + actionHandler?: { + options: ActionHandlerOptions; + start?: (ev: Event) => void; + end?: (ev: Event) => void; + handleEnter?: (ev: KeyboardEvent) => void; + }; } declare global { + interface HTMLElementTagNameMap { + "action-handler": ActionHandler; + } interface HASSDomEvents { action: ActionHandlerDetail; } @@ -76,26 +85,45 @@ class ActionHandler extends HTMLElement implements ActionHandler { }); } - public bind(element: ActionHandlerElement, options) { - if (element.actionHandler) { + public bind(element: ActionHandlerElement, options: ActionHandlerOptions) { + if ( + element.actionHandler && + deepEqual(options, element.actionHandler.options) + ) { return; } - element.actionHandler = true; - element.addEventListener("contextmenu", (ev: Event) => { - const e = ev || window.event; - if (e.preventDefault) { - e.preventDefault(); - } - if (e.stopPropagation) { - e.stopPropagation(); - } - e.cancelBubble = true; - e.returnValue = false; - return false; - }); + if (element.actionHandler) { + element.removeEventListener("touchstart", element.actionHandler.start!); + element.removeEventListener("touchend", element.actionHandler.end!); + element.removeEventListener("touchcancel", element.actionHandler.end!); - const start = (ev: Event) => { + element.removeEventListener("mousedown", element.actionHandler.start!); + element.removeEventListener("click", element.actionHandler.end!); + + element.removeEventListener("keyup", element.actionHandler.handleEnter!); + } else { + element.addEventListener("contextmenu", (ev: Event) => { + const e = ev || window.event; + if (e.preventDefault) { + e.preventDefault(); + } + if (e.stopPropagation) { + e.stopPropagation(); + } + e.cancelBubble = true; + e.returnValue = false; + return false; + }); + } + + element.actionHandler = { options }; + + if (options.disabled) { + return; + } + + element.actionHandler.start = (ev: Event) => { this.held = false; let x; let y; @@ -107,13 +135,19 @@ class ActionHandler extends HTMLElement implements ActionHandler { y = (ev as MouseEvent).pageY; } - this.timer = window.setTimeout(() => { - this.startAnimation(x, y); - this.held = true; - }, this.holdTime); + if (options.hasHold) { + this.timer = window.setTimeout(() => { + this.startAnimation(x, y); + this.held = true; + }, this.holdTime); + } }; - const end = (ev: Event) => { + element.actionHandler.end = (ev: Event) => { + // Don't respond on our own generated click + if (!ev.isTrusted) { + return; + } // Prevent mouse event if touch event ev.preventDefault(); if ( @@ -122,9 +156,11 @@ class ActionHandler extends HTMLElement implements ActionHandler { ) { return; } - clearTimeout(this.timer); - this.stopAnimation(); - this.timer = undefined; + if (options.hasHold) { + clearTimeout(this.timer); + this.stopAnimation(); + this.timer = undefined; + } if (this.held) { fireEvent(element, "action", { action: "hold" }); } else if (options.hasDoubleClick) { @@ -143,24 +179,30 @@ class ActionHandler extends HTMLElement implements ActionHandler { } } else { fireEvent(element, "action", { action: "tap" }); + // Fire the click we prevented the action for + (ev.target as HTMLElement)?.click(); } }; - const handleEnter = (ev: KeyboardEvent) => { + element.actionHandler.handleEnter = (ev: KeyboardEvent) => { if (ev.keyCode !== 13) { return; } - end(ev); + (ev.currentTarget as ActionHandlerElement).actionHandler!.end!(ev); }; - element.addEventListener("touchstart", start, { passive: true }); - element.addEventListener("touchend", end); - element.addEventListener("touchcancel", end); + element.addEventListener("touchstart", element.actionHandler.start, { + passive: true, + }); + element.addEventListener("touchend", element.actionHandler.end); + element.addEventListener("touchcancel", element.actionHandler.end); - element.addEventListener("mousedown", start, { passive: true }); - element.addEventListener("click", end); + element.addEventListener("mousedown", element.actionHandler.start, { + passive: true, + }); + element.addEventListener("click", element.actionHandler.end); - element.addEventListener("keyup", handleEnter); + element.addEventListener("keyup", element.actionHandler.handleEnter); } private startAnimation(x: number, y: number) { diff --git a/yarn.lock b/yarn.lock index 83cd0d1794..8b8451ef9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10975,6 +10975,11 @@ sockjs@0.3.19: faye-websocket "^0.10.0" uuid "^3.0.1" +sortablejs@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.2.tgz#6e40364d913f98b85a14f6678f92b5c1221f5290" + integrity sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A== + source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"