diff --git a/src/components/ha-sidebar-edit-panels.ts b/src/components/ha-sidebar-edit-panels.ts new file mode 100644 index 000000000000..7fe9df43c715 --- /dev/null +++ b/src/components/ha-sidebar-edit-panels.ts @@ -0,0 +1,187 @@ +import { mdiClose, mdiPlus } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import type { SortableInstance } from "../resources/sortable"; +import { HomeAssistant } from "../types"; +import "./ha-icon"; +import "./ha-icon-button"; +import "./ha-svg-icon"; + +const styles = css` + :host { + display: flex; + flex-direction: column; + padding: 0 12px; + } + .panel { + --rgb-text: var(--rgb-sidebar-text-color); + background-color: transparent; + color: rgb(var(--rgb-text)); + font-family: inherit; + border: none; + cursor: pointer; + width: 100%; + font-weight: 500; + font-size: 14px; + line-height: 20px; + + box-sizing: border-box; + display: flex; + align-items: center; + padding: 0 16px; + border-radius: var(--sidebar-item-radius, 25px); + height: 50px; + } + .panel > .icon { + display: flex; + width: 36px; + text-align: left; + } + .panel > ha-icon-button { + margin-left: auto; + margin-right: -12px; + margin-inline: auto -12px; + } + .panel:hover { + color: rgb(var(--rgb-text)); + background-color: rgba(var(--rgb-text), 0.08); + } + .panel:focus-visible, + .panel:active { + color: rgb(var(--rgb-text)); + background-color: rgba(var(--rgb-text), 0.12); + } + #sortable { + overflow: visible; + } + #sortable .panel { + cursor: grab; + } + .sortable-fallback { + display: none; + } + .sortable-ghost { + opacity: 0.4; + } + #sortable .panel:nth-child(even) { + animation: keyframes1 infinite 0.37s; + transform-origin: 50% 10%; + } + #sortable .panel:nth-child(odd) { + animation: keyframes2 infinite alternate 0.5s 0.15s; + transform-origin: 30% 5%; + } + @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; + } + } +`; + +@customElement("ha-sidebar-edit-panels") +class HaSidebarEditPanels extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public panels: any[] = []; + + @property() public hiddenPanels: any[] = []; + + private _sortable?: SortableInstance; + + protected render() { + const renderPanel = (panel) => + html`
+ + ${panel.icon + ? html`` + : html``} + + ${panel.name} + +
`; + const renderHiddenPanel = (panel) => + html``; + return html`
${this.panels.map(renderPanel)}
+ ${this.hiddenPanels.map(renderHiddenPanel)}`; + } + + protected async firstUpdated() { + const { default: Sortable } = await import("../resources/sortable"); + this._sortable = new Sortable( + this.shadowRoot!.getElementById("sortable")!, + { + animation: 150, + dataIdAttr: "data-panel", + handle: "div", + onSort: () => { + fireEvent(this, "panel-reorder", this._sortable!.toArray()); + }, + } + ); + } + + private _hidePanel(ev: CustomEvent) { + const panel = (ev.target as HTMLElement) + .closest("[data-panel]") + ?.getAttribute("data-panel"); + if (!panel) return; + fireEvent(this, "panel-hide", panel); + } + + private _showPanel(ev: CustomEvent) { + const panel = (ev.target as HTMLElement) + .closest("[data-panel]") + ?.getAttribute("data-panel"); + if (!panel) return; + fireEvent(this, "panel-show", panel); + } + + static styles = styles; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-edit-panels": HaSidebarEditPanels; + } + interface HASSDomEvents { + "panel-reorder": string[]; + "panel-hide": string; + "panel-show": string; + } +} diff --git a/src/components/ha-sidebar-panel-config.ts b/src/components/ha-sidebar-panel-config.ts new file mode 100644 index 000000000000..addfdaf9bb38 --- /dev/null +++ b/src/components/ha-sidebar-panel-config.ts @@ -0,0 +1,90 @@ +import "@material/mwc-button/mwc-button"; +import { mdiCog } from "@mdi/js"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { css, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { throttle } from "../common/util/throttle"; +import { subscribeRepairsIssueRegistry } from "../data/repairs"; +import { updateCanInstall, UpdateEntity } from "../data/update"; +import { haStyleSidebarItem } from "../resources/styles"; +import { HomeAssistant } from "../types"; +import "./ha-svg-icon"; +import { keydown, keyup } from "../resources/button-handlers"; + +const styles = css` + .item.expanded { + width: 100%; + } +`; + +@customElement("ha-sidebar-panel-config") +class HaSidebarPanelConfig extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public name = ""; + + @property({ type: Boolean }) public expanded = false; + + @property({ type: Boolean }) public selected = false; + + @state() private _updatesCount = 0; + + @state() private _issuesCount = 0; + + protected render() { + const notices = this._updatesCount + this._issuesCount; + return html` +
+ + + ${!this.expanded && notices > 0 + ? html`${notices}` + : ""} + + ${this.name} + ${this.expanded && notices > 0 + ? html`${notices}` + : ""} +
`; + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + this._checkUpdates(); + } + + private _checkUpdates = throttle(() => { + this._updatesCount = Object.keys(this.hass.states).filter( + (e) => + e.startsWith("update.") && + updateCanInstall(this.hass.states[e] as UpdateEntity) + ).length; + }, 5000); + + public hassSubscribe(): UnsubscribeFunc[] { + return this.hass.user?.is_admin + ? [ + subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => { + this._issuesCount = repairs.issues.filter( + (issue) => !issue.ignored + ).length; + }), + ] + : []; + } + + static styles = [haStyleSidebarItem, styles]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-panel-config": HaSidebarPanelConfig; + } +} diff --git a/src/components/ha-sidebar-panel-ext-config.ts b/src/components/ha-sidebar-panel-ext-config.ts new file mode 100644 index 000000000000..e6f8443476d8 --- /dev/null +++ b/src/components/ha-sidebar-panel-ext-config.ts @@ -0,0 +1,51 @@ +import "@material/mwc-button/mwc-button"; +import { mdiCellphoneCog } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { haStyleSidebarItem } from "../resources/styles"; +import "./ha-icon"; +import "./ha-svg-icon"; +import { HomeAssistant } from "../types"; + +const styles = css` + .item { + width: 100%; + } +`; + +@customElement("ha-sidebar-panel-ext-config") +class HaSidebarPanelExtConfig extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public name = ""; + + @property({ type: Boolean }) public expanded = false; + + protected render() { + return html``; + } + + private _showConfig() { + this.hass.auth.external!.fireMessage({ + type: "config_screen/show", + }); + } + + static styles = [haStyleSidebarItem, styles]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-panel-ext-config": HaSidebarPanelExtConfig; + } +} diff --git a/src/components/ha-sidebar-panel-notifications.ts b/src/components/ha-sidebar-panel-notifications.ts new file mode 100644 index 000000000000..564f29f9fc2a --- /dev/null +++ b/src/components/ha-sidebar-panel-notifications.ts @@ -0,0 +1,70 @@ +import "@material/mwc-button/mwc-button"; +import { mdiBell } from "@mdi/js"; +import { css, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { + PersistentNotification, + subscribeNotifications, +} from "../data/persistent_notification"; +import { haStyleSidebarItem } from "../resources/styles"; +import { HomeAssistant } from "../types"; +import "./ha-svg-icon"; + +const styles = css` + .item { + width: 100%; + } +`; + +@customElement("ha-sidebar-panel-notifications") +class HaSidebarPanelNotifications extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public expanded = false; + + @state() private _notifications?: PersistentNotification[]; + + protected render() { + const notificationCount = this._notifications + ? this._notifications.length + : 0; + return html``; + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + subscribeNotifications(this.hass.connection, (notifications) => { + this._notifications = notifications; + }); + } + + private _showNotifications() { + fireEvent(this, "hass-show-notifications"); + } + + static styles = [haStyleSidebarItem, styles]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-panel-notifications": HaSidebarPanelNotifications; + } +} diff --git a/src/components/ha-sidebar-panel-user.ts b/src/components/ha-sidebar-panel-user.ts new file mode 100644 index 000000000000..562758ab4454 --- /dev/null +++ b/src/components/ha-sidebar-panel-user.ts @@ -0,0 +1,58 @@ +import "@material/mwc-button/mwc-button"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { haStyleSidebarItem } from "../resources/styles"; +import { HomeAssistant } from "../types"; +import "./user/ha-user-badge"; +import { keydown, keyup } from "../resources/button-handlers"; + +const styles = css` + .icon { + height: 24px; + } + .user-icon { + display: inline-flex; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) scale(0.8); + } +`; + +@customElement("ha-sidebar-panel-user") +class HaSidebarPanelUser extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public expanded = false; + + @property({ type: Boolean }) public selected = false; + + protected render() { + return html` +
+ + + + + + ${this.hass.user ? this.hass.user.name : ""} +
`; + } + + static styles = [haStyleSidebarItem, styles]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-panel-user": HaSidebarPanelUser; + } +} diff --git a/src/components/ha-sidebar-panel.ts b/src/components/ha-sidebar-panel.ts new file mode 100644 index 000000000000..3347826b7d34 --- /dev/null +++ b/src/components/ha-sidebar-panel.ts @@ -0,0 +1,58 @@ +import "@material/mwc-button/mwc-button"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { haStyleSidebarItem } from "../resources/styles"; +import "./ha-svg-icon"; +import "./ha-icon"; +import { keydown, keyup } from "../resources/button-handlers"; + +const styles = css` + .item.expanded { + width: 100%; + } + .count { + margin-left: auto; + } +`; + +@customElement("ha-sidebar-panel") +class HaSidebarPanel extends LitElement { + @property() public path = ""; + + @property() public name = ""; + + @property() public icon = ""; + + @property() public iconPath = ""; + + @property({ type: Boolean }) public expanded = false; + + @property({ type: Boolean }) public selected = false; + + protected render() { + return html` +
+ + ${this.iconPath + ? html`` + : html``} + + ${this.name} +
`; + } + + static styles = [haStyleSidebarItem, styles]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-panel": HaSidebarPanel; + } +} diff --git a/src/components/ha-sidebar-panels.ts b/src/components/ha-sidebar-panels.ts new file mode 100644 index 000000000000..0c5490f8b2f1 --- /dev/null +++ b/src/components/ha-sidebar-panels.ts @@ -0,0 +1,342 @@ +import "@material/mwc-button/mwc-button"; +import { + mdiCalendar, + mdiChartBox, + mdiClipboardList, + mdiFormatListBulletedType, + mdiHammer, + mdiLightningBolt, + mdiPlayBoxMultiple, + mdiTooltipAccount, + mdiViewDashboard, +} from "@mdi/js"; +import { LitElement, PropertyValues, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { storage } from "../common/decorators/storage"; +import { fireEvent } from "../common/dom/fire_event"; +import { stringCompare } from "../common/string/compare"; +import { HomeAssistant, PanelInfo } from "../types"; +import "./ha-sidebar-edit-panels"; +import "./ha-sidebar-panel"; +import "./ha-sidebar-panel-config"; +import "./ha-sidebar-panel-ext-config"; + +const styles = css` + :host { + display: flex; + flex-direction: column; + flex-grow: 1; + } + .spacer { + flex: 1; + } +`; + +const SHOW_AFTER_SPACER = ["config", "developer-tools"]; +const SORT_VALUE_URL_PATHS = { + energy: 1, + map: 2, + logbook: 3, + history: 4, + "developer-tools": 9, + config: 11, +}; +const PANEL_ICONS = { + calendar: mdiCalendar, + "developer-tools": mdiHammer, + energy: mdiLightningBolt, + history: mdiChartBox, + logbook: mdiFormatListBulletedType, + lovelace: mdiViewDashboard, + map: mdiTooltipAccount, + "media-browser": mdiPlayBoxMultiple, + todo: mdiClipboardList, +}; +const panelSorter = ( + a: PanelInfo, + b: PanelInfo, + panelOrder: string[], + defaultPanel: string, + language: string +) => { + const indexA = panelOrder.indexOf(a.url_path); + const indexB = panelOrder.indexOf(b.url_path); + const order = indexA - indexB; + if (order) return order; + return defaultPanelSorter(defaultPanel, a, b, language); +}; +const defaultPanelSorter = ( + defaultPanel: string, + a: PanelInfo, + b: PanelInfo, + language: string +) => { + if (a.url_path === defaultPanel) return -1; + if (b.url_path === defaultPanel) return 1; + + const aLovelace = a.component_name === "lovelace"; + const bLovelace = b.component_name === "lovelace"; + + if (aLovelace && bLovelace) + return stringCompare(a.title!, b.title!, language); + if (aLovelace) 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; + + return stringCompare(a.title!, b.title!, language); +}; +const _computePanels = ( + panels: HomeAssistant["panels"], + defaultPanel: HomeAssistant["defaultPanel"], + panelsOrder: string[], + hiddenPanels: string[], + locale: HomeAssistant["locale"] +): [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); + }); + + beforeSpacer.sort((a, b) => + panelSorter(a, b, panelsOrder, defaultPanel, locale.language) + ); + afterSpacer.sort((a, b) => + panelSorter(a, b, panelsOrder, defaultPanel, locale.language) + ); + return [beforeSpacer, afterSpacer]; +}; +const computePanels = memoizeOne(_computePanels); + +@customElement("ha-sidebar-panels") +class HaSidebarPanels extends LitElement { + static shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public editMode = false; + + @property({ type: Boolean }) public expanded = false; + + @property({ type: String }) public currentPanel = ""; + + @storage({ + key: "sidebarPanelOrder", + state: true, + subscribe: true, + }) + private _panelOrder: string[] = []; + + @storage({ + key: "sidebarHiddenPanels", + state: true, + subscribe: true, + }) + private _hiddenPanels: string[] = []; + + private searchKeys = ""; + + private searchKeysUpdated = 0; + + constructor() { + super(); + const getCurrentFocus = () => { + let activeItem = this.shadowRoot?.activeElement; + activeItem = activeItem?.shadowRoot?.activeElement; + if (!(activeItem instanceof HTMLElement)) return null; + + const items = this._listItems(); + return { items, index: items.indexOf(activeItem) }; + }; + this.addEventListener("keydown", (e) => { + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + const focus = getCurrentFocus(); + if (!focus) return; + const next = e.key === "ArrowUp" ? focus.index - 1 : focus.index + 1; + if (next >= 0 && next < focus.items.length) { + e.preventDefault(); + const item = focus.items[next]; + item.focus(); + } + }); + this.addEventListener("keydown", (e) => { + if (e.key.length > 1) return; + if (Date.now() - this.searchKeysUpdated > 500) { + this.searchKeys = ""; + } + this.searchKeys += e.key.toLowerCase(); + this.searchKeysUpdated = Date.now(); + + const items = this._listItems(); + const item = items.find((i) => { + const name = i.getAttribute("aria-label"); + return name && name.toLowerCase().startsWith(this.searchKeys); + }); + if (item) { + item.focus(); + } + }); + } + + protected render() { + const [beforeSpacer, afterSpacer] = computePanels( + this.hass.panels, + this.hass.defaultPanel, + this._panelOrder, + this._hiddenPanels, + this.hass.locale + ); + const getName = (panel: PanelInfo) => + panel.url_path === this.hass.defaultPanel + ? panel.title || this.hass.localize("panel.states") + : this.hass.localize(`panel.${panel.title}`) || panel.title || ""; + const getIconPath = (panel: PanelInfo) => + panel.url_path === this.hass.defaultPanel && !panel.icon + ? PANEL_ICONS.lovelace + : panel.url_path in PANEL_ICONS + ? PANEL_ICONS[panel.url_path] + : undefined; + const renderPanel = (panel: PanelInfo) => + panel.url_path === "config" + ? html`` + : html``; + return [ + this.editMode + ? html` ({ + url_path: p.url_path, + name: getName(p), + icon: p.icon, + iconPath: getIconPath(p), + }))} + .hiddenPanels=${this._hiddenPanels.map((p) => { + const panel = this.hass.panels[p]; + if (!panel) return {}; + return { + url_path: p, + name: getName(panel), + icon: panel.icon, + iconPath: getIconPath(panel), + }; + })} + @panel-reorder=${this._panelReorder} + @panel-hide=${this._panelHide} + @panel-show=${this._panelShow} + >` + : html`${beforeSpacer.map(renderPanel)}`, + html`
`, + ...afterSpacer.map(renderPanel), + ...(!this.hass.user?.is_admin && + this.hass.auth.external?.config.hasSettingsScreen + ? [ + html``, + ] + : []), + ]; + } + + protected shouldUpdate(changedProps: PropertyValues) { + const nonOrderProps = [...changedProps.keys()].filter( + (p) => p !== "_panelOrder" + ); + return nonOrderProps.length > 0; + } + + private _panelOver(ev: CustomEvent) { + fireEvent(this, "panel-hover", ev.target as HTMLElement); + } + + private _panelLeave() { + fireEvent(this, "panel-leave"); + } + + private _panelReorder(ev: CustomEvent) { + this._panelOrder = ev.detail; + } + + private _panelHide(ev: CustomEvent) { + this._hiddenPanels = [...this._hiddenPanels, ev.detail]; + } + + private _panelShow(ev: CustomEvent) { + this._hiddenPanels = this._hiddenPanels.filter((p) => p !== ev.detail); + } + + private _listItems() { + return [...this.shadowRoot!.children].reduce( + (acc, child) => { + if (child.shadowRoot) { + const item = child.shadowRoot.firstElementChild; + if (item instanceof HTMLElement) acc.push(item); + } + return acc; + }, + [] + ); + } + + static styles = styles; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-panels": HaSidebarPanels; + } + interface HASSDomEvents { + "panel-hover": HTMLElement; + "panel-leave": undefined; + } +} diff --git a/src/components/ha-sidebar-title.ts b/src/components/ha-sidebar-title.ts new file mode 100644 index 000000000000..6acab15b607b --- /dev/null +++ b/src/components/ha-sidebar-title.ts @@ -0,0 +1,110 @@ +import "@material/mwc-button/mwc-button"; +import { mdiMenu, mdiMenuOpen } from "@mdi/js"; +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../common/dom/fire_event"; +import { computeRTL } from "../common/util/compute_rtl"; +import { type ActionHandlerDetail } from "../data/lovelace/action_handler"; +import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; +import { HomeAssistant } from "../types"; +import "./ha-icon-button"; + +const styles = css` + .menu { + display: flex; + flex-shrink: 0; + height: var(--header-height); + box-sizing: border-box; + align-items: center; + white-space: nowrap; + } + .menu .title { + color: var(--secondary-text-color); + font-weight: 500; + font-size: 14px; + line-height: 20px; + margin-left: 16px; + } + .menu ha-icon-button { + margin: 0 auto; + color: var(--sidebar-icon-color); + } + .menu.rtl ha-icon-button { + transform: scaleX(-1); + } + .menu mwc-button { + width: 100%; + } + .menu.expanded { + padding: calc((var(--header-height) - 20px) / 5) 12px 0 12px; + } + .menu.expanded ha-icon-button { + margin-inline-end: 0; + } +`; + +@customElement("ha-sidebar-title") +class HaSidebarTitle extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean }) public expanded = false; + + @property({ type: Boolean }) public editMode = false; + + protected render() { + const classes = classMap({ + menu: true, + expanded: this.expanded, + rtl: computeRTL(this.hass), + }); + const saveEdits = html` + ${this.hass.localize("ui.sidebar.done")} + `; + const sidebarToggle = html` + + `; + return html`
+ ${this.editMode ? saveEdits : ""} + ${!this.expanded || this.editMode + ? "" + : html`Home Assistant`} + ${this.narrow || this.editMode ? "" : sidebarToggle} +
`; + } + + private _toggleSidebar(ev: CustomEvent) { + if (ev.detail.action !== "tap") return; + fireEvent(this, "hass-toggle-menu"); + } + + private _editModeOn(ev: CustomEvent) { + if (ev.detail.action !== "hold") return; + fireEvent(this, "hass-edit-sidebar", { editMode: true }); + } + + private _editModeOff() { + fireEvent(this, "hass-edit-sidebar", { editMode: false }); + } + + static styles = styles; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-title": HaSidebarTitle; + } +} diff --git a/src/components/ha-sidebar-tooltip.ts b/src/components/ha-sidebar-tooltip.ts new file mode 100644 index 000000000000..f8c2b0bb72ca --- /dev/null +++ b/src/components/ha-sidebar-tooltip.ts @@ -0,0 +1,36 @@ +import "@material/mwc-button/mwc-button"; +import { css, html, LitElement } from "lit"; +import { customElement } from "lit/decorators"; + +const styles = css` + div { + position: fixed; + background-color: var(--sidebar-text-color); + color: var(--sidebar-background-color); + padding: 4px; + border-radius: 2px; + transform: translateY(-50%); + white-space: nowrap; + display: none; + } +`; + +@customElement("ha-sidebar-tooltip") +class HaSidebarTooltip extends LitElement { + protected render() { + return html`
`; + } + + static styles = styles; +} + +export interface TooltipPosition { + shown: boolean; + x: number; + y: number; +} +declare global { + interface HTMLElementTagNameMap { + "ha-sidebar-tooltip": HaSidebarTooltip; + } +} diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 50b5e1cba96c..89f6b8b7e895 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -1,191 +1,35 @@ -import "@material/mwc-button/mwc-button"; -import { - mdiBell, - mdiCalendar, - mdiCellphoneCog, - mdiChartBox, - mdiClipboardList, - mdiClose, - mdiCog, - mdiFormatListBulletedType, - mdiHammer, - mdiLightningBolt, - mdiMenu, - mdiMenuOpen, - mdiPlayBoxMultiple, - mdiPlus, - mdiTooltipAccount, - mdiViewDashboard, -} from "@mdi/js"; -import "@polymer/paper-item/paper-icon-item"; -import type { PaperIconItemElement } from "@polymer/paper-item/paper-icon-item"; -import "@polymer/paper-item/paper-item"; -import "@polymer/paper-listbox/paper-listbox"; -import { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { - CSSResult, - CSSResultGroup, - LitElement, - PropertyValues, - css, - html, - nothing, -} from "lit"; -import { customElement, eventOptions, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import { guard } from "lit/directives/guard"; -import memoizeOne from "memoize-one"; -import { storage } from "../common/decorators/storage"; -import { fireEvent } from "../common/dom/fire_event"; -import { toggleAttribute } from "../common/dom/toggle_attribute"; -import { stringCompare } from "../common/string/compare"; -import { computeRTL } from "../common/util/compute_rtl"; -import { throttle } from "../common/util/throttle"; -import { ActionHandlerDetail } from "../data/lovelace/action_handler"; -import { - PersistentNotification, - subscribeNotifications, -} from "../data/persistent_notification"; -import { subscribeRepairsIssueRegistry } from "../data/repairs"; -import { UpdateEntity, updateCanInstall } from "../data/update"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; -import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; -import type { SortableInstance } from "../resources/sortable"; import { haStyleScrollbar } from "../resources/styles"; -import type { HomeAssistant, PanelInfo, Route } from "../types"; -import "./ha-icon"; -import "./ha-icon-button"; -import "./ha-menu-button"; -import "./ha-svg-icon"; -import "./user/ha-user-badge"; - -const SHOW_AFTER_SPACER = ["config", "developer-tools"]; - -const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body; - -const SORT_VALUE_URL_PATHS = { - energy: 1, - map: 2, - logbook: 3, - history: 4, - "developer-tools": 9, - config: 11, -}; - -const PANEL_ICONS = { - calendar: mdiCalendar, - "developer-tools": mdiHammer, - energy: mdiLightningBolt, - history: mdiChartBox, - logbook: mdiFormatListBulletedType, - lovelace: mdiViewDashboard, - map: mdiTooltipAccount, - "media-browser": mdiPlayBoxMultiple, - todo: mdiClipboardList, -}; - -const panelSorter = ( - reverseSort: string[], - defaultPanel: string, - a: PanelInfo, - b: PanelInfo, - language: string -) => { - 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, language); -}; - -const defaultPanelSorter = ( - defaultPanel: string, - a: PanelInfo, - b: PanelInfo, - language: string -) => { - // 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 stringCompare(a.title!, b.title!, language); - } - 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 stringCompare(a.title!, b.title!, language); -}; - -const computePanels = memoizeOne( - ( - panels: HomeAssistant["panels"], - defaultPanel: HomeAssistant["defaultPanel"], - panelsOrder: string[], - hiddenPanels: string[], - locale: HomeAssistant["locale"] - ): [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, locale.language) - ); - afterSpacer.sort((a, b) => - panelSorter(reverseSort, defaultPanel, a, b, locale.language) - ); - - return [beforeSpacer, afterSpacer]; - } -); - +import type { HomeAssistant, Route } from "../types"; +import "./ha-sidebar-panel-notifications"; +import "./ha-sidebar-panel-user"; +import "./ha-sidebar-panels"; +import "./ha-sidebar-title"; +import "./ha-sidebar-tooltip"; + +const styles = css` + :host { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + .items { + display: flex; + flex-direction: column; + flex-grow: 1; + padding-bottom: 12px; + overflow: hidden; + } + hr { + margin-inline: 12px; + height: 1px; + border: none; + background-color: var(--divider-color); + } +`; @customElement("ha-sidebar") class HaSidebar extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @@ -198,941 +42,84 @@ class HaSidebar extends SubscribeMixin(LitElement) { @property({ type: Boolean }) public editMode = false; - @state() private _notifications?: PersistentNotification[]; - - @state() private _updatesCount = 0; - - @state() private _issuesCount = 0; - - @state() private _renderEmptySortable = false; - - private _mouseLeaveTimeout?: number; - - private _tooltipHideTimeout?: number; - - private _recentKeydownActiveUntil = 0; - - private sortableStyleLoaded = false; - - @storage({ - key: "sidebarPanelOrder", - state: true, - subscribe: true, - }) - private _panelOrder: string[] = []; - - @storage({ - key: "sidebarHiddenPanels", - state: true, - subscribe: true, - }) - private _hiddenPanels: string[] = []; - - private _sortable?: SortableInstance; - - public hassSubscribe(): UnsubscribeFunc[] { - return this.hass.user?.is_admin - ? [ - subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => { - this._issuesCount = repairs.issues.filter( - (issue) => !issue.ignored - ).length; - }), - ] - : []; - } + static styles = [haStyleScrollbar, styles]; protected render() { if (!this.hass) { return nothing; } - - // prettier-ignore - return html` - ${this._renderHeader()} - ${this._renderAllPanels()} - ${this._renderDivider()} - ${this._renderNotifications()} - ${this._renderUserItem()} -
-
- `; - } - - protected shouldUpdate(changedProps: PropertyValues): boolean { - if ( - changedProps.has("expanded") || - changedProps.has("narrow") || - changedProps.has("alwaysExpand") || - changedProps.has("_externalConfig") || - changedProps.has("_updatesCount") || - changedProps.has("_issuesCount") || - 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.locale !== oldHass.locale || - hass.states !== oldHass.states || - hass.defaultPanel !== oldHass.defaultPanel - ); - } - - protected firstUpdated(changedProps: PropertyValues) { - super.firstUpdated(changedProps); - subscribeNotifications(this.hass.connection, (notifications) => { - this._notifications = notifications; - }); - } - - protected updated(changedProps) { - super.updated(changedProps); - if (changedProps.has("alwaysExpand")) { - toggleAttribute(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.locale !== this.hass.locale) { - toggleAttribute(this, "rtl", computeRTL(this.hass)); - } - - this._calculateCounts(); - - 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 _calculateCounts = throttle(() => { - let updateCount = 0; - - for (const entityId of Object.keys(this.hass.states)) { - if ( - entityId.startsWith("update.") && - updateCanInstall(this.hass.states[entityId] as UpdateEntity) - ) { - updateCount++; - } - } - - this._updatesCount = updateCount; - }, 5000); - - private _renderHeader() { - return html``; - } - - private _renderAllPanels() { - const [beforeSpacer, afterSpacer] = computePanels( - this.hass.panels, - this.hass.defaultPanel, - this._panelOrder, - this._hiddenPanels, - this.hass.locale - ); - - // Show the supervisor as beeing part of configuration - const selectedPanel = this.route.path?.startsWith("/hassio/") + const currentPanel = this.route.path?.startsWith("/hassio/") ? "config" : this.hass.panelUrl; - // prettier-ignore return html` - - ${this.editMode - ? this._renderPanelsEdit(beforeSpacer) - : this._renderPanels(beforeSpacer)} - ${this._renderSpacer()} - ${this._renderPanels(afterSpacer)} - ${this._renderExternalConfiguration()} - - `; - } - - private _renderPanels(panels: PanelInfo[]) { - return panels.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 - ? PANEL_ICONS.lovelace - : panel.url_path in PANEL_ICONS - ? PANEL_ICONS[panel.url_path] - : undefined - ) - ); - } - - private _renderPanel( - urlPath: string, - title: string | null, - icon?: string | null, - iconPath?: string | null - ) { - return urlPath === "config" - ? this._renderConfiguration(title) - : html` - - - ${iconPath - ? html`` - : html``} - ${title} - - ${this.editMode - ? html`` - : ""} - - `; - } - - 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 && !panel.icon - ? html`` - : panel.url_path in PANEL_ICONS - ? html`` - : html``} - ${panel.url_path === this.hass.defaultPanel - ? this.hass.localize("panel.states") - : this.hass.localize(`panel.${panel.title}`) || - panel.title} - - `; - })} - ${this._renderSpacer()}` - : ""}`; - } - - private _renderDivider() { - return html`
`; - } - - private _renderSpacer() { - return html`
`; - } - - private _renderConfiguration(title: string | null) { - return html` - - - ${!this.alwaysExpand && - (this._updatesCount > 0 || this._issuesCount > 0) - ? html` - - ${this._updatesCount + this._issuesCount} - - ` - : ""} - ${title} - ${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0) - ? html` - ${this._updatesCount + this._issuesCount} - ` - : ""} - - `; - } - - private _renderNotifications() { - const notificationCount = this._notifications - ? this._notifications.length - : 0; - - return html`
- - - ${!this.alwaysExpand && notificationCount > 0 - ? html` - - ${notificationCount} - - ` - : ""} - - ${this.hass.localize("ui.notification_drawer.title")} - - ${this.alwaysExpand && notificationCount > 0 - ? html` ${notificationCount} ` - : ""} - -
`; - } - - private _renderUserItem() { - return html` - - +
+ - - - ${this.hass.user ? this.hass.user.name : ""} - - - `; - } - - private _renderExternalConfiguration() { - return html`${!this.hass.user?.is_admin && - this.hass.auth.external?.config.hasSettingsScreen - ? html` - - - - - ${this.hass.localize("ui.sidebar.external_app_configuration")} - - - - ` - : ""}`; - } - - private _handleExternalAppConfiguration(ev: Event) { - ev.preventDefault(); - this.hass.auth.external!.fireMessage({ - type: "config_screen/show", - }); - } - - private get _tooltip() { - return this.shadowRoot!.querySelector(".tooltip")! as HTMLDivElement; - } - - private _handleAction(ev: CustomEvent) { - if (ev.detail.action !== "hold") { - return; - } - - fireEvent(this, "hass-edit-sidebar", { editMode: true }); - } - - private async _activateEditMode() { - await Promise.all([this._loadSortableStyle(), this._createSortable()]); - } - - private async _loadSortableStyle() { - if (this.sortableStyleLoaded) return; - - const sortStylesImport = await import("../resources/ha-sortable-style"); - - const style = document.createElement("style"); - style.innerHTML = (sortStylesImport.sortableStyles as CSSResult).cssText; - this.shadowRoot!.appendChild(style); - - this.sortableStyleLoaded = true; - await this.updateComplete; - } - - private async _createSortable() { - const Sortable = (await import("../resources/sortable")).default; - this._sortable = new Sortable( - this.shadowRoot!.getElementById("sortable")!, - { - animation: 150, - fallbackClass: "sortable-fallback", - dataIdAttr: "data-panel", - handle: "paper-icon-item", - onSort: async () => { - this._panelOrder = this._sortable!.toArray(); - }, - } - ); - } - - private _deactivateEditMode() { - this._sortable?.destroy(); - this._sortable = undefined; - } - - private _closeEditMode() { - 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 - // sidebar causing the mouse to hover a new icon - if ( - this.alwaysExpand || - new Date().getTime() < this._recentKeydownActiveUntil - ) { - return; - } - if (this._mouseLeaveTimeout) { - clearTimeout(this._mouseLeaveTimeout); - this._mouseLeaveTimeout = undefined; - } - this._showTooltip(ev.currentTarget as PaperIconItemElement); + .expanded=${this.alwaysExpand} + .currentPanel=${currentPanel} + .editMode=${this.editMode} + @panel-hover=${this._panelHover} + @panel-leave=${this._mouseLeave} + class="ha-scrollbar" + > +
+ + +
+ + `; } - private _itemMouseLeave() { - if (this._mouseLeaveTimeout) { - clearTimeout(this._mouseLeaveTimeout); - } - this._mouseLeaveTimeout = window.setTimeout(() => { - this._hideTooltip(); - }, 500); + private _panelHover(ev: CustomEvent) { + this._mouseOverItem({ target: ev.detail }); } - private _listboxFocusIn(ev) { - if (this.alwaysExpand || ev.target.nodeName !== "A") { + private _mouseOverItem(ev: { target: HTMLElement }) { + const tooltipRoot = + this.shadowRoot?.querySelector("ha-sidebar-tooltip")?.shadowRoot; + const tooltip = tooltipRoot?.firstElementChild as HTMLElement; + if (!tooltip) return; + if (this.alwaysExpand) { + tooltip.style.display = "none"; return; } - this._showTooltip(ev.target.querySelector("paper-icon-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) { + const target = ev.target; + const targetPos = target.getBoundingClientRect(); + const label = target.shadowRoot?.querySelector(".name")?.innerHTML; + if (!label) { + tooltip.style.display = "none"; return; } - this._hideTooltip(); - } - - private _listboxKeydown() { - this._recentKeydownActiveUntil = new Date().getTime() + 100; - } - - private _showTooltip(item: PaperIconItemElement) { - if (this._tooltipHideTimeout) { - clearTimeout(this._tooltipHideTimeout); - this._tooltipHideTimeout = undefined; - } - const tooltip = this._tooltip; - const listbox = this.shadowRoot!.querySelector("paper-listbox")!; - let top = item.offsetTop + 11; - if (listbox.contains(item)) { - top -= listbox.scrollTop; - } - tooltip.innerHTML = item.querySelector(".item-text")!.innerHTML; + tooltip.innerHTML = label; tooltip.style.display = "block"; - tooltip.style.position = "fixed"; - tooltip.style.top = `${top}px`; - tooltip.style.left = `${item.offsetLeft + item.clientWidth + 4}px`; + tooltip.style.left = this.offsetLeft + this.clientWidth + 4 + "px"; + tooltip.style.top = targetPos.top + targetPos.height / 2 + "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 _handleShowNotificationDrawer() { - fireEvent(this, "hass-show-notifications"); - } - - private _toggleSidebar(ev: CustomEvent) { - if (ev.detail.action !== "tap") { - return; - } - fireEvent(this, "hass-toggle-menu"); - } - - static get styles(): CSSResultGroup { - return [ - haStyleScrollbar, - css` - :host { - overflow: visible; - height: 100%; - display: block; - overflow: hidden; - -ms-user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - background-color: var(--sidebar-background-color); - width: 100%; - box-sizing: border-box; - } - .menu { - height: var(--header-height); - box-sizing: border-box; - display: flex; - padding: 0 4px; - border-bottom: 1px solid transparent; - white-space: nowrap; - font-weight: 400; - color: var(--sidebar-menu-button-text-color, --primary-text-color); - border-bottom: 1px solid var(--divider-color); - background-color: var( - --sidebar-menu-button-background-color, - --primary-background-color - ); - font-size: 20px; - align-items: center; - padding-left: calc(4px + env(safe-area-inset-left)); - } - :host([rtl]) .menu { - padding-left: 4px; - padding-right: calc(4px + 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 ha-icon-button { - color: var(--sidebar-icon-color); - } - .title { - margin-left: 19px; - width: 100%; - display: none; - } - :host([rtl]) .title { - margin-left: 0; - margin-right: 19px; - } - :host([narrow]) .title { - margin: 0; - padding: 0 16px; - } - :host([expanded]) .title { - display: initial; - } - :host([expanded]) .menu mwc-button { - margin: 0 8px; - } - .menu mwc-button { - width: 100%; - } - #sortable, - .hidden-panel { - display: none; - } - - paper-listbox { - padding: 4px 0; - display: flex; - flex-direction: column; - box-sizing: border-box; - height: calc(100% - var(--header-height) - 132px); - height: calc( - 100% - var(--header-height) - 132px - env(safe-area-inset-bottom) - ); - overflow-x: hidden; - background: none; - margin-left: env(safe-area-inset-left); - } - - :host([rtl]) paper-listbox { - margin-left: initial; - margin-right: env(safe-area-inset-right); - } - - a { - text-decoration: none; - color: var(--sidebar-text-color); - font-weight: 500; - font-size: 14px; - position: relative; - display: block; - outline: 0; - } - - paper-icon-item { - box-sizing: border-box; - margin: 4px; - padding-left: 12px; - border-radius: 4px; - --paper-item-min-height: 40px; - width: 48px; - } - :host([expanded]) paper-icon-item { - width: 248px; - } - :host([rtl]) paper-icon-item { - padding-left: auto; - padding-right: 12px; - } - - ha-icon[slot="item-icon"], - ha-svg-icon[slot="item-icon"] { - color: var(--sidebar-icon-color); - } - - .iron-selected paper-icon-item::before, - a:not(.iron-selected):focus::before { - border-radius: 4px; - position: absolute; - top: 0; - right: 2px; - bottom: 0; - left: 2px; - pointer-events: none; - content: ""; - transition: opacity 15ms linear; - will-change: opacity; - } - .iron-selected paper-icon-item::before { - background-color: var(--sidebar-selected-icon-color); - opacity: 0.12; - } - a:not(.iron-selected):focus::before { - background-color: currentColor; - opacity: var(--dark-divider-opacity); - margin: 4px 8px; - } - .iron-selected paper-icon-item:focus::before, - .iron-selected:focus paper-icon-item::before { - opacity: 0.2; - } - - .iron-selected paper-icon-item[pressed]:before { - opacity: 0.37; - } - - paper-icon-item span { - color: var(--sidebar-text-color); - font-weight: 500; - font-size: 14px; - } - - a.iron-selected paper-icon-item ha-icon, - a.iron-selected paper-icon-item ha-svg-icon { - color: var(--sidebar-selected-icon-color); - } - - a.iron-selected .item-text { - color: var(--sidebar-selected-text-color); - } - - paper-icon-item .item-text { - display: none; - max-width: calc(100% - 56px); - } - :host([expanded]) paper-icon-item .item-text { - display: block; - } - - .divider { - bottom: 112px; - padding: 10px 0; - } - .divider::before { - content: " "; - display: block; - height: 1px; - background-color: var(--divider-color); - } - .notifications-container, - .configuration-container { - display: flex; - margin-left: env(safe-area-inset-left); - } - :host([rtl]) .notifications-container, - :host([rtl]) .configuration-container { - margin-left: initial; - margin-right: env(safe-area-inset-right); - } - .notifications { - cursor: pointer; - } - .notifications .item-text, - .configuration .item-text { - flex: 1; - } - .profile { - margin-left: env(safe-area-inset-left); - } - :host([rtl]) .profile { - margin-left: initial; - margin-right: env(safe-area-inset-right); - } - .profile paper-icon-item { - padding-left: 4px; - } - :host([rtl]) .profile paper-icon-item { - padding-left: auto; - padding-right: 4px; - } - .profile .item-text { - margin-left: 8px; - } - :host([rtl]) .profile .item-text { - margin-right: 8px; - } - - .notification-badge, - .configuration-badge { - position: absolute; - left: calc(var(--app-drawer-width, 248px) - 42px); - min-width: 20px; - box-sizing: border-box; - border-radius: 50%; - font-weight: 400; - background-color: var(--accent-color); - line-height: 20px; - text-align: center; - padding: 0px 2px; - color: var(--text-accent-color, var(--text-primary-color)); - } - ha-svg-icon + .notification-badge, - ha-svg-icon + .configuration-badge { - position: absolute; - bottom: 14px; - left: 26px; - font-size: 0.65em; - } - - .spacer { - flex: 1; - pointer-events: none; - } - - .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 ha-icon-button { - -webkit-transform: scaleX(-1); - transform: scaleX(-1); - } - `, - ]; + private _mouseLeave() { + const tooltipRoot = + this.shadowRoot?.querySelector("ha-sidebar-tooltip")?.shadowRoot; + const tooltip = tooltipRoot?.firstElementChild as HTMLElement; + if (!tooltip) return; + tooltip.style.display = "none"; } } diff --git a/src/layouts/home-assistant-main.ts b/src/layouts/home-assistant-main.ts index 956c4c8eedd0..36e174c9a397 100644 --- a/src/layouts/home-assistant-main.ts +++ b/src/layouts/home-assistant-main.ts @@ -175,7 +175,7 @@ export class HomeAssistantMain extends LitElement { color: var(--primary-text-color); /* remove the grey tap highlights in iOS on the fullscreen touch targets */ -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - --mdc-drawer-width: 56px; + --mdc-drawer-width: 75px; --mdc-top-app-bar-width: calc(100% - var(--mdc-drawer-width)); } :host([expanded]) { diff --git a/src/resources/button-handlers.ts b/src/resources/button-handlers.ts new file mode 100644 index 000000000000..17b654a5e73c --- /dev/null +++ b/src/resources/button-handlers.ts @@ -0,0 +1,10 @@ +export const keydown = (e: KeyboardEvent & { currentTarget: HTMLElement }) => { + if (e.key === " ") e.preventDefault(); + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.click(); + } +}; +export const keyup = (e: KeyboardEvent & { currentTarget: HTMLElement }) => { + if (e.key === " ") e.currentTarget.click(); +}; diff --git a/src/resources/ha-sortable-style.ts b/src/resources/ha-sortable-style.ts index 8c7a5ded7c7b..8b745f5adf5e 100644 --- a/src/resources/ha-sortable-style.ts +++ b/src/resources/ha-sortable-style.ts @@ -1,37 +1,11 @@ import { css } from "lit"; export const sortableStyles = css` - #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 a { - height: 48px; - display: flex; - } - #sortable { outline: none; display: block !important; } - .hidden-panel { - display: flex !important; - } - .sortable-fallback { display: none; } @@ -39,71 +13,4 @@ export const sortableStyles = css` .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; - } - } - - .show-panel, - .hide-panel { - display: none; - position: absolute; - top: 0; - right: 4px; - --mdc-icon-button-size: 40px; - } - - :host([rtl]) .show-panel { - right: initial; - left: 4px; - } - - .hide-panel { - top: 4px; - right: 8px; - } - - :host([rtl]) .hide-panel { - right: initial; - left: 8px; - } - - :host([expanded]) .hide-panel { - display: block; - } - - :host([expanded]) .show-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; - } `; diff --git a/src/resources/styles-data.ts b/src/resources/styles-data.ts index 87114d688515..97446e4d32c7 100644 --- a/src/resources/styles-data.ts +++ b/src/resources/styles-data.ts @@ -60,9 +60,12 @@ export const derivedStyles = { "state-unavailable-color": "var(--state-icon-unavailable-color, var(--disabled-text-color))", "sidebar-text-color": "var(--primary-text-color)", + "rgb-sidebar-text-color": "var(--rgb-primary-text-color)", "sidebar-background-color": "var(--card-background-color)", - "sidebar-selected-text-color": "var(--primary-color)", - "sidebar-selected-icon-color": "var(--primary-color)", + "sidebar-selected-color": + "var(--sidebar-selected-text-color, var(--primary-color))", + "rgb-sidebar-selected-color": + "var(--rgb-sidebar-selected-text-color, var(--rgb-primary-color))", "sidebar-icon-color": "rgba(var(--rgb-primary-text-color), 0.6)", "switch-checked-color": "var(--primary-color)", "switch-checked-button-color": diff --git a/src/resources/styles.ts b/src/resources/styles.ts index 80eef864e7e0..697579c94827 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -180,6 +180,106 @@ export const haStyleDialog = css` } `; +export const haStyleSidebarItem = css` + :host { + padding-inline: 12px; + } + .item { + --rgb-text: var(--rgb-sidebar-text-color); + background-color: transparent; + color: rgb(var(--rgb-text)); + text-decoration: none; + border: none; + padding: 0; + cursor: pointer; + font-family: inherit; + font-weight: 500; + font-size: 14px; + line-height: 20px; + + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--sidebar-item-radius, 25px); + height: 50px; + } + .item .icon { + position: relative; + text-align: left; + width: 24px; + } + .item .name { + display: none; + white-space: nowrap; + } + .item:hover { + color: rgb(var(--rgb-text)); + background-color: rgba(var(--rgb-text), 0.08); + } + .item:focus-visible { + outline: none; + } + .item:focus-visible, + .item:active { + color: rgb(var(--rgb-text)); + background-color: rgba(var(--rgb-text), 0.12); + } + .item[aria-current="page"] { + --rgb-text: var(--rgb-sidebar-selected-color); + background-color: rgba(var(--rgb-text), 0.12); + } + .item[aria-current="page"]:focus-visible { + background-color: rgba(var(--rgb-text), 0.2); + } + .item.expanded { + padding-inline-start: 16px; + padding-inline-end: 24px; + justify-content: initial; + } + .item.expanded .icon { + margin-inline-end: 12px; + } + .item.expanded .name { + display: initial; + } + .target { + position: absolute; + top: 0; + bottom: 0; + left: -12px; + right: -12px; + } + .badge { + position: absolute; + top: 0; + right: 0; + width: 16px; + height: 16px; + transform: translateX(50%); + border-radius: 8px; + background-color: var(--accent-color); + color: var(--text-accent-color, var(--text-primary-color)); + font-weight: 500; + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; + } + .count { + margin-inline-start: auto; + margin-inline-end: -9px; + background-color: var(--accent-color); + color: var(--text-accent-color, var(--text-primary-color)); + padding: 0 6px; + min-width: 20px; + height: 20px; + border-radius: 10px; + box-sizing: border-box; + } +`; + export const haStyleScrollbar = css` .ha-scrollbar::-webkit-scrollbar { width: 0.4rem;