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; +};