integration: collapse the mobile nav after hydration and keep fragment targets clear of the sticky header
SSR still ships the expanded menu, but phones collapse it after render, stop sticking the header below md, and use the header-offset token so case anchors no longer sit underneath the bar. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding-block: var(--space-3);
|
||||
@@ -157,6 +157,10 @@
|
||||
}
|
||||
|
||||
@include bp.respond-to(md) {
|
||||
.site-header {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,67 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, TitleStrategy } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||
import { provideRouter, Router, TitleStrategy } from '@angular/router';
|
||||
import { App, WIDE_NAV_QUERY } from './app';
|
||||
import { routes } from './app.routes';
|
||||
import { SITE_CONTENT } from './core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||
import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
|
||||
|
||||
function mockViewport(wide: boolean): void {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: wide && query === WIDE_NAV_QUERY,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}) as MediaQueryList,
|
||||
});
|
||||
}
|
||||
|
||||
async function configureApp(
|
||||
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
|
||||
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
|
||||
...extraProviders,
|
||||
],
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
async function flush(fixture: ComponentFixture<App>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function toggle(root: HTMLElement): HTMLButtonElement {
|
||||
return root.querySelector('.nav-toggle') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
mockViewport(true);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
|
||||
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
|
||||
],
|
||||
}).compileComponents();
|
||||
await configureApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(window, 'matchMedia');
|
||||
});
|
||||
|
||||
it('should create the shell', async () => {
|
||||
@@ -72,4 +111,60 @@ describe('App', () => {
|
||||
expect(site?.contains(dialog)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => {
|
||||
TestBed.resetTestingModule();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
mockViewport(false);
|
||||
await configureApp();
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const shell = fixture.componentInstance as unknown as { navOpen: () => boolean };
|
||||
expect(shell.navOpen()).toBe(true);
|
||||
|
||||
await flush(fixture);
|
||||
const button = toggle(fixture.nativeElement);
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(button.getAttribute('aria-controls')).toBe('primary-nav');
|
||||
expect(button.getAttribute('aria-label')).toBeTruthy();
|
||||
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not collapse the nav on the server even when the viewport helper would be narrow', async () => {
|
||||
TestBed.resetTestingModule();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
mockViewport(false);
|
||||
await configureApp([{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await flush(fixture);
|
||||
|
||||
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('true');
|
||||
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the nav on navigation when the viewport is narrow', async () => {
|
||||
TestBed.resetTestingModule();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
mockViewport(false);
|
||||
await configureApp();
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await flush(fixture);
|
||||
|
||||
const button = toggle(fixture.nativeElement);
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
await TestBed.inject(Router).navigateByUrl('/projekte');
|
||||
await flush(fixture);
|
||||
|
||||
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { DOCUMENT, ViewportScroller } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
Injector,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import { DotBackground } from './components/dot-background/dot-background';
|
||||
import { SHELL_COPY } from './core/content/shell-copy';
|
||||
import { SITE_CONFIG } from './core/content/site-config';
|
||||
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
||||
import { LocaleService } from './core/i18n/locale.service';
|
||||
import { NavigationService } from './core/navigation/navigation.service';
|
||||
import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
|
||||
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './shared/command-palette/command-palette.service';
|
||||
|
||||
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
|
||||
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -28,6 +43,12 @@ export class App {
|
||||
protected readonly navigation = inject(NavigationService);
|
||||
protected readonly localeService = inject(LocaleService);
|
||||
protected readonly palette = inject(CommandPaletteService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly viewportScroller = inject(ViewportScroller);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly wideNav = viewportMatches(WIDE_NAV_QUERY);
|
||||
protected readonly siteConfig = SITE_CONFIG;
|
||||
protected readonly navOpen = signal(true);
|
||||
|
||||
@@ -38,7 +59,61 @@ export class App {
|
||||
this.navOpen() ? this.shell().menuClose : this.shell().menuOpen,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
this.collapseNavIfNarrow();
|
||||
this.bindHeaderScrollOffset();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(() => this.collapseNavIfNarrow());
|
||||
}
|
||||
|
||||
protected toggleNav(): void {
|
||||
this.navOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
private collapseNavIfNarrow(): void {
|
||||
if (!this.isBrowser || this.wideNav) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.navOpen.set(false);
|
||||
}
|
||||
|
||||
private bindHeaderScrollOffset(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewportScroller.setOffset(() => [0, this.headerOffsetPx()]);
|
||||
}
|
||||
|
||||
private headerOffsetPx(): number {
|
||||
const view = this.document.defaultView;
|
||||
if (!view) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const styles = view.getComputedStyle(this.document.documentElement);
|
||||
const raw = styles.getPropertyValue('--header-offset').trim();
|
||||
const numeric = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (raw.endsWith('rem')) {
|
||||
const rootSize = Number.parseFloat(styles.fontSize);
|
||||
return numeric * (Number.isFinite(rootSize) ? rootSize : 16);
|
||||
}
|
||||
|
||||
return numeric;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'breakpoints' as bp;
|
||||
|
||||
/* Design tokens — the only place raw brand hex values are defined. */
|
||||
:root {
|
||||
/* Color — surface ramp built on #0a0a0f */
|
||||
@@ -79,6 +81,16 @@
|
||||
--focus-ring-color: var(--color-accent);
|
||||
--focus-ring-width: 2px;
|
||||
--focus-ring-offset: 3px;
|
||||
|
||||
/* Scroll offset: no sticky header below md, so phones need no extra inset. */
|
||||
--header-offset: 0rem;
|
||||
}
|
||||
|
||||
@include bp.respond-to(md) {
|
||||
:root {
|
||||
/* Matches the sticky header once the primary nav and service list sit in one row. */
|
||||
--header-offset: 22rem;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -87,11 +99,20 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
scroll-padding-top: var(--header-offset);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
:where(article, section, h1, h2, h3)[id] {
|
||||
scroll-margin-top: var(--header-offset);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-surface);
|
||||
|
||||
Reference in New Issue
Block a user