mirror of
https://github.com/home-assistant/frontend.git
synced 2025-10-17 07:39:42 +00:00
Compare commits
42 Commits
energy-pan
...
loading-an
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5dc95faaa9 | ||
![]() |
28447b107d | ||
![]() |
e664b06d9f | ||
![]() |
5502940814 | ||
![]() |
ebbca36212 | ||
![]() |
25f1937860 | ||
![]() |
0a1086fb87 | ||
![]() |
0b9ed858b9 | ||
![]() |
6d3aa9d1a2 | ||
![]() |
7de89f7147 | ||
![]() |
a7a093833f | ||
![]() |
a02487a377 | ||
![]() |
fcf1618f5c | ||
![]() |
fc62a1de55 | ||
![]() |
9cde2fa533 | ||
![]() |
74065ad25d | ||
![]() |
e7ed7b926d | ||
![]() |
589e21aa4c | ||
![]() |
4d1295ebaf | ||
![]() |
364834001b | ||
![]() |
3ff72bb0d9 | ||
![]() |
f3621f8e83 | ||
![]() |
fb71f88ea0 | ||
![]() |
cd0398c3ba | ||
![]() |
0c57fc6b58 | ||
![]() |
aef25d0606 | ||
![]() |
ab1c736e28 | ||
![]() |
7a16c515bb | ||
![]() |
d72ace6f45 | ||
![]() |
ad94d988bd | ||
![]() |
e646bc31d2 | ||
![]() |
60f7a319a2 | ||
![]() |
fdd268036a | ||
![]() |
58eb72d970 | ||
![]() |
9227a78a15 | ||
![]() |
fdab6d0f3c | ||
![]() |
a82f969a82 | ||
![]() |
d78c8034d0 | ||
![]() |
7eba7664e7 | ||
![]() |
528a1a3477 | ||
![]() |
e5df96ebb1 | ||
![]() |
9cd74fbff8 |
@@ -25,6 +25,7 @@ import "../ha-sortable";
|
||||
interface EntityNameOption {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
field_label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,23 @@ const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
return item.text;
|
||||
}
|
||||
return `___${item.type}___`;
|
||||
};
|
||||
|
||||
const parseOptionValue = (value: string): EntityNameItem => {
|
||||
if (value.startsWith("___") && value.endsWith("___")) {
|
||||
const type = value.slice(3, -3);
|
||||
if (KNOWN_TYPES.has(type)) {
|
||||
return { type: type as EntityNameType };
|
||||
}
|
||||
}
|
||||
return { type: "text", text: value };
|
||||
};
|
||||
|
||||
@customElement("ha-entity-name-picker")
|
||||
export class HaEntityNamePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -121,13 +139,23 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
value: name,
|
||||
field_label: primary,
|
||||
value: formatOptionValue({ type: name }),
|
||||
};
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
private _customNameOption = memoizeOne((text: string) => ({
|
||||
primary: this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
),
|
||||
secondary: `"${text}"`,
|
||||
field_label: text,
|
||||
value: formatOptionValue({ type: "text", text }),
|
||||
}));
|
||||
|
||||
private _formatItem = (item: EntityNameItem) => {
|
||||
if (item.type === "text") {
|
||||
return `"${item.text}"`;
|
||||
@@ -214,7 +242,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
allow-custom-value
|
||||
item-id-path="value"
|
||||
item-value-path="value"
|
||||
item-label-path="primary"
|
||||
item-label-path="field_label"
|
||||
.renderer=${rowRenderer}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._comboBoxValueChanged}
|
||||
@@ -286,14 +314,13 @@ export class HaEntityNamePicker extends LitElement {
|
||||
const initialItem =
|
||||
this._editIndex != null ? this._value[this._editIndex] : undefined;
|
||||
|
||||
const initialValue = initialItem
|
||||
? initialItem.type === "text"
|
||||
? initialItem.text
|
||||
: initialItem.type
|
||||
: "";
|
||||
const initialValue = initialItem ? formatOptionValue(initialItem) : "";
|
||||
|
||||
const filteredItems = this._filterSelectedOptions(options, initialValue);
|
||||
|
||||
if (initialItem && initialItem.type === "text" && initialItem.text) {
|
||||
filteredItems.push(this._customNameOption(initialItem.text));
|
||||
}
|
||||
this._comboBox.filteredItems = filteredItems;
|
||||
this._comboBox.setInputValue(initialValue);
|
||||
} else {
|
||||
@@ -326,11 +353,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
const currentItem =
|
||||
this._editIndex != null ? this._value[this._editIndex] : undefined;
|
||||
|
||||
const currentValue = currentItem
|
||||
? currentItem.type === "text"
|
||||
? currentItem.text
|
||||
: currentItem.type
|
||||
: "";
|
||||
const currentValue = currentItem ? formatOptionValue(currentItem) : "";
|
||||
|
||||
this._comboBox.filteredItems = this._filterSelectedOptions(
|
||||
options,
|
||||
@@ -352,6 +375,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
|
||||
const filteredItems = fuse.search(filter).map((result) => result.item);
|
||||
|
||||
filteredItems.push(this._customNameOption(input));
|
||||
this._comboBox.filteredItems = filteredItems;
|
||||
}
|
||||
|
||||
@@ -385,9 +409,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: EntityNameItem = KNOWN_TYPES.has(value as any)
|
||||
? { type: value as EntityNameType }
|
||||
: { type: "text", text: value };
|
||||
const item: EntityNameItem = parseOptionValue(value);
|
||||
|
||||
const newValue = [...this._value];
|
||||
|
||||
|
@@ -2,13 +2,26 @@ import { TopAppBarFixedBase } from "@material/mwc-top-app-bar-fixed/mwc-top-app-
|
||||
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
|
||||
import { haStyleViewTransitions } from "../resources/styles";
|
||||
|
||||
@customElement("ha-top-app-bar-fixed")
|
||||
export class HaTopAppBarFixed extends TopAppBarFixedBase {
|
||||
export class HaTopAppBarFixed extends ViewTransitionMixin(TopAppBarFixedBase) {
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "content-loading" })
|
||||
public contentLoading = true;
|
||||
|
||||
protected onLoadTransition(): void {
|
||||
// Trigger the transition when content is slotted
|
||||
this.startViewTransition(() => {
|
||||
this.contentLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
styles,
|
||||
haStyleViewTransitions,
|
||||
css`
|
||||
header {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
@@ -23,6 +36,10 @@ export class HaTopAppBarFixed extends TopAppBarFixedBase {
|
||||
);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
view-transition-name: layout-fade-in;
|
||||
}
|
||||
:host([content-loading]) .mdc-top-app-bar--fixed-adjust {
|
||||
opacity: 0;
|
||||
}
|
||||
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
|
@@ -7,17 +7,18 @@ import type { MDCTopAppBarAdapter } from "@material/top-app-bar/adapter";
|
||||
import { strings } from "@material/top-app-bar/constants";
|
||||
import MDCFixedTopAppBarFoundation from "@material/top-app-bar/fixed/foundation";
|
||||
import { html, css, nothing } from "lit";
|
||||
import { property, query, customElement } from "lit/decorators";
|
||||
import { property, query, customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
|
||||
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
|
||||
|
||||
export const passiveEventOptionsIfSupported = supportsPassiveEventListener
|
||||
? { passive: true }
|
||||
: undefined;
|
||||
|
||||
@customElement("ha-two-pane-top-app-bar-fixed")
|
||||
export class TopAppBarBaseBase extends BaseElement {
|
||||
export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
|
||||
protected override mdcFoundation!: MDCFixedTopAppBarFoundation;
|
||||
|
||||
protected override mdcFoundationClass = MDCFixedTopAppBarFoundation;
|
||||
@@ -48,6 +49,15 @@ export class TopAppBarBaseBase extends BaseElement {
|
||||
|
||||
@query(".pane .ha-scrollbar") private _paneElement?: HTMLElement;
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
protected onLoadTransition(): void {
|
||||
// Trigger the transition when content is slotted
|
||||
this.startViewTransition(() => {
|
||||
this._loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@property({ attribute: false, type: Object })
|
||||
get scrollTarget() {
|
||||
return this._scrollTarget || window;
|
||||
@@ -144,7 +154,12 @@ export class TopAppBarBaseBase extends BaseElement {
|
||||
: nothing}
|
||||
<div class="main">
|
||||
${this.pane ? html`<div class="shadow-container"></div>` : nothing}
|
||||
<div class="content">
|
||||
<div
|
||||
class=${classMap({
|
||||
content: true,
|
||||
loading: !this._loaded,
|
||||
})}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,6 +260,7 @@ export class TopAppBarBaseBase extends BaseElement {
|
||||
static override styles = [
|
||||
styles,
|
||||
haStyleScrollbar,
|
||||
haStyleViewTransitions,
|
||||
css`
|
||||
header {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
@@ -341,6 +357,10 @@ export class TopAppBarBaseBase extends BaseElement {
|
||||
.mdc-top-app-bar--pane .content {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
view-transition-name: layout-fade-in;
|
||||
}
|
||||
.content.loading {
|
||||
opacity: 0;
|
||||
}
|
||||
.mdc-top-app-bar__title {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
|
@@ -37,6 +37,7 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
view-transition-name: layout-fade-out;
|
||||
}
|
||||
#ha-launch-screen svg {
|
||||
width: 112px;
|
||||
|
@@ -61,6 +61,7 @@ class HassLoadingScreen extends LitElement {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-color: var(--primary-background-color);
|
||||
view-transition-name: layout-fade-out;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
|
@@ -3,6 +3,7 @@ import { ReactiveElement } from "lit";
|
||||
import { property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
|
||||
import type { Route } from "../types";
|
||||
|
||||
const extractPage = (path: string, defaultPage: string) => {
|
||||
@@ -43,7 +44,7 @@ export interface RouterOptions {
|
||||
// Time to wait for code to load before we show loading screen.
|
||||
const LOADING_SCREEN_THRESHOLD = 400; // ms
|
||||
|
||||
export class HassRouterPage extends ReactiveElement {
|
||||
export class HassRouterPage extends ViewTransitionMixin(ReactiveElement) {
|
||||
@property({ attribute: false }) public route?: Route;
|
||||
|
||||
protected routerOptions!: RouterOptions;
|
||||
@@ -310,16 +311,19 @@ export class HassRouterPage extends ReactiveElement {
|
||||
page: string,
|
||||
routeOptions: RouteOptions
|
||||
) {
|
||||
if (this.lastChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
this.startViewTransition(() => {
|
||||
if (this.lastChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
|
||||
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
|
||||
this.updatePageEl(panelEl);
|
||||
this.appendChild(panelEl);
|
||||
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
|
||||
(panelEl as HTMLElement).style.viewTransitionName = "layout-fade-in";
|
||||
this.updatePageEl(panelEl);
|
||||
this.appendChild(panelEl);
|
||||
|
||||
if (routerOptions.cacheAll || routeOptions.cache) {
|
||||
this._cache[page] = panelEl;
|
||||
}
|
||||
if (routerOptions.cacheAll || routeOptions.cache) {
|
||||
this._cache[page] = panelEl;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,17 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { goBack } from "../common/navigate";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
|
||||
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@customElement("hass-subpage")
|
||||
class HassSubpage extends LitElement {
|
||||
class HassSubpage extends ViewTransitionMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public header?: string;
|
||||
@@ -24,9 +26,18 @@ class HassSubpage extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public supervisor = false;
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
// @ts-ignore
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
protected onLoadTransition(): void {
|
||||
// Trigger the transition when content is slotted
|
||||
this.startViewTransition(() => {
|
||||
this._loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="toolbar">
|
||||
@@ -60,7 +71,14 @@ class HassSubpage extends LitElement {
|
||||
<slot name="toolbar-icon"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
|
||||
<div
|
||||
class=${classMap({
|
||||
content: true,
|
||||
"ha-scrollbar": true,
|
||||
loading: !this._loaded,
|
||||
})}
|
||||
@scroll=${this._saveScrollPos}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div id="fab">
|
||||
@@ -85,6 +103,7 @@ class HassSubpage extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
haStyleViewTransitions,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -167,6 +186,10 @@ class HassSubpage extends LitElement {
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
view-transition-name: layout-fade-in;
|
||||
}
|
||||
.content.loading {
|
||||
opacity: 0;
|
||||
}
|
||||
:host([narrow]) .content {
|
||||
width: calc(
|
||||
|
@@ -11,7 +11,8 @@ import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
import "../components/ha-tab";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
|
||||
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../types";
|
||||
|
||||
export interface PageNavigation {
|
||||
@@ -29,7 +30,7 @@ export interface PageNavigation {
|
||||
}
|
||||
|
||||
@customElement("hass-tabs-subpage")
|
||||
class HassTabsSubpage extends LitElement {
|
||||
class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public supervisor = false;
|
||||
@@ -61,9 +62,18 @@ class HassTabsSubpage extends LitElement {
|
||||
|
||||
@state() private _activeTab?: PageNavigation;
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
// @ts-ignore
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
protected onLoadTransition(): void {
|
||||
// Trigger the transition when content is slotted
|
||||
this.startViewTransition(() => {
|
||||
this._loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
private _getTabs = memoizeOne(
|
||||
(
|
||||
tabs: PageNavigation[],
|
||||
@@ -185,7 +195,12 @@ class HassTabsSubpage extends LitElement {
|
||||
</div>`
|
||||
: nothing}
|
||||
<div
|
||||
class="content ha-scrollbar ${classMap({ tabs: showTabs })}"
|
||||
class=${classMap({
|
||||
content: true,
|
||||
"ha-scrollbar": true,
|
||||
tabs: showTabs,
|
||||
loading: !this._loaded,
|
||||
})}
|
||||
@scroll=${this._saveScrollPos}
|
||||
>
|
||||
<slot></slot>
|
||||
@@ -214,6 +229,7 @@ class HassTabsSubpage extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
haStyleViewTransitions,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -332,6 +348,10 @@ class HassTabsSubpage extends LitElement {
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
view-transition-name: layout-fade-in;
|
||||
}
|
||||
.content.loading {
|
||||
opacity: 0;
|
||||
}
|
||||
:host([narrow]) .content {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
|
94
src/mixins/view-transition-mixin.ts
Normal file
94
src/mixins/view-transition-mixin.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { PropertyValues, ReactiveElement } from "lit";
|
||||
|
||||
type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
|
||||
|
||||
export const ViewTransitionMixin = <
|
||||
T extends AbstractConstructor<ReactiveElement>,
|
||||
>(
|
||||
superClass: T
|
||||
) => {
|
||||
abstract class ViewTransitionClass extends superClass {
|
||||
private _slot?: HTMLSlotElement;
|
||||
|
||||
private _transitionTriggered = false;
|
||||
|
||||
/**
|
||||
* Trigger a view transition if supported by the browser
|
||||
* @param updateCallback - Callback function that updates the DOM
|
||||
* @returns Promise that resolves when the transition is complete
|
||||
*/
|
||||
protected async startViewTransition(
|
||||
updateCallback: () => void | Promise<void>
|
||||
): Promise<void> {
|
||||
if (
|
||||
!document.startViewTransition ||
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
// Fallback: update without view transition
|
||||
await updateCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(async () => {
|
||||
await updateCallback();
|
||||
});
|
||||
|
||||
try {
|
||||
await transition.finished;
|
||||
} catch (_error) {
|
||||
// View transition skipped
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional callback to execute during the load transition
|
||||
*/
|
||||
protected onLoadTransition?(): void;
|
||||
|
||||
/**
|
||||
* Check if slot has content and trigger transition if it does
|
||||
*/
|
||||
private _checkSlotContent = (): void => {
|
||||
// Guard against multiple slotchange events triggering the transition multiple times
|
||||
if (this._transitionTriggered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._slot) {
|
||||
const elements = this._slot.assignedElements();
|
||||
if (elements.length > 0) {
|
||||
this._transitionTriggered = true;
|
||||
this.onLoadTransition?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically apply view transition on first render
|
||||
* @param changedProperties - Properties that changed
|
||||
*/
|
||||
protected firstUpdated(changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
||||
// Wait for slotted content to be ready, then trigger transition
|
||||
this._slot = this.shadowRoot?.querySelector(
|
||||
"slot:not([name])"
|
||||
) as HTMLSlotElement | undefined;
|
||||
if (this._slot) {
|
||||
this._checkSlotContent();
|
||||
this._slot.addEventListener("slotchange", this._checkSlotContent);
|
||||
} else {
|
||||
// Start transition immediately if no slot is found
|
||||
this.onLoadTransition?.();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
if (this._slot) {
|
||||
this._slot.removeEventListener("slotchange", this._checkSlotContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ViewTransitionClass;
|
||||
};
|
@@ -1,4 +1,4 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { mdiPencil, mdiDownload } from "@mdi/js";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -6,7 +6,6 @@ import "../../components/ha-menu-button";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-list-item";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import "../../components/ha-alert";
|
||||
import type { LovelaceConfig } from "../../data/lovelace/config/types";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -22,7 +21,6 @@ import type {
|
||||
GasSourceTypeEnergyPreference,
|
||||
WaterSourceTypeEnergyPreference,
|
||||
DeviceConsumptionEnergyPreference,
|
||||
EnergyCollection,
|
||||
} from "../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
@@ -32,27 +30,12 @@ import {
|
||||
import { fileDownload } from "../../util/file_download";
|
||||
import type { StatisticValue } from "../../data/recorder";
|
||||
|
||||
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
|
||||
|
||||
const ENERGY_LOVELACE_CONFIG: LovelaceConfig = {
|
||||
views: [
|
||||
{
|
||||
strategy: {
|
||||
type: "energy-overview",
|
||||
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
},
|
||||
},
|
||||
{
|
||||
strategy: {
|
||||
type: "energy",
|
||||
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
},
|
||||
path: "electricity",
|
||||
},
|
||||
{
|
||||
type: "panel",
|
||||
path: "setup",
|
||||
cards: [{ type: "custom:energy-setup-wizard-card" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -63,30 +46,13 @@ class PanelEnergy extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _viewIndex = 0;
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@property({ attribute: false }) public route?: {
|
||||
path: string;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
private _energyCollection?: EnergyCollection;
|
||||
|
||||
get _viewPath(): string | undefined {
|
||||
const viewPath: string | undefined = this.route!.path.split("/")[1];
|
||||
return viewPath ? decodeURI(viewPath) : undefined;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._loadPrefs();
|
||||
}
|
||||
|
||||
public async willUpdate(changedProps: PropertyValues) {
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.hasUpdated) {
|
||||
this.hass.loadFragmentTranslation("lovelace");
|
||||
}
|
||||
@@ -95,37 +61,10 @@ class PanelEnergy extends LitElement {
|
||||
}
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (oldHass?.locale !== this.hass.locale) {
|
||||
await this._setLovelace();
|
||||
} else if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._reloadView();
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadPrefs() {
|
||||
if (this._viewPath === "setup") {
|
||||
await import("./cards/energy-setup-wizard-card");
|
||||
} else {
|
||||
this._energyCollection = getEnergyDataCollection(this.hass, {
|
||||
key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
});
|
||||
try {
|
||||
// Have to manually refresh here as we don't want to subscribe yet
|
||||
await this._energyCollection.refresh();
|
||||
} catch (err: any) {
|
||||
if (err.code === "not_found") {
|
||||
navigate("/energy/setup");
|
||||
}
|
||||
this._error = err.message;
|
||||
return;
|
||||
}
|
||||
const prefs = this._energyCollection.prefs!;
|
||||
if (
|
||||
prefs.device_consumption.length === 0 &&
|
||||
prefs.energy_sources.length === 0
|
||||
) {
|
||||
// No energy sources available, start from scratch
|
||||
navigate("/energy/setup");
|
||||
}
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._reloadView();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,28 +73,7 @@ class PanelEnergy extends LitElement {
|
||||
goBack();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._energyCollection?.prefs) {
|
||||
// Still loading
|
||||
return html`<div class="centered">
|
||||
<ha-spinner size="large"></ha-spinner>
|
||||
</div>`;
|
||||
}
|
||||
let viewPath = this._viewPath;
|
||||
const { prefs } = this._energyCollection;
|
||||
if (
|
||||
prefs.energy_sources.every((source) =>
|
||||
["grid", "solar", "battery"].includes(source.type)
|
||||
)
|
||||
) {
|
||||
// if only electricity sources, show electricity view directly
|
||||
viewPath = "electricity";
|
||||
}
|
||||
const viewIndex = Math.max(
|
||||
ENERGY_LOVELACE_CONFIG.views.findIndex((view) => view.path === viewPath),
|
||||
0
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="header">
|
||||
<div class="toolbar">
|
||||
@@ -181,7 +99,7 @@ class PanelEnergy extends LitElement {
|
||||
|
||||
<hui-energy-period-selector
|
||||
.hass=${this.hass}
|
||||
.collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
|
||||
collection-key="energy_dashboard"
|
||||
>
|
||||
${this.hass.user?.is_admin
|
||||
? html` <ha-list-item
|
||||
@@ -209,21 +127,12 @@ class PanelEnergy extends LitElement {
|
||||
.hass=${this.hass}
|
||||
@reload-energy-panel=${this._reloadView}
|
||||
>
|
||||
${this._error
|
||||
? html`<div class="centered">
|
||||
<ha-alert alert-type="error">
|
||||
An error occurred while fetching your energy preferences:
|
||||
${this._error}
|
||||
</ha-alert>
|
||||
</div>`
|
||||
: this._lovelace
|
||||
? html`<hui-view
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${viewIndex}
|
||||
></hui-view>`
|
||||
: nothing}
|
||||
<hui-view
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
></hui-view>
|
||||
</hui-view-container>
|
||||
`;
|
||||
}
|
||||
@@ -251,7 +160,9 @@ class PanelEnergy extends LitElement {
|
||||
|
||||
private async _dumpCSV(ev) {
|
||||
ev.stopPropagation();
|
||||
const energyData = this._energyCollection!;
|
||||
const energyData = getEnergyDataCollection(this.hass, {
|
||||
key: "energy_dashboard",
|
||||
});
|
||||
|
||||
if (!energyData.prefs || !energyData.state.stats) {
|
||||
return;
|
||||
@@ -548,11 +459,11 @@ class PanelEnergy extends LitElement {
|
||||
}
|
||||
|
||||
private _reloadView() {
|
||||
// Force strategy to be re-run by making a copy of the view
|
||||
// Force strategy to be re-run by make a copy of the view
|
||||
const config = this._lovelace!.config;
|
||||
this._lovelace = {
|
||||
...this._lovelace!,
|
||||
config: { ...config, views: config.views.map((view) => ({ ...view })) },
|
||||
config: { ...config, views: [{ ...config.views[0] }] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -654,13 +565,6 @@ class PanelEnergy extends LitElement {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.centered {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -1,193 +0,0 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
|
||||
|
||||
@customElement("energy-overview-view-strategy")
|
||||
export class EnergyViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
_config: LovelaceStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const view: LovelaceViewConfig = { type: "sections", sections: [] };
|
||||
|
||||
const collectionKey =
|
||||
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
|
||||
|
||||
const energyCollection = getEnergyDataCollection(hass, {
|
||||
key: collectionKey,
|
||||
});
|
||||
const prefs = energyCollection.prefs;
|
||||
|
||||
// No energy sources available
|
||||
if (
|
||||
!prefs ||
|
||||
(prefs.device_consumption.length === 0 &&
|
||||
prefs.energy_sources.length === 0)
|
||||
) {
|
||||
return view;
|
||||
}
|
||||
|
||||
const hasGrid = prefs.energy_sources.find(
|
||||
(source) =>
|
||||
source.type === "grid" &&
|
||||
(source.flow_from?.length || source.flow_to?.length)
|
||||
) as GridSourceTypeEnergyPreference;
|
||||
const hasReturn = hasGrid && hasGrid.flow_to.length;
|
||||
const hasSolar = prefs.energy_sources.some(
|
||||
(source) => source.type === "solar"
|
||||
);
|
||||
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
|
||||
const hasBattery = prefs.energy_sources.some(
|
||||
(source) => source.type === "battery"
|
||||
);
|
||||
const hasWater = prefs.energy_sources.some(
|
||||
(source) => source.type === "water"
|
||||
);
|
||||
|
||||
const energySection: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize("ui.panel.energy.overview.electricity"),
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/energy/electricity",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// Only include if we have a grid or battery.
|
||||
if (hasGrid || hasBattery) {
|
||||
energySection.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.energy_distribution_title"),
|
||||
type: "energy-distribution",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (prefs!.device_consumption.length > 0) {
|
||||
energySection.cards!.push({
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_top_consumers_title"
|
||||
),
|
||||
type: "energy-devices-graph",
|
||||
collection_key: collectionKey,
|
||||
max_devices: 5,
|
||||
});
|
||||
} else if (hasGrid) {
|
||||
const gauges: LovelaceCardConfig[] = [];
|
||||
// Only include if we have a grid source & return.
|
||||
if (hasReturn) {
|
||||
gauges.push({
|
||||
type: "energy-grid-neutrality-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
gauges.push({
|
||||
type: "energy-carbon-consumed-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
|
||||
// Only include if we have a solar source.
|
||||
if (hasSolar) {
|
||||
if (hasReturn) {
|
||||
gauges.push({
|
||||
type: "energy-solar-consumed-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
gauges.push({
|
||||
type: "energy-self-sufficiency-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
energySection.cards!.push({
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: true,
|
||||
cards: gauges,
|
||||
});
|
||||
}
|
||||
|
||||
view.sections!.push(energySection);
|
||||
|
||||
if (hasGrid || hasSolar || hasBattery || hasGas || hasWater) {
|
||||
view.sections!.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.energy.cards.energy_sources_table_title"
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "energy-sources-table",
|
||||
collection_key: collectionKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (hasGas) {
|
||||
view.sections!.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize("ui.panel.energy.overview.gas"),
|
||||
},
|
||||
{
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_gas_graph_title"
|
||||
),
|
||||
type: "energy-gas-graph",
|
||||
collection_key: collectionKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (hasWater) {
|
||||
view.sections!.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize("ui.panel.energy.overview.water"),
|
||||
},
|
||||
{
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_water_graph_title"
|
||||
),
|
||||
type: "energy-water-graph",
|
||||
collection_key: collectionKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"energy-overview-view-strategy": EnergyViewStrategy;
|
||||
}
|
||||
}
|
@@ -1,11 +1,25 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type {
|
||||
EnergyPreferences,
|
||||
GridSourceTypeEnergyPreference,
|
||||
} from "../../../data/energy";
|
||||
import { getEnergyPreferences } from "../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
|
||||
|
||||
const setupWizard = async (): Promise<LovelaceViewConfig> => {
|
||||
await import("../cards/energy-setup-wizard-card");
|
||||
return {
|
||||
type: "panel",
|
||||
cards: [
|
||||
{
|
||||
type: "custom:energy-setup-wizard-card",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@customElement("energy-view-strategy")
|
||||
export class EnergyViewStrategy extends ReactiveElement {
|
||||
@@ -15,23 +29,29 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const view: LovelaceViewConfig = { cards: [] };
|
||||
|
||||
const collectionKey =
|
||||
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
|
||||
let prefs: EnergyPreferences;
|
||||
|
||||
const energyCollection = getEnergyDataCollection(hass, {
|
||||
key: collectionKey,
|
||||
});
|
||||
const prefs = energyCollection.prefs;
|
||||
|
||||
// No energy sources available
|
||||
if (
|
||||
!prefs ||
|
||||
(prefs.device_consumption.length === 0 &&
|
||||
prefs.energy_sources.length === 0)
|
||||
) {
|
||||
try {
|
||||
prefs = await getEnergyPreferences(hass);
|
||||
} catch (err: any) {
|
||||
if (err.code === "not_found") {
|
||||
return setupWizard();
|
||||
}
|
||||
view.cards!.push({
|
||||
type: "markdown",
|
||||
content: `An error occurred while fetching your energy preferences: ${err.message}.`,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
// No energy sources available, start from scratch
|
||||
if (
|
||||
prefs!.device_consumption.length === 0 &&
|
||||
prefs!.energy_sources.length === 0
|
||||
) {
|
||||
return setupWizard();
|
||||
}
|
||||
|
||||
view.type = "sidebar";
|
||||
|
||||
const hasGrid = prefs.energy_sources.find(
|
||||
@@ -43,9 +63,13 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
const hasSolar = prefs.energy_sources.some(
|
||||
(source) => source.type === "solar"
|
||||
);
|
||||
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
|
||||
const hasBattery = prefs.energy_sources.some(
|
||||
(source) => source.type === "battery"
|
||||
);
|
||||
const hasWater = prefs.energy_sources.some(
|
||||
(source) => source.type === "water"
|
||||
);
|
||||
|
||||
view.cards!.push({
|
||||
type: "energy-compare",
|
||||
@@ -70,6 +94,24 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Only include if we have a gas source.
|
||||
if (hasGas) {
|
||||
view.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"),
|
||||
type: "energy-gas-graph",
|
||||
collection_key: "energy_dashboard",
|
||||
});
|
||||
}
|
||||
|
||||
// Only include if we have a water source.
|
||||
if (hasWater) {
|
||||
view.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"),
|
||||
type: "energy-water-graph",
|
||||
collection_key: "energy_dashboard",
|
||||
});
|
||||
}
|
||||
|
||||
// Only include if we have a grid or battery.
|
||||
if (hasGrid || hasBattery) {
|
||||
view.cards!.push({
|
||||
@@ -80,14 +122,13 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (hasGrid || hasSolar || hasBattery) {
|
||||
if (hasGrid || hasSolar || hasGas || hasWater || hasBattery) {
|
||||
view.cards!.push({
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_sources_table_title"
|
||||
),
|
||||
type: "energy-sources-table",
|
||||
collection_key: "energy_dashboard",
|
||||
types: ["grid", "solar", "battery"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,6 +170,20 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
|
||||
// Only include if we have at least 1 device in the config.
|
||||
if (prefs.device_consumption.length) {
|
||||
view.cards!.push({
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_devices_detail_graph_title"
|
||||
),
|
||||
type: "energy-devices-detail-graph",
|
||||
collection_key: "energy_dashboard",
|
||||
});
|
||||
view.cards!.push({
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_devices_graph_title"
|
||||
),
|
||||
type: "energy-devices-graph",
|
||||
collection_key: "energy_dashboard",
|
||||
});
|
||||
const showFloorsNAreas = !prefs.device_consumption.some(
|
||||
(d) => d.included_in_stat
|
||||
);
|
||||
@@ -139,20 +194,6 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
group_by_floor: showFloorsNAreas,
|
||||
group_by_area: showFloorsNAreas,
|
||||
});
|
||||
view.cards!.push({
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_devices_graph_title"
|
||||
),
|
||||
type: "energy-devices-graph",
|
||||
collection_key: "energy_dashboard",
|
||||
});
|
||||
view.cards!.push({
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_devices_detail_graph_title"
|
||||
),
|
||||
type: "energy-devices-detail-graph",
|
||||
collection_key: "energy_dashboard",
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
|
@@ -379,7 +379,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
show: true,
|
||||
position: "center",
|
||||
color: computedStyle.getPropertyValue("--secondary-text-color"),
|
||||
fontSize: computedStyle.getPropertyValue("--ha-font-size-m"),
|
||||
fontSize: computedStyle.getPropertyValue("--ha-font-size-l"),
|
||||
lineHeight: 24,
|
||||
fontWeight: "bold",
|
||||
formatter: `{a}\n${formatNumber(totalUsed, this.hass.locale)} kWh`,
|
||||
|
@@ -150,6 +150,11 @@ export interface EnergyCardBaseConfig extends LovelaceCardConfig {
|
||||
collection_key?: string;
|
||||
}
|
||||
|
||||
export interface EnergySummaryCardConfig extends EnergyCardBaseConfig {
|
||||
type: "energy-summary";
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface EnergyDistributionCardConfig extends EnergyCardBaseConfig {
|
||||
type: "energy-distribution";
|
||||
title?: string;
|
||||
|
@@ -72,7 +72,8 @@ import {
|
||||
} from "../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showShortcutsDialog } from "../../dialogs/shortcuts/show-shortcuts-dialog";
|
||||
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import { ViewTransitionMixin } from "../../mixins/view-transition-mixin";
|
||||
import { haStyle, haStyleViewTransitions } from "../../resources/styles";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import { showToast } from "../../util/toast";
|
||||
@@ -114,7 +115,7 @@ interface SubActionItem {
|
||||
}
|
||||
|
||||
@customElement("hui-root")
|
||||
class HUIRoot extends LitElement {
|
||||
class HUIRoot extends ViewTransitionMixin(LitElement) {
|
||||
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -130,6 +131,8 @@ class HUIRoot extends LitElement {
|
||||
|
||||
@state() private _curView?: number | "hass-unused-entities";
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
private _viewCache?: Record<string, HUIView>;
|
||||
|
||||
private _viewScrollPositions: Record<string, number> = {};
|
||||
@@ -153,6 +156,10 @@ class HUIRoot extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
protected onLoadTransition(): void {
|
||||
this._loaded = true;
|
||||
}
|
||||
|
||||
private _renderActionItems(): TemplateResult {
|
||||
const result: TemplateResult[] = [];
|
||||
if (this._editMode) {
|
||||
@@ -493,6 +500,7 @@ class HUIRoot extends LitElement {
|
||||
class=${classMap({
|
||||
"edit-mode": this._editMode,
|
||||
narrow: this.narrow,
|
||||
loading: !this._loaded,
|
||||
})}
|
||||
>
|
||||
<div class="header">
|
||||
@@ -1162,43 +1170,45 @@ class HUIRoot extends LitElement {
|
||||
// Recreate a new element to clear the applied themes.
|
||||
const root = this._viewRoot;
|
||||
|
||||
if (root.lastChild) {
|
||||
root.removeChild(root.lastChild);
|
||||
}
|
||||
this.startViewTransition(() => {
|
||||
if (root.lastChild) {
|
||||
root.removeChild(root.lastChild);
|
||||
}
|
||||
|
||||
if (viewIndex === "hass-unused-entities") {
|
||||
const unusedEntities = document.createElement("hui-unused-entities");
|
||||
// Wait for promise to resolve so that the element has been upgraded.
|
||||
import("./editor/unused-entities/hui-unused-entities").then(() => {
|
||||
unusedEntities.hass = this.hass!;
|
||||
unusedEntities.lovelace = this.lovelace!;
|
||||
unusedEntities.narrow = this.narrow;
|
||||
});
|
||||
root.appendChild(unusedEntities);
|
||||
return;
|
||||
}
|
||||
if (viewIndex === "hass-unused-entities") {
|
||||
const unusedEntities = document.createElement("hui-unused-entities");
|
||||
// Wait for promise to resolve so that the element has been upgraded.
|
||||
import("./editor/unused-entities/hui-unused-entities").then(() => {
|
||||
unusedEntities.hass = this.hass!;
|
||||
unusedEntities.lovelace = this.lovelace!;
|
||||
unusedEntities.narrow = this.narrow;
|
||||
});
|
||||
root.appendChild(unusedEntities);
|
||||
return;
|
||||
}
|
||||
|
||||
let view;
|
||||
const viewConfig = this.config.views[viewIndex];
|
||||
let view;
|
||||
const viewConfig = this.config.views[viewIndex];
|
||||
|
||||
if (!viewConfig) {
|
||||
this.lovelace!.setEditMode(true);
|
||||
return;
|
||||
}
|
||||
if (!viewConfig) {
|
||||
this.lovelace!.setEditMode(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && this._viewCache![viewIndex]) {
|
||||
view = this._viewCache![viewIndex];
|
||||
} else {
|
||||
view = document.createElement("hui-view");
|
||||
view.index = viewIndex;
|
||||
this._viewCache![viewIndex] = view;
|
||||
}
|
||||
if (!force && this._viewCache![viewIndex]) {
|
||||
view = this._viewCache![viewIndex];
|
||||
} else {
|
||||
view = document.createElement("hui-view");
|
||||
view.index = viewIndex;
|
||||
this._viewCache![viewIndex] = view;
|
||||
}
|
||||
|
||||
view.lovelace = this.lovelace;
|
||||
view.hass = this.hass;
|
||||
view.narrow = this.narrow;
|
||||
view.lovelace = this.lovelace;
|
||||
view.hass = this.hass;
|
||||
view.narrow = this.narrow;
|
||||
|
||||
root.appendChild(view);
|
||||
root.appendChild(view);
|
||||
});
|
||||
}
|
||||
|
||||
private _openShortcutDialog(ev: Event) {
|
||||
@@ -1209,12 +1219,21 @@ class HUIRoot extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleViewTransitions,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
::view-transition-new(hui-root-container) {
|
||||
animation: fade-in var(--ha-animation-layout-duration) ease-out;
|
||||
animation-delay: var(--ha-animation-layout-delay-base);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: var(--app-header-background-color);
|
||||
color: var(--app-header-text-color, white);
|
||||
@@ -1403,6 +1422,10 @@ class HUIRoot extends LitElement {
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-inline-end: var(--safe-area-inset-right);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
view-transition-name: hui-root-container;
|
||||
}
|
||||
.loading hui-view-container {
|
||||
opacity: 0;
|
||||
}
|
||||
.narrow hui-view-container {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
@@ -1411,6 +1434,7 @@ class HUIRoot extends LitElement {
|
||||
hui-view-container > * {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
view-transition-name: layout-fade-in;
|
||||
}
|
||||
/**
|
||||
* In edit mode we have the tab bar on a new line *
|
||||
|
@@ -38,8 +38,6 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
|
||||
view: {
|
||||
"original-states": () =>
|
||||
import("./original-states/original-states-view-strategy"),
|
||||
"energy-overview": () =>
|
||||
import("../../energy/strategies/energy-overview-view-strategy"),
|
||||
energy: () => import("../../energy/strategies/energy-view-strategy"),
|
||||
map: () => import("./map/map-view-strategy"),
|
||||
iframe: () => import("./iframe/iframe-view-strategy"),
|
||||
|
@@ -199,3 +199,56 @@ export const baseEntrypointStyles = css`
|
||||
width: 100vw;
|
||||
}
|
||||
`;
|
||||
|
||||
export const haStyleViewTransitions = css`
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* Prevent root cross-fade during view transitions (pseudo-element) */
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Elements leaving the view (loading screen) */
|
||||
::view-transition-group(layout-fade-out) {
|
||||
animation-duration: var(--ha-animation-layout-duration);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
::view-transition-old(layout-fade-out) {
|
||||
animation: fade-out var(--ha-animation-layout-duration) ease-out;
|
||||
}
|
||||
::view-transition-new(layout-fade-out) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* New content entering (panels, subpages)
|
||||
Uses base delay to be less abrupt and allow for elements to render */
|
||||
::view-transition-group(layout-fade-in) {
|
||||
animation-duration: var(--ha-animation-layout-duration);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
::view-transition-new(layout-fade-in) {
|
||||
animation: fade-in var(--ha-animation-layout-duration) ease-out;
|
||||
animation-delay: var(--ha-animation-layout-delay-base);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@@ -42,6 +42,17 @@ export const coreStyles = css`
|
||||
--ha-space-18: 72px;
|
||||
--ha-space-19: 76px;
|
||||
--ha-space-20: 80px;
|
||||
|
||||
/* Animation timing */
|
||||
--ha-animation-layout-duration: 350ms;
|
||||
--ha-animation-layout-delay-base: 100ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
--ha-animation-layout-duration: 0ms;
|
||||
--ha-animation-layout-delay-base: 0ms;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
@@ -667,7 +667,8 @@
|
||||
"floor_missing": "No floor assigned",
|
||||
"device_missing": "No related device"
|
||||
},
|
||||
"add": "Add"
|
||||
"add": "Add",
|
||||
"custom_name": "Custom name"
|
||||
},
|
||||
"entity-attribute-picker": {
|
||||
"attribute": "Attribute",
|
||||
@@ -9310,11 +9311,6 @@
|
||||
}
|
||||
},
|
||||
"energy": {
|
||||
"overview": {
|
||||
"electricity": "Electricity",
|
||||
"gas": "Gas",
|
||||
"water": "Water"
|
||||
},
|
||||
"download_data": "[%key:ui::panel::history::download_data%]",
|
||||
"configure": "[%key:ui::dialogs::quick-bar::commands::navigation::energy%]",
|
||||
"compare": {
|
||||
@@ -9344,8 +9340,7 @@
|
||||
"energy_sources_table_title": "Sources",
|
||||
"energy_devices_graph_title": "Individual devices total usage",
|
||||
"energy_devices_detail_graph_title": "Individual devices detail usage",
|
||||
"energy_sankey_title": "Energy flow",
|
||||
"energy_top_consumers_title": "Top consumers"
|
||||
"energy_sankey_title": "Energy flow"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
@@ -3,7 +3,20 @@ import { render } from "lit";
|
||||
|
||||
export const removeLaunchScreen = () => {
|
||||
const launchScreenElement = document.getElementById("ha-launch-screen");
|
||||
if (launchScreenElement) {
|
||||
if (!launchScreenElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use View Transition API if available and user doesn't prefer reduced motion
|
||||
if (
|
||||
document.startViewTransition &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
document.startViewTransition(() => {
|
||||
launchScreenElement.parentElement!.removeChild(launchScreenElement);
|
||||
});
|
||||
} else {
|
||||
// Fallback: Direct removal without transition
|
||||
launchScreenElement.parentElement!.removeChild(launchScreenElement);
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user