Allow to move and hide sidebar items (#6755)

This commit is contained in:
Bram Kragten 2020-09-01 23:28:03 +02:00 committed by GitHub
parent 5292119e6e
commit 3bd2e8dbf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 474 additions and 88 deletions

View File

@ -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",

View File

@ -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,
},
};
};
};

View File

@ -0,0 +1,77 @@
import { html } from "lit-element";
export const sortStyles = html`
<style>
#sortable a:nth-of-type(2n) paper-icon-item {
animation-name: keyframes1;
animation-iteration-count: infinite;
transform-origin: 50% 10%;
animation-delay: -0.75s;
animation-duration: 0.25s;
}
#sortable a:nth-of-type(2n-1) paper-icon-item {
animation-name: keyframes2;
animation-iteration-count: infinite;
animation-direction: alternate;
transform-origin: 30% 5%;
animation-delay: -0.5s;
animation-duration: 0.33s;
}
#sortable {
outline: none;
display: flex;
flex-direction: column;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-fallback {
opacity: 0;
}
@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;
}
}
.hide-panel {
display: none;
position: absolute;
right: 8px;
}
:host([expanded]) .hide-panel {
display: inline-flex;
}
paper-icon-item.hidden-panel,
paper-icon-item.hidden-panel span,
paper-icon-item.hidden-panel ha-icon[slot="item-icon"] {
color: var(--secondary-text-color);
cursor: pointer;
}
</style>
`;

View File

@ -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 : ""}
<div class="menu">
${!this.narrow
? html`
@ -170,7 +239,13 @@ class HaSidebar extends LitElement {
</mwc-icon-button>
`
: ""}
<span class="title">Home Assistant</span>
<div class="title">
${this._editMode
? html`<mwc-button outlined @click=${this._closeEditMode}>
DONE
</mwc-button>`
: "Home Assistant"}
</div>
</div>
<paper-listbox
attr-for-selected="data-panel"
@ -179,31 +254,53 @@ class HaSidebar extends LitElement {
@focusout=${this._listboxFocusOut}
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: !this._editMode,
disabled: this._editMode,
})}
>
${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`<div id="sortable">
${guard([this._hiddenPanels, this._renderEmptySortable], () =>
this._renderEmptySortable
? ""
: this._renderPanels(beforeSpacer)
)}
</div>`
: this._renderPanels(beforeSpacer)}
<div class="spacer" disabled></div>
${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`<paper-icon-item
@click=${this._unhidePanel}
class="hidden-panel"
>
<ha-icon
slot="item-icon"
.icon=${panel.url_path === "lovelace"
? "mdi:view-dashboard"
: panel.icon}
></ha-icon>
<span class="item-text"
>${panel.url_path === "lovelace"
? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) ||
panel.title}</span
>
<ha-svg-icon
class="hide-panel"
.panel=${url}
.path=${mdiPlus}
></ha-svg-icon>
</paper-icon-item>`;
})}
<div class="spacer" disabled></div>
`
: ""}
${this._renderPanels(afterSpacer)}
${this._externalConfig && this._externalConfig.hasSettingsScreen
? html`
<a
@ -295,7 +392,9 @@ class HaSidebar extends LitElement {
changedProps.has("narrow") ||
changedProps.has("alwaysExpand") ||
changedProps.has("_externalConfig") ||
changedProps.has("_notifications")
changedProps.has("_notifications") ||
changedProps.has("_editMode") ||
changedProps.has("_renderEmptySortable")
) {
return true;
}
@ -361,6 +460,74 @@ class HaSidebar extends LitElement {
return this.shadowRoot!.querySelector(".tooltip")! as HTMLDivElement;
}
private async _handleAction(ev: CustomEvent<ActionHandlerDetail>) {
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 {
></ha-svg-icon>`
: html`<ha-icon slot="item-icon" .icon=${icon}></ha-icon>`}
<span class="item-text">${title}</span>
${this._editMode
? html`<ha-svg-icon
class="hide-panel"
.panel=${urlPath}
@click=${this._hidePanel}
.path=${mdiClose}
></ha-svg-icon>`
: ""}
</paper-icon-item>
</a>
`;
@ -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;

View File

@ -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<ActionHandlerDetail>;

View File

@ -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) {

View File

@ -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"