diff --git a/src/components/ha-top-app-bar-fixed.ts b/src/components/ha-top-app-bar-fixed.ts
index 9cf9214e70..5bbe69a780 100644
--- a/src/components/ha-top-app-bar-fixed.ts
+++ b/src/components/ha-top-app-bar-fixed.ts
@@ -2,13 +2,54 @@ 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;
+ });
+ }
+
+ protected override enableLoadTransition(): boolean {
+ // Disable automatic transition, we'll trigger it manually
+ return false;
+ }
+
+ protected override firstUpdated() {
+ super.firstUpdated();
+ // Wait for slotted content to be ready
+ const slot = this.shadowRoot?.querySelector("slot:not([name])");
+ if (slot) {
+ const checkContent = () => {
+ const nodes = (slot as HTMLSlotElement).assignedNodes({
+ flatten: true,
+ });
+ if (nodes.length > 0) {
+ this.onLoadTransition();
+ }
+ };
+ // Check immediately in case content is already there
+ checkContent();
+ // Also listen for slotchange
+ slot.addEventListener("slotchange", checkContent, { once: true });
+ } else {
+ // No slot, just trigger immediately
+ this.onLoadTransition();
+ }
+ }
+
static override styles = [
styles,
+ haStyleViewTransitions,
css`
header {
padding-top: var(--safe-area-inset-top);
@@ -23,6 +64,11 @@ 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;
+ transition: opacity var(--ha-animation-layout-duration) ease-out;
+ }
+ :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);
diff --git a/src/components/ha-two-pane-top-app-bar-fixed.ts b/src/components/ha-two-pane-top-app-bar-fixed.ts
index b3000539d2..57e8e111f9 100644
--- a/src/components/ha-two-pane-top-app-bar-fixed.ts
+++ b/src/components/ha-two-pane-top-app-bar-fixed.ts
@@ -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,20 @@ 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;
+ });
+ }
+
+ protected enableLoadTransition(): boolean {
+ // Disable automatic transition, we'll trigger it manually
+ return false;
+ }
+
@property({ attribute: false, type: Object })
get scrollTarget() {
return this._scrollTarget || window;
@@ -144,7 +159,12 @@ export class TopAppBarBaseBase extends BaseElement {
: nothing}
${this.pane ? html`
` : nothing}
-
@@ -235,6 +255,26 @@ export class TopAppBarBaseBase extends BaseElement {
super.firstUpdated();
this.updateRootPosition();
this.registerListeners();
+
+ // Wait for slotted content to be ready for view transition
+ const slot = this.shadowRoot?.querySelector("slot:not([name])");
+ if (slot) {
+ const checkContent = () => {
+ const nodes = (slot as HTMLSlotElement).assignedNodes({
+ flatten: true,
+ });
+ if (nodes.length > 0) {
+ this.onLoadTransition();
+ }
+ };
+ // Check immediately in case content is already there
+ checkContent();
+ // Also listen for slotchange
+ slot.addEventListener("slotchange", checkContent, { once: true });
+ } else {
+ // No slot, just trigger immediately
+ this.onLoadTransition();
+ }
}
override disconnectedCallback() {
@@ -245,6 +285,7 @@ export class TopAppBarBaseBase extends BaseElement {
static override styles = [
styles,
haStyleScrollbar,
+ haStyleViewTransitions,
css`
header {
padding-top: var(--safe-area-inset-top);
@@ -341,6 +382,11 @@ export class TopAppBarBaseBase extends BaseElement {
.mdc-top-app-bar--pane .content {
height: 100%;
overflow: auto;
+ view-transition-name: layout-fade-in;
+ transition: opacity var(--ha-animation-layout-duration) ease-out;
+ }
+ .content.loading {
+ opacity: 0;
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
diff --git a/src/layouts/hass-router-page.ts b/src/layouts/hass-router-page.ts
index 0b3eee8ab8..847a88b987 100644
--- a/src/layouts/hass-router-page.ts
+++ b/src/layouts/hass-router-page.ts
@@ -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;
+ }
+ });
}
}
diff --git a/src/layouts/hass-subpage.ts b/src/layouts/hass-subpage.ts
index 5defc16322..280f1da897 100644
--- a/src/layouts/hass-subpage.ts
+++ b/src/layouts/hass-subpage.ts
@@ -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,46 @@ 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 override enableLoadTransition(): boolean {
+ // Disable automatic transition, we'll trigger it manually
+ return false;
+ }
+
+ protected override firstUpdated(changedProps) {
+ super.firstUpdated(changedProps);
+ // Wait for slotted content to be ready
+ const slot = this.shadowRoot?.querySelector("slot:not([name])");
+ if (slot) {
+ const checkContent = () => {
+ const nodes = (slot as HTMLSlotElement).assignedNodes({
+ flatten: true,
+ });
+ if (nodes.length > 0) {
+ this.onLoadTransition();
+ }
+ };
+ // Check immediately in case content is already there
+ checkContent();
+ // Also listen for slotchange
+ slot.addEventListener("slotchange", checkContent, { once: true });
+ } else {
+ // No slot, just trigger immediately
+ this.onLoadTransition();
+ }
+ }
+
protected render(): TemplateResult {
return html`
@@ -60,7 +99,14 @@ class HassSubpage extends LitElement {
-
+
@@ -85,6 +131,7 @@ class HassSubpage extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
+ haStyleViewTransitions,
css`
:host {
display: block;
@@ -167,6 +214,11 @@ class HassSubpage extends LitElement {
overflow-y: auto;
overflow: auto;
-webkit-overflow-scrolling: touch;
+ view-transition-name: layout-fade-in;
+ transition: opacity var(--ha-animation-layout-duration) ease-out;
+ }
+ .content.loading {
+ opacity: 0;
}
:host([narrow]) .content {
width: calc(
diff --git a/src/layouts/hass-tabs-subpage.ts b/src/layouts/hass-tabs-subpage.ts
index 7679ebe33c..a94ef06914 100644
--- a/src/layouts/hass-tabs-subpage.ts
+++ b/src/layouts/hass-tabs-subpage.ts
@@ -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,46 @@ 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;
+ });
+ }
+
+ protected override enableLoadTransition(): boolean {
+ // Disable automatic transition, we'll trigger it manually
+ return false;
+ }
+
+ protected override firstUpdated(changedProps) {
+ super.firstUpdated(changedProps);
+ // Wait for slotted content to be ready
+ const slot = this.shadowRoot?.querySelector("slot:not([name])");
+ if (slot) {
+ const checkContent = () => {
+ const nodes = (slot as HTMLSlotElement).assignedNodes({
+ flatten: true,
+ });
+ if (nodes.length > 0) {
+ this.onLoadTransition();
+ }
+ };
+ // Check immediately in case content is already there
+ checkContent();
+ // Also listen for slotchange
+ slot.addEventListener("slotchange", checkContent, { once: true });
+ } else {
+ // No slot, just trigger immediately
+ this.onLoadTransition();
+ }
+ }
+
private _getTabs = memoizeOne(
(
tabs: PageNavigation[],
@@ -185,7 +223,12 @@ class HassTabsSubpage extends LitElement {
`
: nothing}
@@ -214,6 +257,7 @@ class HassTabsSubpage extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
+ haStyleViewTransitions,
css`
:host {
display: block;
@@ -332,6 +376,11 @@ class HassTabsSubpage extends LitElement {
margin-bottom: var(--safe-area-inset-bottom);
overflow: auto;
-webkit-overflow-scrolling: touch;
+ view-transition-name: layout-fade-in;
+ transition: opacity var(--ha-animation-layout-duration) ease-out;
+ }
+ .content.loading {
+ opacity: 0;
}
:host([narrow]) .content {
margin-left: var(--safe-area-inset-left);
diff --git a/src/mixins/view-transition-mixin.ts b/src/mixins/view-transition-mixin.ts
index 5ac7a5de55..50bd17b96c 100644
--- a/src/mixins/view-transition-mixin.ts
+++ b/src/mixins/view-transition-mixin.ts
@@ -1,10 +1,13 @@
-import type { LitElement, PropertyValues } from "lit";
-import type { Constructor } from "../types";
+import type { PropertyValues, ReactiveElement } from "lit";
-export const ViewTransitionMixin = >(
+type AbstractConstructor = abstract new (...args: any[]) => T;
+
+export const ViewTransitionMixin = <
+ T extends AbstractConstructor,
+>(
superClass: T
-) =>
- class ViewTransitionClass extends superClass {
+) => {
+ abstract class ViewTransitionClass extends superClass {
/**
* Trigger a view transition if supported by the browser
* @param updateCallback - Callback function that updates the DOM
@@ -71,4 +74,6 @@ export const ViewTransitionMixin = >(
});
});
}
- };
+ }
+ return ViewTransitionClass;
+};