mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-25 17:03:52 +00:00
feat: improve initial render
- fix(ui): delay material icons until font is ready - fix(side-nav): wire up tree readiness gate and clean up dead code - fix(ui): hide icon ligature fallback text - fix(side-nav): defer data-driven startup content - fix(side-nav): stabilize magic nav initial render
This commit is contained in:
parent
5d37b98986
commit
271db4fab5
13 changed files with 371 additions and 190 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export enum BodyClass {
|
|||
isAndroidKeyboardHidden = 'isAndroidKeyboardHidden',
|
||||
isFullScreen = 'isFullScreen',
|
||||
isAddTaskBarOpen = 'isAddTaskBarOpen',
|
||||
isMaterialSymbolsLoaded = 'isMaterialSymbolsLoaded',
|
||||
|
||||
// iOS-specific classes
|
||||
isIOS = 'isIOS',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%)' })),
|
||||
]),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@
|
|||
<!-- Main Navigation Sidebar -->
|
||||
<nav
|
||||
class="nav-sidenav"
|
||||
[@mobileNav]="isMobile() ? void 0 : true"
|
||||
[@mobileNavRight]="isMobile() ? true : void 0"
|
||||
[@mobileNavRight]="isMobile() ? 'visible' : 'desktop'"
|
||||
[class.fullMode]="isFullMode() || isMobile()"
|
||||
[class.compactMode]="!isFullMode() && !isMobile()"
|
||||
[class.mobile]="isMobile()"
|
||||
|
|
@ -70,120 +69,122 @@
|
|||
</button>
|
||||
}
|
||||
|
||||
<!-- Navigation Items -->
|
||||
<ul
|
||||
class="nav-list"
|
||||
role="list"
|
||||
>
|
||||
@for (item of config().items; track item.id) {
|
||||
@switch (item.type) {
|
||||
@case ('separator') {
|
||||
<hr
|
||||
class="nav-separator"
|
||||
[style.margin-top]="item.mtAuto && 'auto'"
|
||||
/>
|
||||
}
|
||||
@case ('tree') {
|
||||
<li
|
||||
class="nav-item has-children"
|
||||
role="listitem"
|
||||
>
|
||||
<nav-list-tree
|
||||
@if (isDataLoaded()) {
|
||||
<!-- Navigation Items -->
|
||||
<ul
|
||||
class="nav-list"
|
||||
role="list"
|
||||
>
|
||||
@for (item of config().items; track item.id) {
|
||||
@switch (item.type) {
|
||||
@case ('separator') {
|
||||
<hr
|
||||
class="nav-separator"
|
||||
[style.margin-top]="item.mtAuto && 'auto'"
|
||||
/>
|
||||
}
|
||||
@case ('tree') {
|
||||
<li
|
||||
class="nav-item has-children"
|
||||
role="listitem"
|
||||
>
|
||||
<nav-list-tree
|
||||
[item]="item"
|
||||
[showLabels]="showText()"
|
||||
[isExpanded]="isGroupExpanded(item)"
|
||||
[activeWorkContextId]="activeWorkContextId()"
|
||||
(itemClick)="onItemClick($event)"
|
||||
></nav-list-tree>
|
||||
</li>
|
||||
}
|
||||
@case ('menu') {
|
||||
<nav-mat-menu
|
||||
[item]="item"
|
||||
[showLabels]="showText()"
|
||||
[isExpanded]="isGroupExpanded(item)"
|
||||
[activeWorkContextId]="activeWorkContextId()"
|
||||
(itemClick)="onItemClick($event)"
|
||||
></nav-list-tree>
|
||||
</li>
|
||||
}
|
||||
@case ('menu') {
|
||||
<nav-mat-menu
|
||||
[item]="item"
|
||||
[showLabels]="showText()"
|
||||
(itemClick)="onItemClick($event)"
|
||||
></nav-mat-menu>
|
||||
}
|
||||
@default {
|
||||
<li
|
||||
class="nav-item"
|
||||
role="listitem"
|
||||
>
|
||||
@switch (item.type) {
|
||||
@case ('workContext') {
|
||||
<nav-item
|
||||
[workContext]="item.workContext"
|
||||
[type]="item.workContextType"
|
||||
[defaultIcon]="item.defaultIcon"
|
||||
[activeWorkContextId]="activeWorkContextId() || ''"
|
||||
[variant]="'nav'"
|
||||
[showLabels]="showText()"
|
||||
[attr.data-project-id]="
|
||||
item.workContextType === WorkContextType.PROJECT
|
||||
? item.workContext.id
|
||||
: null
|
||||
"
|
||||
[attr.data-tag-id]="
|
||||
item.workContextType === WorkContextType.TAG
|
||||
? item.workContext.id
|
||||
: null
|
||||
"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
></nav-mat-menu>
|
||||
}
|
||||
@default {
|
||||
<li
|
||||
class="nav-item"
|
||||
role="listitem"
|
||||
>
|
||||
@switch (item.type) {
|
||||
@case ('workContext') {
|
||||
<nav-item
|
||||
[workContext]="item.workContext"
|
||||
[type]="item.workContextType"
|
||||
[defaultIcon]="item.defaultIcon"
|
||||
[activeWorkContextId]="activeWorkContextId() || ''"
|
||||
[variant]="'nav'"
|
||||
[showLabels]="showText()"
|
||||
[attr.data-project-id]="
|
||||
item.workContextType === WorkContextType.PROJECT
|
||||
? item.workContext.id
|
||||
: null
|
||||
"
|
||||
[attr.data-tag-id]="
|
||||
item.workContextType === WorkContextType.TAG
|
||||
? item.workContext.id
|
||||
: null
|
||||
"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('route') {
|
||||
<nav-item
|
||||
[container]="'route'"
|
||||
[navRoute]="item.route"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
[featureConfigKey]="item.featureConfigKey"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('href') {
|
||||
<nav-item
|
||||
[container]="'href'"
|
||||
[navHref]="item.href"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('action') {
|
||||
<nav-item
|
||||
[container]="'action'"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('plugin') {
|
||||
<nav-item
|
||||
[container]="'action'"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
}
|
||||
@case ('route') {
|
||||
<nav-item
|
||||
[container]="'route'"
|
||||
[navRoute]="item.route"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
[featureConfigKey]="item.featureConfigKey"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('href') {
|
||||
<nav-item
|
||||
[container]="'href'"
|
||||
[navHref]="item.href"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('action') {
|
||||
<nav-item
|
||||
[container]="'action'"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
@case ('plugin') {
|
||||
<nav-item
|
||||
[container]="'action'"
|
||||
[icon]="item.icon"
|
||||
[svgIcon]="item.svgIcon"
|
||||
[label]="item.label"
|
||||
[showLabels]="showText()"
|
||||
[tourClass]="item.tourClass"
|
||||
(clicked)="onItemClick(item)"
|
||||
></nav-item>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</ul>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
||||
// Externally controlled mobile overlay visibility
|
||||
mobileVisibleChange = output<boolean>();
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
class="nav-children"
|
||||
role="list"
|
||||
draggable="false"
|
||||
[@.disabled]="!shouldAnimateExpandCollapse()"
|
||||
[@expandCollapse]
|
||||
>
|
||||
<tree-dnd
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
effect,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
|
|
@ -33,6 +34,8 @@ import { WorkContextType } from '../../../features/work-context/work-context.mod
|
|||
import { DEFAULT_PROJECT_ICON } from '../../../features/project/project.const';
|
||||
import { expandCollapseAni } from '../../../ui/tree-dnd/tree.animations';
|
||||
|
||||
const EXPAND_ANIMATION_RESET_DELAY_MS = 250;
|
||||
|
||||
@Component({
|
||||
selector: 'nav-list-tree',
|
||||
standalone: true,
|
||||
|
|
@ -52,9 +55,10 @@ import { expandCollapseAni } from '../../../ui/tree-dnd/tree.animations';
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandCollapseAni],
|
||||
})
|
||||
export class NavListTreeComponent {
|
||||
export class NavListTreeComponent implements OnDestroy {
|
||||
private readonly _navConfigService = inject(MagicNavConfigService);
|
||||
private readonly _menuTreeService = inject(MenuTreeService);
|
||||
private _expandAnimationTimeoutId: number | null = null;
|
||||
|
||||
item = input.required<NavTreeItem>();
|
||||
showLabels = input<boolean>(true);
|
||||
|
|
@ -76,6 +80,7 @@ export class NavListTreeComponent {
|
|||
|
||||
readonly treeNodes = signal<TreeNode<MenuTreeViewNode>[]>([]);
|
||||
readonly treeKind = computed<MenuTreeKind>(() => 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<MenuTreeViewNode>): 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<MenuTreeViewNode>,
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
<div class="wrapper">
|
||||
<page-title></page-title>
|
||||
|
||||
@if (isScheduleSection()) {
|
||||
<div class="week-month-selector">
|
||||
<button
|
||||
(click)="selectTimeView('week')"
|
||||
[class.active]="selectedTimeView() === 'week'"
|
||||
class="week-month-btn week-btn"
|
||||
matTooltip="View Week"
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>view_week</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
(click)="selectTimeView('month')"
|
||||
[class.active]="selectedTimeView() === 'month'"
|
||||
class="week-month-btn month-btn"
|
||||
matTooltip="View Month"
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>calendar_month</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isDataLoaded()) {
|
||||
<page-title></page-title>
|
||||
|
||||
@if (isScheduleSection()) {
|
||||
<div class="week-month-selector">
|
||||
<button
|
||||
(click)="selectTimeView('week')"
|
||||
[class.active]="selectedTimeView() === 'week'"
|
||||
class="week-month-btn week-btn"
|
||||
matTooltip="View Week"
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>view_week</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
(click)="selectTimeView('month')"
|
||||
[class.active]="selectedTimeView() === 'month'"
|
||||
class="week-month-btn month-btn"
|
||||
matTooltip="View Month"
|
||||
mat-icon-button
|
||||
>
|
||||
<mat-icon>calendar_month</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<nav class="action-nav-right">
|
||||
<div class="counters-action-group">
|
||||
@if (isTimeTrackingEnabled()) {
|
||||
|
|
|
|||
|
|
@ -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<string[]> | null = null;
|
||||
private fontReadyPromise: Promise<void> | null = null;
|
||||
|
||||
async loadIcons(): Promise<string[]> {
|
||||
// Return cached icons if already loaded
|
||||
|
|
@ -23,9 +27,44 @@ export class MaterialIconsLoaderService {
|
|||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
ensureFontReady(): Promise<void> {
|
||||
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<string[]> {
|
||||
const { MATERIAL_ICONS } = await import('./material-icons.const');
|
||||
this.icons = MATERIAL_ICONS;
|
||||
return MATERIAL_ICONS;
|
||||
}
|
||||
|
||||
private async _loadFont(body: HTMLElement): Promise<void> {
|
||||
try {
|
||||
await document.fonts.load(MATERIAL_ICONS_FONT);
|
||||
} finally {
|
||||
body.classList.add(BodyClass.isMaterialSymbolsLoaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue