diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index 91cb236abed..0efb1325920 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -2,10 +2,9 @@ {{ 'root.skip-to-content' | translate }} -
+
diff --git a/src/app/root/root.component.scss b/src/app/root/root.component.scss index 9eb198417ad..6e617a3e48a 100644 --- a/src/app/root/root.component.scss +++ b/src/app/root/root.component.scss @@ -14,3 +14,31 @@ top: 0; } } + +// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from +// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a +// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0 +// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the +// gutter from the `--ds-admin-sidebar-*` custom properties in CSS instead renders identically on the +// server (snapshot) and the browser (live app) - no hardcoded px, theme- and viewport-aware - and the +// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0. +.outer-wrapper { + // padding-left:0 (no admin sidebar); explicit for self-documentation. + &.ds-admin-sidebar-hidden { + padding-left: 0; + } + + &.ds-admin-sidebar-unpinned { + padding-left: var(--ds-admin-sidebar-fixed-element-width); + } + + &.ds-admin-sidebar-pinned { + padding-left: var(--ds-admin-sidebar-total-width); + } + + // Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled) + // so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates. + &.ds-admin-sidebar-animate { + transition: padding-left 300ms ease-in-out; + } +} diff --git a/src/app/root/root.component.spec.ts b/src/app/root/root.component.spec.ts index 77b39d29088..e83811880c5 100644 --- a/src/app/root/root.component.spec.ts +++ b/src/app/root/root.component.spec.ts @@ -7,6 +7,7 @@ import { import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { AccessibilitySettingsService } from '../accessibility/accessibility-settings.service'; import { AccessibilitySettingsServiceStub } from '../accessibility/accessibility-settings.service.stub'; @@ -72,4 +73,50 @@ describe('RootComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should emit the hidden gutter state when the admin sidebar is not visible', () => { + spyOn(TestBed.inject(MenuService), 'isMenuVisibleWithVisibleSections').and.returnValue(of(false)); + component.ngOnInit(); + component.browserOsClasses.next(['browser-firefox', 'browser-firefox-windows']); + fixture.detectChanges(); + + let state: string; + component.sidebarPaddingState$.subscribe((value: string) => state = value); + expect(state).toEqual('hidden'); + + // the gutter class must be merged with the browser/OS classes, not replace them + let classes: string[]; + component.outerWrapperClasses$.subscribe((value: string[]) => classes = value); + expect(classes).toEqual(['browser-firefox', 'browser-firefox-windows', 'ds-admin-sidebar-hidden']); + + const wrapper: HTMLElement = fixture.nativeElement.querySelector('.outer-wrapper'); + expect(Array.from(wrapper.classList)).toContain('browser-firefox'); + expect(Array.from(wrapper.classList)).toContain('browser-firefox-windows'); + expect(Array.from(wrapper.classList)).toContain('ds-admin-sidebar-hidden'); + }); + + it('should enable the gutter transition only after the first paint', () => { + const paints: FrameRequestCallback[] = []; + spyOn(window, 'requestAnimationFrame').and.callFake((callback: FrameRequestCallback) => { + paints.push(callback); + return 0; + }); + + const gutterFixture = TestBed.createComponent(RootComponent); + gutterFixture.detectChanges(); + const wrapper: HTMLElement = gutterFixture.nativeElement.querySelector('.outer-wrapper'); + + // the gutter itself is rendered right away (this is what removes the SSR -> CSR jump) ... + expect(Array.from(wrapper.classList)).toContain('ds-admin-sidebar-pinned'); + // ... but it must not animate before the first paint + expect(gutterFixture.componentInstance.gutterTransitionEnabled).toBeFalse(); + expect(Array.from(wrapper.classList)).not.toContain('ds-admin-sidebar-animate'); + + expect(paints.length).toBeGreaterThan(0); + paints.forEach((callback: FrameRequestCallback) => callback(0)); + gutterFixture.detectChanges(); + + expect(gutterFixture.componentInstance.gutterTransitionEnabled).toBeTrue(); + expect(Array.from(wrapper.classList)).toContain('ds-admin-sidebar-animate'); + }); }); diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 8ce800f5f5f..f6430de99c0 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -3,6 +3,7 @@ import { NgClass, } from '@angular/common'; import { + AfterViewInit, Component, Inject, Input, @@ -38,7 +39,6 @@ import { } from '../core/services/window.service'; import { ThemedFooterComponent } from '../footer/themed-footer.component'; import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component'; -import { slideSidebarPadding } from '../shared/animations/slide'; import { HostWindowService } from '../shared/host-window.service'; import { LiveRegionComponent } from '../shared/live-region/live-region.component'; import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; @@ -52,7 +52,6 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne selector: 'ds-base-root', templateUrl: './root.component.html', styleUrls: ['./root.component.scss'], - animations: [slideSidebarPadding], imports: [ AsyncPipe, LiveRegionComponent, @@ -68,12 +67,37 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne TranslateModule, ], }) -export class RootComponent implements OnInit { +export class RootComponent implements OnInit, AfterViewInit { theme: Observable = of({} as any); isSidebarVisible$: Observable; slideSidebarOver$: Observable; collapsedSidebarWidth$: Observable; expandedSidebarWidth$: Observable; + + /** + * The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the + * outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular + * animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it + * is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live + * app) - no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware. + */ + sidebarPaddingState$: Observable; + + /** + * The classes on the outer wrapper: the browser/OS classes this branch already carries, plus the + * admin-sidebar gutter class. Merged into a single stream because Angular allows one [ngClass] + * binding per element and the wrapper already has one. + */ + outerWrapperClasses$: Observable; + + /** + * Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial + * SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS + * transition would animate that initial 0->gutter change (the overlay settle detector only watches + * DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed. + * Off on the server and on first render, so only genuine pin/unpin toggles animate. + */ + gutterTransitionEnabled = false; notificationOptions: INotificationBoardOptions; models: any; @@ -113,6 +137,8 @@ export class RootComponent implements OnInit { this.isSidebarVisible$ = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN); + // Still provided to ; the sidebar element itself sizes from CSS vars, so a + // null value on the server (the store is browser-only) is harmless there. this.expandedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-total-width').pipe( skipWhile((val) => !val), first(), @@ -129,11 +155,36 @@ export class RootComponent implements OnInit { startWith(true), ); + // Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the + // animation needs a concrete width from the browser-only CSS-variable store, so on the server it + // rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed. + // The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically + // on server and browser - fixing the jump without any hardcoded width. + this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe( + map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'), + ); + this.outerWrapperClasses$ = combineLatestObservable([ + this.browserOsClasses.asObservable(), + this.sidebarPaddingState$, + ]).pipe( + map(([osClasses, state]) => [...osClasses, `ds-admin-sidebar-${state}`]), + ); + if (this.router.url === getPageInternalServerErrorRoute()) { this.shouldShowRouteLoader = false; } } + ngAfterViewInit(): void { + // Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not + // defined under SSR), so the initial padding resolution never animates - see gutterTransitionEnabled. + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { + this.gutterTransitionEnabled = true; + }); + } + } + skipToMainContent() { const mainContent = document.getElementById('main-content'); if (mainContent) { diff --git a/src/themes/custom/app/root/root.component.ts b/src/themes/custom/app/root/root.component.ts index 2bdab293bf6..e3611b32a77 100644 --- a/src/themes/custom/app/root/root.component.ts +++ b/src/themes/custom/app/root/root.component.ts @@ -11,7 +11,6 @@ import { ThemedBreadcrumbsComponent } from '../../../../app/breadcrumbs/themed-b import { ThemedFooterComponent } from '../../../../app/footer/themed-footer.component'; import { ThemedHeaderNavbarWrapperComponent } from '../../../../app/header-nav-wrapper/themed-header-navbar-wrapper.component'; import { RootComponent as BaseComponent } from '../../../../app/root/root.component'; -import { slideSidebarPadding } from '../../../../app/shared/animations/slide'; import { LiveRegionComponent } from '../../../../app/shared/live-region/live-region.component'; import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component'; import { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.component'; @@ -23,7 +22,6 @@ import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-aler styleUrls: ['../../../../app/root/root.component.scss'], // templateUrl: './root.component.html', templateUrl: '../../../../app/root/root.component.html', - animations: [slideSidebarPadding], imports: [ AsyncPipe, LiveRegionComponent,