make navbar dockable when scrolling
All checks were successful
publish.yml / publish (push) Successful in 1m6s

This commit is contained in:
2026-04-17 13:05:52 +02:00
parent 67676721d6
commit 1470b0723b
4 changed files with 169 additions and 8 deletions

View File

@@ -25,13 +25,41 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
padding: 0.9rem 1.2rem;
gap: 1.5rem;
padding: 1.1rem 1.45rem;
border: 1px solid #30363d;
border-radius: 16px;
border-radius: 18px;
background: rgba(22, 27, 34, 0.75);
backdrop-filter: blur(9px);
z-index: 3;
transition:
top 260ms ease,
left 260ms ease,
width 260ms ease,
transform 260ms ease,
border-radius 260ms ease,
padding 260ms ease,
background-color 260ms ease;
}
.sidebar.is-partially-docked {
top: 0.2rem;
left: 50%;
transform: translateX(-50%);
width: min(1200px, calc(100% - 1rem));
border-radius: 8px;
padding: 1rem 1.35rem;
background: rgba(22, 27, 34, 0.84);
}
.sidebar.is-docked {
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
border-radius: 0;
padding: 1rem 1.5rem;
background: rgba(22, 27, 34, 0.9);
}
.sidebar-header {
@@ -43,7 +71,7 @@
.brand {
color: #e6edf3;
font-size: 1.05rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
@@ -64,10 +92,11 @@
}
.nav a {
padding: 0.55rem 0.8rem;
padding: 0.62rem 0.95rem;
border: 1px solid transparent;
border-radius: 12px;
color: #c9d1d9;
font-size: 1rem;
text-decoration: none;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
@@ -154,7 +183,9 @@
padding: calc(6.5rem + env(safe-area-inset-top)) 0.5rem 3rem;
}
.sidebar {
.sidebar,
.sidebar.is-partially-docked,
.sidebar.is-docked {
position: fixed;
top: 0.75rem;
right: 0.5rem;
@@ -167,6 +198,28 @@
align-items: stretch;
justify-content: flex-start;
border-radius: 16px;
transition:
top 300ms ease,
border-radius 300ms ease,
padding 300ms ease,
background-color 300ms ease;
}
.sidebar.is-partially-docked {
top: 0.35rem;
border-radius: 12px;
padding: 0.98rem 1.1rem;
background: rgba(22, 27, 34, 0.88);
}
.sidebar.is-docked {
top: 0;
left: 0;
right: 0;
width: auto;
border-radius: 0;
padding: 0.92rem 1rem;
background: rgba(22, 27, 34, 0.92);
}
.subtitle {

View File

@@ -1,7 +1,11 @@
<div class="shell">
<app-interactive-background></app-interactive-background>
<aside class="sidebar">
<aside
class="sidebar"
[class.is-partially-docked]="isNavPartiallyDocked"
[class.is-docked]="isNavDocked"
>
<div class="sidebar-header">
<a class="brand" routerLink="/home" (click)="closeMenu()">Tim Kainz</a>
<button

View File

@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { App } from './app';
describe('App', () => {
@@ -27,4 +28,70 @@ describe('App', () => {
expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4);
expect(compiled.querySelector('.site-footer')).toBeTruthy();
});
it('should transition navbar from partial to full dock based on scroll depth', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const scrollSpy = vi
.spyOn(window, 'scrollY', 'get')
.mockReturnValueOnce(24)
.mockReturnValueOnce(140)
.mockReturnValueOnce(0);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(true);
expect(app.isNavDocked).toBe(false);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(false);
expect(app.isNavDocked).toBe(true);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(false);
expect(app.isNavDocked).toBe(false);
scrollSpy.mockRestore();
});
it('should keep full dock until scroll drops below the full-dock exit threshold', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const scrollSpy = vi
.spyOn(window, 'scrollY', 'get')
.mockReturnValueOnce(110)
.mockReturnValueOnce(80)
.mockReturnValueOnce(60);
app.onWindowScroll();
expect(app.isNavDocked).toBe(true);
app.onWindowScroll();
expect(app.isNavDocked).toBe(true);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(true);
expect(app.isNavDocked).toBe(false);
scrollSpy.mockRestore();
});
it('should reflect partial dock state on the sidebar class', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.isNavPartiallyDocked = true;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.sidebar.is-partially-docked')).toBeTruthy();
});
it('should reflect fully docked state on the sidebar class', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.isNavDocked = true;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.sidebar.is-docked')).toBeTruthy();
});
});

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, HostListener } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { InteractiveBackground } from '../components/interactive-background/interactive-background';
@@ -10,7 +10,44 @@ import { InteractiveBackground } from '../components/interactive-background/inte
})
export class App {
isMenuOpen = false;
isNavPartiallyDocked = false;
isNavDocked = false;
readonly currentYear = new Date().getFullYear();
private readonly partialDockOffset = 8;
private readonly fullDockEnterOffset = 96;
private readonly fullDockExitOffset = 64;
@HostListener('window:scroll')
onWindowScroll(): void {
const currentScrollY = window.scrollY || document.documentElement.scrollTop || 0;
if (currentScrollY <= this.partialDockOffset) {
this.isNavPartiallyDocked = false;
this.isNavDocked = false;
return;
}
if (this.isNavDocked) {
if (currentScrollY <= this.fullDockExitOffset) {
this.isNavPartiallyDocked = true;
this.isNavDocked = false;
return;
}
this.isNavPartiallyDocked = false;
this.isNavDocked = true;
return;
}
if (currentScrollY >= this.fullDockEnterOffset) {
this.isNavPartiallyDocked = false;
this.isNavDocked = true;
return;
}
this.isNavPartiallyDocked = true;
this.isNavDocked = false;
}
toggleMenu(): void {
this.isMenuOpen = !this.isMenuOpen;