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"