Skip to content

Commit 3ffd50a

Browse files
jr-rkclaude
andauthored
MENDELU/Backport #1333: admin-sidebar gutter via CSS var (no logged-in reload shift) (#1355)
Backport of #1333 (originally landed on customer/vsb-tuo). Problem: for an authenticated user, a hard reload shifted the whole page right by the admin-sidebar width when the SSR snapshot was removed. The .outer-wrapper left gutter was produced by the @slideSidebarPadding animation, whose width is read from a browser-only CSS-variable store; on the server that store is empty so it renders padding-left:0, then the browser resolves the real width and the page jumps. Fix: drive the gutter from a CSS class (ds-admin-sidebar-{hidden,unpinned,pinned}, set from a small sidebarPaddingState$) whose padding-left resolves from the admin-sidebar width custom properties in CSS. CSS resolves those identically on the server (the SSR snapshot) and the browser (the live app), so there is no shift and no hardcoded width. The pin/unpin slide is preserved via transition:padding-left, gated behind ds-admin-sidebar-animate (enabled only after the first browser paint) so the initial SSR->CSR gutter resolution does not animate. Translated to this branch's root.component (Angular 18 standalone generation, base + themed root components), not a cherry-pick. Refs: dataquest-dev/dspace-customers#717, #1333 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 57602d3 commit 3ffd50a

4 files changed

Lines changed: 71 additions & 9 deletions

File tree

src/app/root/root.component.html

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
{{ 'root.skip-to-content' | translate }}
33
</button>
44

5-
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader" [ngClass]="browserOsClasses.asObservable() | async" [@slideSidebarPadding]="{
6-
value: ((isSidebarVisible$ | async) !== true ? 'hidden' : (slideSidebarOver$ | async) ? 'unpinned' : 'pinned'),
7-
params: { collapsedWidth: (collapsedSidebarWidth$ | async), expandedWidth: (expandedSidebarWidth$ | async) }
8-
}">
5+
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader"
6+
[ngClass]="browserOsClasses.asObservable() | async"
7+
[class.ds-admin-sidebar-animate]="gutterTransitionEnabled"
8+
[class.ds-admin-sidebar-hidden]="(sidebarPaddingState$ | async) === 'hidden'"
9+
[class.ds-admin-sidebar-unpinned]="(sidebarPaddingState$ | async) === 'unpinned'"
10+
[class.ds-admin-sidebar-pinned]="(sidebarPaddingState$ | async) === 'pinned'">
911
<ds-admin-sidebar [expandedSidebarWidth$]="expandedSidebarWidth$" [collapsedSidebarWidth$]="collapsedSidebarWidth$"></ds-admin-sidebar>
1012
<div class="inner-wrapper">
1113
<ds-system-wide-alert-banner></ds-system-wide-alert-banner>

src/app/root/root.component.scss

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,31 @@
1414
top: 0;
1515
}
1616
}
17+
18+
// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from
19+
// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a
20+
// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0
21+
// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the
22+
// gutter from the `--ds-admin-sidebar-*` custom properties in CSS instead renders identically on the
23+
// server (snapshot) and the browser (live app) — no hardcoded px, theme- and viewport-aware — and the
24+
// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0.
25+
.outer-wrapper {
26+
// padding-left:0 (no admin sidebar); explicit for self-documentation.
27+
&.ds-admin-sidebar-hidden {
28+
padding-left: 0;
29+
}
30+
31+
&.ds-admin-sidebar-unpinned {
32+
padding-left: var(--ds-admin-sidebar-fixed-element-width);
33+
}
34+
35+
&.ds-admin-sidebar-pinned {
36+
padding-left: var(--ds-admin-sidebar-total-width);
37+
}
38+
39+
// Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled)
40+
// so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates.
41+
&.ds-admin-sidebar-animate {
42+
transition: padding-left 300ms ease-in-out;
43+
}
44+
}

src/app/root/root.component.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
NgClass,
44
} from '@angular/common';
55
import {
6+
AfterViewInit,
67
Component,
78
Inject,
89
Input,
@@ -38,7 +39,6 @@ import {
3839
} from '../core/services/window.service';
3940
import { ThemedFooterComponent } from '../footer/themed-footer.component';
4041
import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component';
41-
import { slideSidebarPadding } from '../shared/animations/slide';
4242
import { HostWindowService } from '../shared/host-window.service';
4343
import { LiveRegionComponent } from '../shared/live-region/live-region.component';
4444
import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component';
@@ -52,7 +52,6 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
5252
selector: 'ds-base-root',
5353
templateUrl: './root.component.html',
5454
styleUrls: ['./root.component.scss'],
55-
animations: [slideSidebarPadding],
5655
standalone: true,
5756
imports: [
5857
AsyncPipe,
@@ -69,12 +68,30 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
6968
TranslateModule,
7069
],
7170
})
72-
export class RootComponent implements OnInit {
71+
export class RootComponent implements OnInit, AfterViewInit {
7372
theme: Observable<ThemeConfig> = of({} as any);
7473
isSidebarVisible$: Observable<boolean>;
7574
slideSidebarOver$: Observable<boolean>;
7675
collapsedSidebarWidth$: Observable<string>;
7776
expandedSidebarWidth$: Observable<string>;
77+
78+
/**
79+
* The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the
80+
* outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular
81+
* animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it
82+
* is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live
83+
* app) — no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware.
84+
*/
85+
sidebarPaddingState$: Observable<string>;
86+
87+
/**
88+
* Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial
89+
* SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS
90+
* transition would animate that initial 0->gutter change (the overlay settle detector only watches
91+
* DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed.
92+
* Off on the server and on first render, so only genuine pin/unpin toggles animate.
93+
*/
94+
gutterTransitionEnabled = false;
7895
notificationOptions: INotificationBoardOptions;
7996
models: any;
8097

@@ -130,11 +147,28 @@ export class RootComponent implements OnInit {
130147
startWith(true),
131148
);
132149

150+
// Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the
151+
// animation needs a concrete width from the browser-only CSS-variable store, so on the server it
152+
// rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed.
153+
// The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically
154+
// on server and browser — fixing the jump without any hardcoded width.
155+
this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe(
156+
map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'),
157+
);
158+
133159
if (this.router.url === getPageInternalServerErrorRoute()) {
134160
this.shouldShowRouteLoader = false;
135161
}
136162
}
137163

164+
ngAfterViewInit(): void {
165+
// Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not
166+
// defined under SSR), so the initial padding resolution never animates — see gutterTransitionEnabled.
167+
if (typeof requestAnimationFrame === 'function') {
168+
requestAnimationFrame(() => { this.gutterTransitionEnabled = true; });
169+
}
170+
}
171+
138172
skipToMainContent() {
139173
const mainContent = document.getElementById('main-content');
140174
if (mainContent) {

src/themes/custom/app/root/root.component.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { ThemedBreadcrumbsComponent } from '../../../../app/breadcrumbs/themed-b
1111
import { ThemedFooterComponent } from '../../../../app/footer/themed-footer.component';
1212
import { ThemedHeaderNavbarWrapperComponent } from '../../../../app/header-nav-wrapper/themed-header-navbar-wrapper.component';
1313
import { RootComponent as BaseComponent } from '../../../../app/root/root.component';
14-
import { slideSidebarPadding } from '../../../../app/shared/animations/slide';
1514
import { LiveRegionComponent } from '../../../../app/shared/live-region/live-region.component';
1615
import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component';
1716
import { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.component';
@@ -23,7 +22,6 @@ import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-aler
2322
styleUrls: ['../../../../app/root/root.component.scss'],
2423
// templateUrl: './root.component.html',
2524
templateUrl: '../../../../app/root/root.component.html',
26-
animations: [slideSidebarPadding],
2725
standalone: true,
2826
imports: [
2927
AsyncPipe,

0 commit comments

Comments
 (0)