Compare commits

..

41 Commits

Author SHA1 Message Date
Aidan Timson
5dc95faaa9 Comments 2025-10-16 16:37:35 +01:00
Aidan Timson
28447b107d Add guard 2025-10-16 16:29:14 +01:00
Aidan Timson
e664b06d9f Fix leak 2025-10-16 16:27:37 +01:00
Aidan Timson
5502940814 Simplify 2025-10-16 16:21:50 +01:00
Aidan Timson
ebbca36212 Remove duplicate transitions (non view transitions) 2025-10-16 16:15:53 +01:00
Aidan Timson
25f1937860 Remove unused code 2025-10-16 12:51:59 +01:00
Aidan Timson
0a1086fb87 Cleanup 2025-10-16 12:38:45 +01:00
Aidan Timson
0b9ed858b9 Move duplicated logic into mixin 2025-10-16 12:32:05 +01:00
Aidan Timson
6d3aa9d1a2 Flip logic 2025-10-16 12:29:38 +01:00
Aidan Timson
7de89f7147 Setup other layouts 2025-10-16 12:17:34 +01:00
Aidan Timson
a7a093833f Fix 2025-10-16 11:08:21 +01:00
Aidan Timson
a02487a377 Fix 2025-10-16 11:05:56 +01:00
Aidan Timson
fcf1618f5c Cleanup 2025-10-16 10:58:04 +01:00
Aidan Timson
fc62a1de55 Fix 2025-10-16 10:57:49 +01:00
Aidan Timson
9cde2fa533 Rename 2025-10-16 10:03:27 +01:00
Aidan Timson
74065ad25d Cleanup 2025-10-16 10:01:17 +01:00
Aidan Timson
e7ed7b926d Cleanup 2025-10-16 09:57:22 +01:00
Aidan Timson
589e21aa4c Rename, zero for reduced motion 2025-10-16 09:56:39 +01:00
Aidan Timson
4d1295ebaf Show on loaded 2025-10-16 09:12:17 +01:00
Aidan Timson
364834001b Rename 2025-10-16 08:48:58 +01:00
Aidan Timson
3ff72bb0d9 Fade out launch screen 2025-10-16 08:46:36 +01:00
Aidan Timson
f3621f8e83 Allow transition name to be provided by caller 2025-10-15 12:36:59 +01:00
Aidan Timson
fb71f88ea0 Use generic transition names 2025-10-15 12:22:43 +01:00
Aidan Timson
cd0398c3ba Switch to mixin 2025-10-15 12:10:31 +01:00
Aidan Timson
0c57fc6b58 Cleanup 2025-10-15 11:50:15 +01:00
Aidan Timson
aef25d0606 Order 2025-10-15 11:48:49 +01:00
Aidan Timson
ab1c736e28 Revert 2025-10-15 11:46:40 +01:00
Aidan Timson
7a16c515bb Remove sidebar code 2025-10-15 11:46:40 +01:00
Aidan Timson
d72ace6f45 POC: view transitions 2025-10-15 11:46:40 +01:00
Aidan Timson
ad94d988bd Respect reduced motion 2025-10-15 11:46:40 +01:00
Aidan Timson
e646bc31d2 Add to hui views 2025-10-15 11:46:40 +01:00
Aidan Timson
60f7a319a2 Add animations 2025-10-15 11:46:40 +01:00
Aidan Timson
fdd268036a Cleanup 2025-10-15 11:46:39 +01:00
Aidan Timson
58eb72d970 Use index based delay 2025-10-15 11:46:39 +01:00
Aidan Timson
9227a78a15 Faster 2025-10-15 11:46:39 +01:00
Aidan Timson
fdab6d0f3c Fade in menu button 2025-10-15 11:46:39 +01:00
Aidan Timson
a82f969a82 Move 2025-10-15 11:46:39 +01:00
Aidan Timson
d78c8034d0 Cap stagger at 8 items 2025-10-15 11:46:39 +01:00
Aidan Timson
7eba7664e7 Animate sidebar 2025-10-15 11:46:39 +01:00
Aidan Timson
528a1a3477 Set base themable animation durations 2025-10-15 11:46:39 +01:00
Aidan Timson
e5df96ebb1 Create fade in slide down shared animation 2025-10-15 11:46:39 +01:00
12 changed files with 337 additions and 56 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
) {
this.startViewTransition(() => {
if (this.lastChild) {
this.removeChild(this.lastChild);
}
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;
}
});
}
}

View File

@@ -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(

View File

@@ -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);

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

View File

@@ -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,6 +1170,7 @@ class HUIRoot extends LitElement {
// Recreate a new element to clear the applied themes.
const root = this._viewRoot;
this.startViewTransition(() => {
if (root.lastChild) {
root.removeChild(root.lastChild);
}
@@ -1199,6 +1208,7 @@ class HUIRoot extends LitElement {
view.narrow = this.narrow;
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 *

View File

@@ -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);
}
}
`;

View File

@@ -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;
}
}
`;

View File

@@ -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);
}
};