From 430e47c0fcfad67997b1b954c5ed135ea83afede Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 10 Apr 2025 16:20:24 +0200 Subject: [PATCH] Replace paper tabs by shoelace tabs (#24909) --- build-scripts/bundle.cjs | 1 + demo/src/configs/jimpower/theme.ts | 1 - demo/src/configs/kernehed/theme.ts | 1 - package.json | 1 - src/components/ha-tabs.ts | 115 ------- src/components/sl-tab-group.ts | 159 +++++++++ .../ha-panel-developer-tools.ts | 65 ++-- .../config-elements/hui-stack-card-editor.ts | 65 ++-- src/panels/lovelace/hui-root.ts | 310 +++++++++--------- yarn.lock | 122 ------- 10 files changed, 377 insertions(+), 463 deletions(-) delete mode 100644 src/components/ha-tabs.ts create mode 100644 src/components/sl-tab-group.ts diff --git a/build-scripts/bundle.cjs b/build-scripts/bundle.cjs index 04529ef616..37c8183cc9 100644 --- a/build-scripts/bundle.cjs +++ b/build-scripts/bundle.cjs @@ -182,6 +182,7 @@ module.exports.babelOptions = ({ include: /\/node_modules\//, exclude: [ "element-internals-polyfill", + "@shoelace-style", "@?lit(?:-labs|-element|-html)?", ].map((p) => new RegExp(`/node_modules/${p}/`)), }, diff --git a/demo/src/configs/jimpower/theme.ts b/demo/src/configs/jimpower/theme.ts index 246b3b22bd..4763713f61 100644 --- a/demo/src/configs/jimpower/theme.ts +++ b/demo/src/configs/jimpower/theme.ts @@ -3,7 +3,6 @@ export const demoThemeJimpower = () => ({ "paper-item-icon-color": "var(--primary-text-color)", "primary-color": "#5294E2", "label-badge-red": "var(--accent-color)", - "paper-tabs-selection-bar-color": "green", "light-primary-color": "var(--accent-color)", "primary-background-color": "#383C45", "primary-text-color": "#FFFFFF", diff --git a/demo/src/configs/kernehed/theme.ts b/demo/src/configs/kernehed/theme.ts index 28433aba5d..6cfaafbf25 100644 --- a/demo/src/configs/kernehed/theme.ts +++ b/demo/src/configs/kernehed/theme.ts @@ -4,7 +4,6 @@ export const demoThemeKernehed = () => ({ "paper-item-icon-color": "var(--primary-text-color)", "primary-color": "#2980b9", "label-badge-red": "var(--accent-color)", - "paper-tabs-selection-bar-color": "green", "primary-text-color": "#FFFFFF", "light-primary-color": "var(--accent-color)", "primary-background-color": "#222222", diff --git a/package.json b/package.json index d9ddb9f1a1..be2a9539a4 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "@mdi/svg": "7.4.47", "@polymer/paper-item": "3.0.1", "@polymer/paper-listbox": "3.0.1", - "@polymer/paper-tabs": "3.1.0", "@polymer/polymer": "3.5.2", "@replit/codemirror-indentation-markers": "6.5.3", "@shoelace-style/shoelace": "2.20.1", diff --git a/src/components/ha-tabs.ts b/src/components/ha-tabs.ts deleted file mode 100644 index cf0d62b7dd..0000000000 --- a/src/components/ha-tabs.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { PaperIconButtonElement } from "@polymer/paper-icon-button/paper-icon-button"; -import type { PaperTabElement } from "@polymer/paper-tabs/paper-tab"; -import "@polymer/paper-tabs/paper-tabs"; -import type { PaperTabsElement } from "@polymer/paper-tabs/paper-tabs"; -import { customElement } from "lit/decorators"; -import type { Constructor } from "../types"; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const PaperTabs = customElements.get( - "paper-tabs" -) as Constructor; - -let subTemplate: HTMLTemplateElement; - -@customElement("ha-tabs") -export class HaTabs extends PaperTabs { - private _firstTabWidth = 0; - - private _lastTabWidth = 0; - - private _lastLeftHiddenState = false; - - private _lastRightHiddenState = false; - - static get template(): HTMLTemplateElement { - if (!subTemplate) { - subTemplate = (PaperTabs as any).template.cloneNode(true); - - const superStyle = subTemplate.content.querySelector("style"); - - // Add "noink" attribute for scroll buttons to disable animation. - subTemplate.content - .querySelectorAll("paper-icon-button") - .forEach((arrow: PaperIconButtonElement) => { - arrow.setAttribute("noink", ""); - }); - - superStyle!.appendChild( - document.createTextNode(` - #selectionBar { - box-sizing: border-box; - } - .not-visible { - display: none; - } - paper-icon-button { - width: 24px; - height: 48px; - padding: 0; - margin: 0; - } - `) - ); - } - return subTemplate; - } - - // Get first and last tab's width for _affectScroll - // eslint-disable-next-line @typescript-eslint/naming-convention - public _tabChanged(tab: PaperTabElement, old: PaperTabElement): void { - super._tabChanged(tab, old); - const tabs = this.querySelectorAll("paper-tab:not(.hide-tab)"); - if (tabs.length > 0) { - this._firstTabWidth = tabs[0].clientWidth; - this._lastTabWidth = tabs[tabs.length - 1].clientWidth; - } - - // Scroll active tab into view if needed. - const selected = this.querySelector(".iron-selected"); - if (selected) { - selected.scrollIntoView(); - this._affectScroll(0); // Ensure scroll arrows match scroll position - } - } - - /** - * Modify _affectScroll so that when the scroll arrows appear - * while scrolling and the tab container shrinks we can counteract - * the jump in tab position so that the scroll still appears smooth. - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - public _affectScroll(dx: number): void { - if (this._firstTabWidth === 0 || this._lastTabWidth === 0) { - return; - } - - this.$.tabsContainer.scrollLeft += dx; - - const scrollLeft = this.$.tabsContainer.scrollLeft; - const dirRTL = this.dir === "rtl"; - - const boolCondition1 = Math.abs(scrollLeft) < this._firstTabWidth; - const boolCondition2 = - Math.abs(scrollLeft) + this._lastTabWidth > this._tabContainerScrollSize; - - this._leftHidden = !dirRTL ? boolCondition1 : boolCondition2; - this._rightHidden = !dirRTL ? boolCondition2 : boolCondition1; - - if (!dirRTL) { - if (this._lastLeftHiddenState !== this._leftHidden) { - this._lastLeftHiddenState = this._leftHidden; - this.$.tabsContainer.scrollLeft += this._leftHidden ? -23 : 23; - } - } else if (this._lastRightHiddenState !== this._rightHidden) { - this._lastRightHiddenState = this._rightHidden; - this.$.tabsContainer.scrollLeft -= this._rightHidden ? -23 : 23; - } - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-tabs": HaTabs; - } -} diff --git a/src/components/sl-tab-group.ts b/src/components/sl-tab-group.ts new file mode 100644 index 0000000000..546fad9d9c --- /dev/null +++ b/src/components/sl-tab-group.ts @@ -0,0 +1,159 @@ +import TabGroup from "@shoelace-style/shoelace/dist/components/tab-group/tab-group.component"; +import TabGroupStyles from "@shoelace-style/shoelace/dist/components/tab-group/tab-group.styles"; +import "@shoelace-style/shoelace/dist/components/tab/tab"; +import type { PropertyValues } from "lit"; +import { css } from "lit"; +import { customElement, query } from "lit/decorators"; + +@customElement("sl-tab-group") +// @ts-ignore +export class HaSlTabGroup extends TabGroup { + private _mouseIsDown = false; + + private _scrolled = false; + + private _mouseReleasedAt?: number; + + private _scrollStartX = 0; + + private _scrollLeft = 0; + + @query(".tab-group__nav", true) private _scrollContainer?: HTMLElement; + + public disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener("mousemove", this._mouseMove); + } + + override setAriaLabels() { + // Override the method to prevent setting aria-labels, as we don't use panels + // and don't want to set aria-labels for the tabs + } + + override getAllPanels() { + // Override the method to prevent querying for panels + // and return an empty array instead + // as we don't use panels + return []; + } + + protected override firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + + const scrollContainer = this._scrollContainer; + + if (scrollContainer) { + scrollContainer.addEventListener("mousedown", this._mouseDown); + scrollContainer.addEventListener("mouseup", this._mouseUp); + } + } + + // @ts-ignore + protected override handleClick(event: MouseEvent) { + if ( + this._mouseReleasedAt && + new Date().getTime() - this._mouseReleasedAt < 100 + ) { + return; + } + // @ts-ignore + super.handleClick(event); + } + + private _mouseDown = (event: MouseEvent) => { + const scrollContainer = this._scrollContainer; + + if (!scrollContainer) { + return; + } + + this._scrollStartX = event.pageX - scrollContainer.offsetLeft; + this._scrollLeft = scrollContainer.scrollLeft; + this._mouseIsDown = true; + this._scrolled = false; + + window.addEventListener("mousemove", this._mouseMove); + }; + + private _mouseUp = () => { + this._mouseIsDown = false; + if (this._scrolled) { + this._mouseReleasedAt = new Date().getTime(); + } + window.removeEventListener("mousemove", this._mouseMove); + }; + + private _mouseMove = (event: MouseEvent) => { + if (!this._mouseIsDown) { + return; + } + + const scrollContainer = this._scrollContainer; + + if (!scrollContainer) { + return; + } + + const x = event.pageX - scrollContainer.offsetLeft; + const scroll = x - this._scrollStartX; + + if (!this._scrolled) { + this._scrolled = Math.abs(scroll) > 1; + } + + scrollContainer.scrollLeft = this._scrollLeft - scroll; + }; + + static override styles = [ + TabGroupStyles, + css` + :host { + --sl-spacing-3x-small: 0.125rem; + --sl-spacing-2x-small: 0.25rem; + --sl-spacing-x-small: 0.5rem; + --sl-spacing-small: 0.75rem; + --sl-spacing-medium: 1rem; + --sl-spacing-large: 1.25rem; + --sl-spacing-x-large: 1.75rem; + --sl-spacing-2x-large: 2.25rem; + --sl-spacing-3x-large: 3rem; + --sl-spacing-4x-large: 4.5rem; + + --sl-transition-x-slow: 1000ms; + --sl-transition-slow: 500ms; + --sl-transition-medium: 250ms; + --sl-transition-fast: 150ms; + --sl-transition-x-fast: 50ms; + --transition-speed: var(--sl-transition-fast); + --sl-border-radius-small: 0.1875rem; + --sl-border-radius-medium: 0.25rem; + --sl-border-radius-large: 0.5rem; + --sl-border-radius-x-large: 1rem; + --sl-border-radius-circle: 50%; + --sl-border-radius-pill: 9999px; + + --sl-color-neutral-600: inherit; + + --sl-font-weight-semibold: 500; + --sl-font-size-small: 14px; + + --sl-color-primary-600: var( + --ha-tab-active-text-color, + var(--primary-color) + ); + --track-color: var(--ha-tab-track-color, var(--divider-color)); + --indicator-color: var(--ha-tab-indicator-color, var(--primary-color)); + } + ::slotted(sl-tab:not([active])) { + opacity: 0.8; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + // @ts-ignore + "sl-tab-group": HaSlTabGroup; + } +} diff --git a/src/panels/developer-tools/ha-panel-developer-tools.ts b/src/panels/developer-tools/ha-panel-developer-tools.ts index 23b299cfcd..c16c615bc7 100644 --- a/src/panels/developer-tools/ha-panel-developer-tools.ts +++ b/src/panels/developer-tools/ha-panel-developer-tools.ts @@ -1,6 +1,4 @@ import { mdiDotsVertical } from "@mdi/js"; -import "@polymer/paper-tabs/paper-tab"; -import "@polymer/paper-tabs/paper-tabs"; import type { CSSResultGroup, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; @@ -10,6 +8,7 @@ import "../../components/ha-menu-button"; import "../../components/ha-button-menu"; import "../../components/ha-icon-button"; import "../../components/ha-list-item"; +import "../../components/sl-tab-group"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant, Route } from "../../types"; import "./developer-tools-router"; @@ -51,36 +50,37 @@ class PanelDeveloperTools extends LitElement { - - + + ${this.hass.localize("ui.panel.developer-tools.tabs.yaml.title")} - - + + ${this.hass.localize("ui.panel.developer-tools.tabs.states.title")} - - + + ${this.hass.localize("ui.panel.developer-tools.tabs.actions.title")} - - + + ${this.hass.localize( "ui.panel.developer-tools.tabs.templates.title" )} - - + + ${this.hass.localize("ui.panel.developer-tools.tabs.events.title")} - - + + ${this.hass.localize( "ui.panel.developer-tools.tabs.statistics.title" )} - - Assist - + + Assist + ) { + const newPage = ev.detail.name; + if (!newPage) { + return; + } if (newPage !== this._page) { navigate(`/developer-tools/${newPage}`); } else { @@ -161,16 +164,10 @@ class PanelDeveloperTools extends LitElement { flex: 1 1 100%; max-width: 100%; } - paper-tabs { - margin-left: max(env(safe-area-inset-left), 24px); - margin-right: max(env(safe-area-inset-right), 24px); - margin-inline-start: max(env(safe-area-inset-left), 24px); - margin-inline-end: max(env(safe-area-inset-right), 24px); - --paper-tabs-selection-bar-color: var( - --app-header-selection-bar-color, - var(--app-header-text-color, #fff) - ); - text-transform: uppercase; + sl-tab-group { + --ha-tab-active-text-color: var(--text-primary-color); + --ha-tab-track-color: var(--app-header-background-color); + --ha-tab-indicator-color: var(--text-primary-color); } `, ]; diff --git a/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts index c08c959c22..a13d52d990 100644 --- a/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts @@ -6,12 +6,11 @@ import { mdiListBoxOutline, mdiPlus, } from "@mdi/js"; -import "@polymer/paper-tabs"; -import "@polymer/paper-tabs/paper-tab"; import deepClone from "deep-clone-simple"; import type { CSSResultGroup } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +import { keyed } from "lit/directives/keyed"; import { any, array, @@ -21,17 +20,17 @@ import { optional, string, } from "superstruct"; -import { keyed } from "lit/directives/keyed"; +import { storage } from "../../../../common/decorators/storage"; +import type { HASSDomEvent } from "../../../../common/dom/fire_event"; +import { fireEvent } from "../../../../common/dom/fire_event"; import type { HaFormSchema, SchemaUnion, } from "../../../../components/ha-form/types"; -import { storage } from "../../../../common/decorators/storage"; -import type { HASSDomEvent } from "../../../../common/dom/fire_event"; -import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-icon-button"; -import "../../../../components/ha-icon-button-arrow-prev"; import "../../../../components/ha-icon-button-arrow-next"; +import "../../../../components/ha-icon-button-arrow-prev"; +import "../../../../components/sl-tab-group"; import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; import type { HomeAssistant } from "../../../../types"; @@ -124,24 +123,18 @@ export class HuiStackCardEditor >
- + ${this._config.cards.map( - (_card, i) => html` ${i + 1} ` + (_card, i) => + html` + ${i + 1} + ` )} - - - - - - + +
@@ -234,14 +227,16 @@ export class HuiStackCardEditor return this._keys.get(key)!; } + protected async _handleAddCard() { + this._selectedCard = this._config!.cards.length; + await this.updateComplete; + this.renderRoot.querySelector("sl-tab-group")!.syncIndicator(); + } + protected _handleSelectedCard(ev) { - if (ev.target.id === "add-card") { - this._selectedCard = this._config!.cards.length; - return; - } this._setMode(true); this._guiModeAvailable = true; - this._selectedCard = parseInt(ev.detail.selected, 10); + this._selectedCard = parseInt(ev.detail.name, 10); } protected _handleConfigChanged(ev: HASSDomEvent) { @@ -344,17 +339,13 @@ export class HuiStackCardEditor css` .toolbar { display: flex; - --paper-tabs-selection-bar-color: var(--primary-color); - --paper-tab-ink: var(--primary-color); + justify-content: space-between; + align-items: center; } - paper-tabs { - display: flex; - font-size: 14px; + sl-tab-group { flex-grow: 1; - } - #add-card { - max-width: 32px; - padding: 0; + min-width: 0; + --ha-tab-track-color: var(--card-background-color); } #card-options { diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index b4fa06cab7..9b17d5ec02 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -15,10 +15,8 @@ import { mdiShape, mdiViewDashboard, } from "@mdi/js"; -import "@polymer/paper-tabs/paper-tab"; -import "@polymer/paper-tabs/paper-tabs"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; -import { css, html, LitElement } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; @@ -34,7 +32,6 @@ import { extractSearchParamsObject, removeSearchParam, } from "../../common/url/search-params"; -import { computeRTLDirection } from "../../common/util/compute_rtl"; import { debounce } from "../../common/util/debounce"; import { afterNextRender } from "../../common/util/render-status"; import "../../components/ha-button-menu"; @@ -44,7 +41,7 @@ import "../../components/ha-icon-button-arrow-next"; import "../../components/ha-icon-button-arrow-prev"; import "../../components/ha-menu-button"; import "../../components/ha-svg-icon"; -import "../../components/ha-tabs"; +import "../../components/sl-tab-group"; import type { LovelacePanelConfig } from "../../data/lovelace"; import type { LovelaceConfig } from "../../data/lovelace/config/types"; import { isStrategyDashboard } from "../../data/lovelace/config/types"; @@ -76,9 +73,9 @@ import { getLovelaceStrategy } from "./strategies/get-strategy"; import { isLegacyStrategyConfig } from "./strategies/legacy-strategy"; import type { Lovelace } from "./types"; import "./views/hui-view"; -import "./views/hui-view-container"; import type { HUIView } from "./views/hui-view"; import "./views/hui-view-background"; +import "./views/hui-view-container"; import { showShortcutsDialog } from "../../dialogs/shortcuts/show-shortcuts-dialog"; @customElement("hui-root") @@ -302,6 +299,77 @@ class HUIRoot extends LitElement { const background = curViewConfig?.background || this.config.background; + const tabs = html` + ${views.map( + (view, index) => html` + e.user === this.hass!.user?.id + )) || + view.visible === false) + ), + })} + > + ${this._editMode + ? html` + + ` + : nothing} + ${view.icon + ? html` + + ` + : view.title || "Unnamed view"} + ${this._editMode + ? html` + + + ` + : nothing} + + ` + )} + `; + return html`
${curViewConfig.title}
` : views.filter((view) => !view.subview).length > 1 - ? html` - - ${views.map( - (view) => html` - - e.user === this.hass!.user?.id - )) || - view.visible === false)) - ), - })} - > - ${view.icon - ? html` - - ` - : view.title || "Unnamed view"} - - ` - )} - - ` + ? tabs : html`
${views[0]?.title ?? dashboardTitle} @@ -392,93 +423,19 @@ class HUIRoot extends LitElement { `}
${this._editMode - ? html` - - ${views.map( - (view) => html` - e.user === this.hass!.user?.id - )) || - view.visible === false) - ), - })} - > - ${this._editMode - ? html` - - ` - : ""} - ${view.icon - ? html` - - ` - : view.title || "Unnamed view"} - ${this._editMode - ? html` - - - ` - : ""} - - ` + ? html`
+ ${tabs} + - ` - : ""} - - ` - : ""} + .path=${mdiPlus} + > +
` + : nothing}