From e59d6d5dcad0ff0df5e791a0f287b7b1d8a5c249 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Wed, 9 Sep 2026 13:06:20 +0200 Subject: [PATCH] JCU/fix(root): resolve the admin-sidebar gutter in CSS, not in an animation Backport of dataquest-dev/dspace-angular#1333, already on customer/TUL, customer/lindat and customer/mendelu. Written for this branch rather than cherry-picked. @slideSidebarPadding reads the gutter width from the browser-only CSS-variable store, so the server has nothing to substitute and renders style="padding-left: *", which is not valid CSS. For a logged-in user the browser then resolves the real width and the page moves right by the sidebar. That is visible on every reload, and all the more so now that the anti-flicker overlay holds the server's paint on screen while it happens. The gutter now comes from a ds-admin-sidebar-{hidden,unpinned,pinned} class whose padding-left reads --ds-admin-sidebar-fixed-element-width / --ds-admin-sidebar-total-width. CSS resolves those the same on the server and in the browser, so there is nothing left to shift and no width is hardcoded. The pin/unpin slide survives as transition: padding-left, gated behind ds-admin-sidebar-animate, which is added only after the first paint so the initial resolution never animates. Measured on the docker stack, logged in, reloading /home: outer-wrapper padding-left goes from "padding-left: *" (computed 0px, sidebar overlapping the content) to 55px, identical in the SSR snapshot and the live app, and the horizontal position of #main-content no longer moves at all. Refs: dataquest-dev/dspace-customers#717 Co-Authored-By: Claude Opus 5 (1M context) --- src/app/root/root.component.html | 10 +++--- src/app/root/root.component.scss | 24 ++++++++++++++ src/app/root/root.component.ts | 34 ++++++++++++++++++-- src/themes/custom/app/root/root.component.ts | 2 -- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index 91cb236abed..b584cd95143 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -2,10 +2,12 @@ {{ 'root.skip-to-content' | translate }} -
+
diff --git a/src/app/root/root.component.scss b/src/app/root/root.component.scss index 9eb198417ad..2a6cdfb7848 100644 --- a/src/app/root/root.component.scss +++ b/src/app/root/root.component.scss @@ -14,3 +14,27 @@ top: 0; } } + +// Admin-sidebar left gutter, driven by the ds-admin-sidebar-* class from sidebarPaddingState$ rather +// than the @slideSidebarPadding animation. The animation read the width from a browser-only store, so +// the server emitted `padding-left: *` and the page moved sideways once the browser resolved it. +// Resolving from the custom properties in CSS renders the same on both sides, with no hardcoded px. +.outer-wrapper { + &.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); + } + + // Only genuine pin/unpin toggles slide: the class is added after the first paint, so the initial + // gutter never animates behind the anti-flicker overlay. + &.ds-admin-sidebar-animate { + transition: padding-left 300ms ease-in-out; + } +} diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 8ce800f5f5f..2dac12579f2 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,25 @@ 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; + + /** + * Which admin-sidebar gutter the outer wrapper should carry: 'hidden', 'unpinned' or 'pinned'. + * The width itself comes from CSS (see root.component.scss), so the server and the browser + * resolve it the same way. + */ + sidebarPaddingState$: Observable; + + /** + * Lets the gutter transition run only after the first paint, so the initial resolution does not + * animate. Off on the server and on the first render; only pin/unpin toggles slide. + */ + gutterTransitionEnabled = false; notificationOptions: INotificationBoardOptions; models: any; @@ -129,11 +141,27 @@ export class RootComponent implements OnInit { startWith(true), ); + // A CSS class instead of the @slideSidebarPadding animation: that animation needed a concrete + // width from the browser-only CSS-variable store, so the server rendered `padding-left: *` and + // the page moved sideways once the browser resolved the real width. + this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe( + map(([visible, over]: [boolean, boolean]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'), + ); + if (this.router.url === getPageInternalServerErrorRoute()) { this.shouldShowRouteLoader = false; } } + ngAfterViewInit(): void { + // Browser only; requestAnimationFrame does not exist under SSR. + 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,