Skip to content

Commit 1321b8a

Browse files
jr-rkclaude
andauthored
UFAL/Backport #1333: admin-sidebar gutter via CSS var (no logged-in reload shift) (#1350)
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 (identical to the vsb-tuo pre-#1333 generation; applied verbatim), not a cherry-pick. Refs: dataquest-dev/dspace-customers#717, #1333 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c49ef5e commit 1321b8a

4 files changed

Lines changed: 71 additions & 11 deletions

File tree

src/app/root/root.component.html

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

5-
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader" [@slideSidebarPadding]="{
6-
value: (!(isSidebarVisible$ | async) ? 'hidden' : (slideSidebarOver$ | async) ? 'unpinned' : 'pinned'),
7-
params: { collapsedWidth: (collapsedSidebarWidth$ | async), expandedWidth: (expandedSidebarWidth$ | async) }
8-
}">
5+
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader"
6+
[class.ds-admin-sidebar-animate]="gutterTransitionEnabled"
7+
[ngClass]="'ds-admin-sidebar-' + (sidebarPaddingState$ | async)">
98
<ds-themed-admin-sidebar [expandedSidebarWidth$]="expandedSidebarWidth$" [collapsedSidebarWidth$]="collapsedSidebarWidth$"></ds-themed-admin-sidebar>
109
<div class="inner-wrapper">
1110
<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: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { first, map, skipWhile, startWith } from 'rxjs/operators';
2-
import { Component, Input, OnInit } from '@angular/core';
2+
import { AfterViewInit, Component, Input, OnInit } from '@angular/core';
33
import { Router } from '@angular/router';
44

55
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
@@ -8,7 +8,6 @@ import { MenuService } from '../shared/menu/menu.service';
88
import { HostWindowService } from '../shared/host-window.service';
99
import { ThemeConfig } from '../../config/theme.config';
1010
import { environment } from '../../environments/environment';
11-
import { slideSidebarPadding } from '../shared/animations/slide';
1211
import { MenuID } from '../shared/menu/menu-id.model';
1312
import { getPageInternalServerErrorRoute } from '../app-routing-paths';
1413
import { INotificationBoardOptions } from 'src/config/notifications-config.interfaces';
@@ -17,14 +16,31 @@ import { INotificationBoardOptions } from 'src/config/notifications-config.inter
1716
selector: 'ds-root',
1817
templateUrl: './root.component.html',
1918
styleUrls: ['./root.component.scss'],
20-
animations: [slideSidebarPadding],
2119
})
22-
export class RootComponent implements OnInit {
20+
export class RootComponent implements OnInit, AfterViewInit {
2321
theme: Observable<ThemeConfig> = of({} as any);
2422
isSidebarVisible$: Observable<boolean>;
2523
slideSidebarOver$: Observable<boolean>;
2624
collapsedSidebarWidth$: Observable<string>;
2725
expandedSidebarWidth$: Observable<string>;
26+
27+
/**
28+
* The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the
29+
* outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular
30+
* animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it
31+
* is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live
32+
* app) — no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware.
33+
*/
34+
sidebarPaddingState$: Observable<string>;
35+
36+
/**
37+
* Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial
38+
* SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS
39+
* transition would animate that initial 0->gutter change (the overlay settle detector only watches
40+
* DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed.
41+
* Off on the server and on first render, so only genuine pin/unpin toggles animate.
42+
*/
43+
gutterTransitionEnabled = false;
2844
notificationOptions: INotificationBoardOptions;
2945
models: any;
3046

@@ -42,14 +58,16 @@ export class RootComponent implements OnInit {
4258
private router: Router,
4359
private cssService: CSSVariableService,
4460
private menuService: MenuService,
45-
private windowService: HostWindowService
61+
private windowService: HostWindowService,
4662
) {
4763
this.notificationOptions = environment.notifications;
4864
}
4965

5066
ngOnInit() {
5167
this.isSidebarVisible$ = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
5268

69+
// Still provided to <ds-themed-admin-sidebar>; the sidebar element itself sizes from CSS vars, so a
70+
// null value on the server (the store is browser-only) is harmless there.
5371
this.expandedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-total-width').pipe(
5472
skipWhile((val) => !val),
5573
first(),
@@ -66,11 +84,28 @@ export class RootComponent implements OnInit {
6684
startWith(true),
6785
);
6886

87+
// Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the
88+
// animation needs a concrete width from the browser-only CSS-variable store, so on the server it
89+
// rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed.
90+
// The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically
91+
// on server and browser — fixing the jump without any hardcoded width.
92+
this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe(
93+
map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'),
94+
);
95+
6996
if (this.router.url === getPageInternalServerErrorRoute()) {
7097
this.shouldShowRouteLoader = false;
7198
}
7299
}
73100

101+
ngAfterViewInit(): void {
102+
// Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not
103+
// defined under SSR), so the initial padding resolution never animates — see gutterTransitionEnabled.
104+
if (typeof requestAnimationFrame === 'function') {
105+
requestAnimationFrame(() => { this.gutterTransitionEnabled = true; });
106+
}
107+
}
108+
74109
skipToMainContent() {
75110
const mainContent = document.getElementById('main-content');
76111
if (mainContent) {

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Component } from '@angular/core';
2-
import { slideSidebarPadding } from '../../../../app/shared/animations/slide';
32
import { RootComponent as BaseComponent } from '../../../../app/root/root.component';
43

54
@Component({
@@ -8,7 +7,6 @@ import { RootComponent as BaseComponent } from '../../../../app/root/root.compon
87
styleUrls: ['../../../../app/root/root.component.scss'],
98
// templateUrl: './root.component.html',
109
templateUrl: '../../../../app/root/root.component.html',
11-
animations: [slideSidebarPadding],
1210
})
1311
export class RootComponent extends BaseComponent {
1412

0 commit comments

Comments
 (0)