diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 0dcec1995b..96aa4d188b 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -213,11 +213,6 @@ mat-sidenav { } .app-entrance { - // Side nav slides in from left (desktop only) - magic-side-nav { - animation: entrance-slide-left 450ms ease-out 50ms backwards; - } - // Main content area fades + slides up as a whole .main-content { animation: entrance-slide-up 450ms ease-out 150ms backwards; @@ -226,7 +221,6 @@ mat-sidenav { @media (prefers-reduced-motion: reduce) { .app-entrance { - magic-side-nav, .main-content { animation: none; } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7b0af26da7..2e2b4b2dcd 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -70,6 +70,7 @@ import { setKeyboardLayoutService } from './util/check-key-combo'; import { OnboardingPresetSelectionComponent } from './features/onboarding/onboarding-preset-selection.component'; import { OnboardingHintComponent } from './features/onboarding/onboarding-hint.component'; import { OnboardingHintService } from './features/onboarding/onboarding-hint.service'; +import { MaterialIconsLoaderService } from './ui/material-icons-loader.service'; const ONBOARDING_PRESET_EXIT_DELAY = 1000; const ONBOARDING_ENTRANCE_COMPLETE_DELAY = 2000; @@ -133,6 +134,7 @@ export class AppComponent implements OnDestroy, AfterViewInit { private _exampleTasksService = inject(ExampleTasksService); private _keyboardLayoutService = inject(KeyboardLayoutService); private _dataInitStateService = inject(DataInitStateService); + private _materialIconsLoaderService = inject(MaterialIconsLoaderService); readonly onboardingHintService = inject(OnboardingHintService); private _syncTriggerService = inject(SyncTriggerService); @@ -191,6 +193,7 @@ export class AppComponent implements OnDestroy, AfterViewInit { constructor() { this._startupService.init(); + void this._materialIconsLoaderService.ensureFontReady(); // Skip onboarding for existing users with data if (this.isShowOnboardingPresets()) { diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index 0cf25ff48b..98138ce6c2 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -46,6 +46,7 @@ export enum BodyClass { isAndroidKeyboardHidden = 'isAndroidKeyboardHidden', isFullScreen = 'isFullScreen', isAddTaskBarOpen = 'isAddTaskBarOpen', + isMaterialSymbolsLoaded = 'isMaterialSymbolsLoaded', // iOS-specific classes isIOS = 'isIOS', diff --git a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts index 998ab2cfd1..6b932d7aad 100644 --- a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts +++ b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts @@ -108,6 +108,16 @@ export class MagicNavConfigService { private readonly isSearchEnabled = computed( () => this._configService.appFeatures().isSearchEnabled, ); + readonly areInitialTreesReady = computed(() => { + const visibleProjects = this._visibleProjects(); + const tags = this._tags(); + + const areProjectsReady = + visibleProjects.length === 0 || this._menuTreeService.hasProjectTree(); + const areTagsReady = tags.length === 0 || this._menuTreeService.hasTagTree(); + + return areProjectsReady && areTagsReady; + }); constructor() { // TODO these should probably live in the _menuTreeService diff --git a/src/app/core-ui/magic-side-nav/magic-side-nav.animations.ts b/src/app/core-ui/magic-side-nav/magic-side-nav.animations.ts index d230f4a08c..78058f5620 100644 --- a/src/app/core-ui/magic-side-nav/magic-side-nav.animations.ts +++ b/src/app/core-ui/magic-side-nav/magic-side-nav.animations.ts @@ -2,23 +2,13 @@ import { animate, style, transition, trigger } from '@angular/animations'; import { ANI_ENTER_TIMING, ANI_LEAVE_TIMING } from '../../ui/animations/animation.const'; export const magicSideNavAnimations = [ - // Desktop animation - slides from left - trigger('mobileNav', [ - transition(':enter', [ - style({ transform: 'translateX(-100%)', opacity: 0 }), - animate(ANI_ENTER_TIMING, style({ transform: 'translateX(0)', opacity: 1 })), - ]), - transition(':leave', [ - animate(ANI_LEAVE_TIMING, style({ transform: 'translateX(-100%)' })), - ]), - ]), // Mobile animation - slides from right trigger('mobileNavRight', [ - transition(':enter', [ + transition('void => visible', [ style({ transform: 'translateX(100%)', opacity: 0 }), animate(ANI_ENTER_TIMING, style({ transform: 'translateX(0)', opacity: 1 })), ]), - transition(':leave', [ + transition('visible => void', [ animate(ANI_LEAVE_TIMING, style({ transform: 'translateX(100%)' })), ]), ]), diff --git a/src/app/core-ui/magic-side-nav/magic-side-nav.component.html b/src/app/core-ui/magic-side-nav/magic-side-nav.component.html index c2d155ada6..4b332406f5 100644 --- a/src/app/core-ui/magic-side-nav/magic-side-nav.component.html +++ b/src/app/core-ui/magic-side-nav/magic-side-nav.component.html @@ -31,8 +31,7 @@ } - - - @for (item of config().items; track item.id) { - @switch (item.type) { - @case ('separator') { - - } - @case ('tree') { - - + + @for (item of config().items; track item.id) { + @switch (item.type) { + @case ('separator') { + + } + @case ('tree') { + + + + } + @case ('menu') { + - - } - @case ('menu') { - - } - @default { - - @switch (item.type) { - @case ('workContext') { - + > + } + @default { + + @switch (item.type) { + @case ('workContext') { + + } + @case ('route') { + + } + @case ('href') { + + } + @case ('action') { + + } + @case ('plugin') { + + } } - @case ('route') { - - } - @case ('href') { - - } - @case ('action') { - - } - @case ('plugin') { - - } - } - + + } } } - } - + + } } diff --git a/src/app/core-ui/magic-side-nav/magic-side-nav.component.scss b/src/app/core-ui/magic-side-nav/magic-side-nav.component.scss index da7034d11c..ba8067cd23 100644 --- a/src/app/core-ui/magic-side-nav/magic-side-nav.component.scss +++ b/src/app/core-ui/magic-side-nav/magic-side-nav.component.scss @@ -27,6 +27,42 @@ transition: none !important; } +:host(.initializing), +:host(.initializing) * { + transition: none !important; + animation: none !important; +} + +:host(.initializing) { + .nav-sidenav, + .resize-handle { + opacity: 0; + } +} + +:host(.initial-shell-enter) { + .nav-sidenav { + animation: sidenav-fade-in var(--transition-duration-l) var(--ani-enter-timing) both; + } +} + +:host(.initial-enter) { + .nav-list, + .resize-handle { + animation: sidenav-fade-in var(--transition-duration-l) var(--ani-enter-timing) both; + } +} + +@keyframes sidenav-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + // Mobile menu open button (only shows when menu is closed) .mobile-menu-open { display: flex; diff --git a/src/app/core-ui/magic-side-nav/magic-side-nav.component.ts b/src/app/core-ui/magic-side-nav/magic-side-nav.component.ts index 5755b121e9..86de3b2b91 100644 --- a/src/app/core-ui/magic-side-nav/magic-side-nav.component.ts +++ b/src/app/core-ui/magic-side-nav/magic-side-nav.component.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { + AfterViewInit, Component, computed, DestroyRef, @@ -8,10 +9,8 @@ import { inject, input, OnDestroy, - OnInit, output, signal, - AfterViewInit, } from '@angular/core'; import { NavigationStart, Router, RouterModule } from '@angular/router'; import { NavItemComponent } from './nav-item/nav-item.component'; @@ -25,7 +24,7 @@ import { NavMatMenuComponent } from './nav-mat-menu/nav-mat-menu.component'; import { TaskService } from '../../features/tasks/task.service'; import { LayoutService } from '../layout/layout.service'; import { magicSideNavAnimations } from './magic-side-nav.animations'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { filter, take } from 'rxjs/operators'; import { Subscription } from 'rxjs'; import { ScheduleExternalDragService } from '../../features/schedule/schedule-week/schedule-external-drag.service'; @@ -35,10 +34,12 @@ import { DragDropRegistry } from '@angular/cdk/drag-drop'; import { WorkContextType } from '../../features/work-context/work-context.model'; import { HISTORY_STATE } from '../../app.constants'; import { SwipeDirective } from '../../ui/swipe-gesture/swipe.directive'; +import { DataInitStateService } from '../../core/data-init/data-init-state.service'; const COLLAPSED_WIDTH = 64; const MOBILE_NAV_WIDTH = 300; const FOCUS_DELAY_MS = 10; +const INITIAL_ENTER_ANIMATION_DURATION_MS = 425; @Component({ selector: 'magic-side-nav', @@ -56,15 +57,19 @@ const FOCUS_DELAY_MS = 10; host: { '[style.width.px]': 'hostWidthSignal()', '[class.animate]': 'animateWidth()', + '[class.initial-shell-enter]': 'playInitialShellEnter()', + '[class.initial-enter]': 'playInitialEnter()', + '[class.initializing]': '!areTransitionsReady()', '[class.resizing]': 'isResizing()', }, animations: magicSideNavAnimations, }) -export class MagicSideNavComponent implements OnInit, OnDestroy, AfterViewInit { +export class MagicSideNavComponent implements OnDestroy, AfterViewInit { private readonly _destroyRef = inject(DestroyRef); private readonly _sideNavConfigService = inject(MagicNavConfigService); private readonly _taskService = inject(TaskService); private readonly _layoutService = inject(LayoutService); + private readonly _dataInitStateService = inject(DataInitStateService); private readonly _router = inject(Router); private _dragDropRegistry = inject(DragDropRegistry); private _externalDragService = inject(ScheduleExternalDragService); @@ -72,15 +77,23 @@ export class MagicSideNavComponent implements OnInit, OnDestroy, AfterViewInit { // Use service's computed signal directly readonly config = this._sideNavConfigService.navConfig; + private readonly _isDataLoaded = toSignal( + this._dataInitStateService.isAllDataLoadedInitially$, + { initialValue: false }, + ); + readonly isDataLoaded = computed( + () => this._isDataLoaded() && this._sideNavConfigService.areInitialTreesReady(), + ); readonly WorkContextType = WorkContextType; + private readonly _initialState = this._getInitialState(); activeWorkContextId = input(null); // Externally controlled mobile overlay visibility mobileVisibleChange = output(); - isFullMode = signal(true); - isMobile = signal(false); + isFullMode = signal(this._initialState.isFullMode); + isMobile = signal(this._initialState.isMobile); showMobileMenuOverlay = signal(false); // Temporarily disable swipe during menu open animation to prevent // the touch event from the toggle button being captured as a swipe @@ -88,10 +101,17 @@ export class MagicSideNavComponent implements OnInit, OnDestroy, AfterViewInit { // Animate only for compactMode/fullMode toggle animateWidth = signal(false); + areTransitionsReady = signal(false); + playInitialShellEnter = signal(false); + playInitialEnter = signal(false); + private _hasPlayedInitialShellEnter = false; + private _hasPlayedInitialEnter = false; private _animateTimeoutId: number | null = null; + private _initialShellEnterTimeoutId: number | null = null; + private _initialEnterTimeoutId: number | null = null; // Resize functionality - currentWidth = signal(MOBILE_NAV_WIDTH); + currentWidth = signal(this._initialState.currentWidth); // Use values directly from config for min/max/thresholds isResizing = signal(false); startX = signal(0); @@ -159,6 +179,49 @@ export class MagicSideNavComponent implements OnInit, OnDestroy, AfterViewInit { window.removeEventListener('resize', resizeListener); }); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + this.areTransitionsReady.set(true); + }); + }); + + effect(() => { + if ( + this._hasPlayedInitialShellEnter || + !this.areTransitionsReady() || + this.playInitialShellEnter() || + this._initialShellEnterTimeoutId != null + ) { + return; + } + + this._hasPlayedInitialShellEnter = true; + this.playInitialShellEnter.set(true); + this._initialShellEnterTimeoutId = window.setTimeout(() => { + this.playInitialShellEnter.set(false); + this._initialShellEnterTimeoutId = null; + }, INITIAL_ENTER_ANIMATION_DURATION_MS); + }); + + effect(() => { + if ( + this._hasPlayedInitialEnter || + !this.areTransitionsReady() || + !this.isDataLoaded() || + this.playInitialEnter() || + this._initialEnterTimeoutId != null + ) { + return; + } + + this._hasPlayedInitialEnter = true; + this.playInitialEnter.set(true); + this._initialEnterTimeoutId = window.setTimeout(() => { + this.playInitialEnter.set(false); + this._initialEnterTimeoutId = null; + }, INITIAL_ENTER_ANIMATION_DURATION_MS); + }); + this._router.events .pipe( filter((event): event is NavigationStart => event instanceof NavigationStart), @@ -177,40 +240,22 @@ export class MagicSideNavComponent implements OnInit, OnDestroy, AfterViewInit { this._animateTimeoutId = null; } + if (this._initialShellEnterTimeoutId != null) { + window.clearTimeout(this._initialShellEnterTimeoutId); + this._initialShellEnterTimeoutId = null; + } + + if (this._initialEnterTimeoutId != null) { + window.clearTimeout(this._initialEnterTimeoutId); + this._initialEnterTimeoutId = null; + } + if (this._pointerUpSubscription) { this._pointerUpSubscription.unsubscribe(); this._pointerUpSubscription = null; } } - ngOnInit(): void { - // Check screen size first to set mobile state - this._checkScreenSize(); - - // Persisted state is only relevant for desktop when bottom nav is not visible - const isBottomNavVisible = this._layoutService.isXs(); - if (!isBottomNavVisible) { - // Load saved fullMode/compactMode state - const initialFullMode = readBoolLS( - LS.NAV_SIDEBAR_EXPANDED, - this.config().fullModeByDefault, - ); - this.isFullMode.set(initialFullMode); - - // Load saved width from localStorage or default - const bounded = readNumberLSBounded( - LS.NAV_SIDEBAR_WIDTH, - this.config().minWidth, - this.config().maxWidth, - ); - this.currentWidth.set(bounded ?? this.config().defaultWidth); - } else { - // Use defaults for mobile; do not apply persisted desktop state - this.isFullMode.set(this.config().fullModeByDefault); - this.currentWidth.set(this.config().defaultWidth); - } - } - ngAfterViewInit(): void { // Listen for global pointer releases while a drag is active this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe((event) => { @@ -228,7 +273,7 @@ export class MagicSideNavComponent implements OnInit, OnDestroy, AfterViewInit { private _checkScreenSize(): void { const wasMobile = this.isMobile(); - const currentMobile = window.innerWidth < this.config().mobileBreakpoint; + const currentMobile = this._isMobileViewport(); this.isMobile.set(currentMobile); if (wasMobile !== currentMobile && currentMobile) { @@ -237,6 +282,36 @@ export class MagicSideNavComponent implements OnInit, OnDestroy, AfterViewInit { } } + private _getInitialState(): { + currentWidth: number; + isFullMode: boolean; + isMobile: boolean; + } { + const config = this.config(); + const isMobile = this._isMobileViewport(); + + if (isMobile) { + return { + currentWidth: config.defaultWidth, + isFullMode: config.fullModeByDefault, + isMobile, + }; + } + + return { + currentWidth: + readNumberLSBounded(LS.NAV_SIDEBAR_WIDTH, config.minWidth, config.maxWidth) ?? + config.defaultWidth, + isFullMode: readBoolLS(LS.NAV_SIDEBAR_EXPANDED, config.fullModeByDefault), + isMobile, + }; + } + + private _isMobileViewport(): boolean { + const maxWidth = this.config().mobileBreakpoint - 1; + return window.matchMedia(`(max-width: ${maxWidth}px)`).matches; + } + toggleMobileNav(): void { const isOpening = !this.showMobileMenuOverlay(); this.showMobileMenuOverlay.update((show) => !show); diff --git a/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.html b/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.html index 881bfecbdf..6f1946a0b9 100644 --- a/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.html +++ b/src/app/core-ui/magic-side-nav/nav-list/nav-list-tree.component.html @@ -45,6 +45,7 @@ class="nav-children" role="list" draggable="false" + [@.disabled]="!shouldAnimateExpandCollapse()" [@expandCollapse] > (); showLabels = input(true); @@ -76,6 +80,7 @@ export class NavListTreeComponent { readonly treeNodes = signal[]>([]); readonly treeKind = computed(() => this.item().treeKind); + readonly shouldAnimateExpandCollapse = signal(false); constructor() { effect(() => { @@ -85,9 +90,17 @@ export class NavListTreeComponent { } onHeaderClick(): void { + this._enableExpandAnimationTemporarily(); this.itemClick.emit(this.item()); } + ngOnDestroy(): void { + if (this._expandAnimationTimeoutId != null) { + window.clearTimeout(this._expandAnimationTimeoutId); + this._expandAnimationTimeoutId = null; + } + } + onChildClick(node: TreeNode): void { const data = node.data; if (!data) return; @@ -130,6 +143,18 @@ export class NavListTreeComponent { this._openFolderContextMenu(event, node); } + private _enableExpandAnimationTemporarily(): void { + if (this._expandAnimationTimeoutId != null) { + window.clearTimeout(this._expandAnimationTimeoutId); + } + + this.shouldAnimateExpandCollapse.set(true); + this._expandAnimationTimeoutId = window.setTimeout(() => { + this.shouldAnimateExpandCollapse.set(false); + this._expandAnimationTimeoutId = null; + }, EXPAND_ANIMATION_RESET_DELAY_MS); + } + private _openFolderContextMenu( event: MouseEvent, node: TreeNode, diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html index 6470defb7b..931273383f 100644 --- a/src/app/core-ui/main-header/main-header.component.html +++ b/src/app/core-ui/main-header/main-header.component.html @@ -1,30 +1,30 @@ - - - @if (isScheduleSection()) { - - - view_week - - - calendar_month - - - } - @if (isDataLoaded()) { + + + @if (isScheduleSection()) { + + + view_week + + + calendar_month + + + } + @if (isTimeTrackingEnabled()) { diff --git a/src/app/ui/material-icons-loader.service.ts b/src/app/ui/material-icons-loader.service.ts index d7ba012f79..918c5514e0 100644 --- a/src/app/ui/material-icons-loader.service.ts +++ b/src/app/ui/material-icons-loader.service.ts @@ -1,4 +1,7 @@ import { Injectable } from '@angular/core'; +import { BodyClass } from '../app.constants'; + +const MATERIAL_ICONS_FONT = '24px "Material Symbols Outlined"'; @Injectable({ providedIn: 'root', @@ -6,6 +9,7 @@ import { Injectable } from '@angular/core'; export class MaterialIconsLoaderService { private icons: string[] | null = null; private loadingPromise: Promise | null = null; + private fontReadyPromise: Promise | null = null; async loadIcons(): Promise { // Return cached icons if already loaded @@ -23,9 +27,44 @@ export class MaterialIconsLoaderService { return this.loadingPromise; } + ensureFontReady(): Promise { + if (typeof document === 'undefined') { + return Promise.resolve(); + } + + const body = document.body; + if (!body) { + return Promise.resolve(); + } + + if (body.classList.contains(BodyClass.isMaterialSymbolsLoaded)) { + return Promise.resolve(); + } + + if (!('fonts' in document)) { + body.classList.add(BodyClass.isMaterialSymbolsLoaded); + return Promise.resolve(); + } + + if (this.fontReadyPromise) { + return this.fontReadyPromise; + } + + this.fontReadyPromise = this._loadFont(body); + return this.fontReadyPromise; + } + private async _loadModule(): Promise { const { MATERIAL_ICONS } = await import('./material-icons.const'); this.icons = MATERIAL_ICONS; return MATERIAL_ICONS; } + + private async _loadFont(body: HTMLElement): Promise { + try { + await document.fonts.load(MATERIAL_ICONS_FONT); + } finally { + body.classList.add(BodyClass.isMaterialSymbolsLoaded); + } + } } diff --git a/src/styles/components/_overwrite-material.scss b/src/styles/components/_overwrite-material.scss index 5317787afa..55b530dd7e 100644 --- a/src/styles/components/_overwrite-material.scss +++ b/src/styles/components/_overwrite-material.scss @@ -238,6 +238,12 @@ mat-option.isDone { mat-icon, .mat-icon { vertical-align: middle; + visibility: hidden; +} + +body.isMaterialSymbolsLoaded .mat-icon, +body.isMaterialSymbolsLoaded mat-icon { + visibility: visible; } .mat-icon.mat-icon {