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:
Johannes Millan 2026-03-26 17:10:50 +01:00
parent 5d37b98986
commit 271db4fab5
13 changed files with 371 additions and 190 deletions

View file

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

View file

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

View file

@ -46,6 +46,7 @@ export enum BodyClass {
isAndroidKeyboardHidden = 'isAndroidKeyboardHidden',
isFullScreen = 'isFullScreen',
isAddTaskBarOpen = 'isAddTaskBarOpen',
isMaterialSymbolsLoaded = 'isMaterialSymbolsLoaded',
// iOS-specific classes
isIOS = 'isIOS',

View file

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

View file

@ -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%)' })),
]),
]),

View file

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

View file

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

View file

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

View file

@ -45,6 +45,7 @@
class="nav-children"
role="list"
draggable="false"
[@.disabled]="!shouldAnimateExpandCollapse()"
[@expandCollapse]
>
<tree-dnd

View file

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

View file

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

View file

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

View file

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