Skip to content

Commit 38cc487

Browse files
committed
feat: add scroll progress indicator to navigation header
1 parent 98ed668 commit 38cc487

5 files changed

Lines changed: 518 additions & 0 deletions

File tree

src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
22
import { JetBrains_Mono, Inter } from "next/font/google";
33
import "./globals.css";
44
import { ThemeProvider } from '../lib/theme/ThemeContext'
5+
import { ProgressBar } from '@/components/ui/ProgressBar';
56

67
const jetbrainsMono = JetBrains_Mono({
78
variable: "--font-jetbrains-mono",
@@ -29,6 +30,7 @@ export default function RootLayout({
2930
className={`${jetbrainsMono.variable} ${inter.variable} antialiased`}
3031
suppressHydrationWarning={true}
3132
>
33+
<ProgressBar />
3234
<ThemeProvider>
3335
{children}
3436
</ThemeProvider>

src/components/ui/ProgressBar.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use client";
2+
3+
import React, { useEffect, useState } from 'react';
4+
import { useScrollProgress } from '@/lib/hooks/useScrollProgress';
5+
6+
// GitHub accent colors
7+
const ACCENT_LIGHT = '#0969da';
8+
const ACCENT_DARK = '#58a6ff';
9+
10+
function useIsMobile() {
11+
const [isMobile, setIsMobile] = useState(false);
12+
useEffect(() => {
13+
function handleResize() {
14+
setIsMobile(window.innerWidth < 768);
15+
}
16+
handleResize();
17+
window.addEventListener('resize', handleResize);
18+
return () => window.removeEventListener('resize', handleResize);
19+
}, []);
20+
return isMobile;
21+
}
22+
23+
function useThemeAccent() {
24+
// Use matchMedia to detect dark mode
25+
const [isDark, setIsDark] = useState(false);
26+
useEffect(() => {
27+
const mql = window.matchMedia('(prefers-color-scheme: dark)');
28+
setIsDark(mql.matches);
29+
const listener = (e: MediaQueryListEvent) => setIsDark(e.matches);
30+
mql.addEventListener('change', listener);
31+
return () => mql.removeEventListener('change', listener);
32+
}, []);
33+
return isDark ? ACCENT_DARK : ACCENT_LIGHT;
34+
}
35+
36+
export const ProgressBar: React.FC = () => {
37+
const { progress } = useScrollProgress();
38+
const isMobile = useIsMobile();
39+
const accent = useThemeAccent();
40+
41+
if (isMobile) return null;
42+
43+
return (
44+
<div
45+
data-testid="progress-bar"
46+
style={{
47+
position: 'fixed',
48+
top: 0,
49+
left: 0,
50+
width: '100%',
51+
height: '2px',
52+
background: 'transparent',
53+
zIndex: 50,
54+
pointerEvents: 'none',
55+
}}
56+
>
57+
<div
58+
data-testid="progress-bar-fill"
59+
style={{
60+
width: `${Math.round(progress * 100)}%`,
61+
height: '100%',
62+
background: accent,
63+
transition: 'width 0.2s cubic-bezier(0.4,0,0.2,1)',
64+
borderRadius: '1px',
65+
}}
66+
/>
67+
</div>
68+
);
69+
};
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import { ProgressBar } from '../ProgressBar';
5+
6+
// Mock useScrollProgress
7+
jest.mock('@/lib/hooks/useScrollProgress', () => ({
8+
useScrollProgress: jest.fn(),
9+
}));
10+
const { useScrollProgress } = require('@/lib/hooks/useScrollProgress');
11+
12+
// Helper to set window width
13+
function setWindowWidth(width: number) {
14+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: width });
15+
window.dispatchEvent(new Event('resize'));
16+
}
17+
18+
describe('ProgressBar', () => {
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
setWindowWidth(1024); // Desktop by default
22+
});
23+
24+
it('renders at the top of the viewport with correct styles', () => {
25+
useScrollProgress.mockReturnValue({ progress: 0.5, isScrolling: true });
26+
render(<ProgressBar />);
27+
const bar = screen.getByTestId('progress-bar');
28+
expect(bar).toBeInTheDocument();
29+
expect(bar).toHaveStyle({
30+
position: 'fixed',
31+
top: '0px',
32+
left: '0px',
33+
width: '100%',
34+
height: '2px',
35+
'z-index': '50',
36+
'background': 'transparent',
37+
});
38+
});
39+
40+
it('shows correct progress width', () => {
41+
useScrollProgress.mockReturnValue({ progress: 0.3, isScrolling: true });
42+
render(<ProgressBar />);
43+
const fill = screen.getByTestId('progress-bar-fill');
44+
expect(fill).toHaveStyle('width: 30%');
45+
});
46+
47+
it('uses GitHub accent color for fill (light mode)', () => {
48+
useScrollProgress.mockReturnValue({ progress: 0.7, isScrolling: true });
49+
render(<ProgressBar />);
50+
const fill = screen.getByTestId('progress-bar-fill');
51+
expect(fill).toHaveStyle('background: #0969da');
52+
});
53+
54+
it('uses GitHub accent color for fill (dark mode)', () => {
55+
// Simulate dark mode
56+
window.matchMedia = jest.fn().mockImplementation(query => ({
57+
matches: query.includes('dark'),
58+
addEventListener: jest.fn(),
59+
removeEventListener: jest.fn(),
60+
}));
61+
useScrollProgress.mockReturnValue({ progress: 0.7, isScrolling: true });
62+
render(<ProgressBar />);
63+
const fill = screen.getByTestId('progress-bar-fill');
64+
expect(fill).toHaveStyle('background: #58a6ff');
65+
});
66+
67+
it('has smooth width transition', () => {
68+
useScrollProgress.mockReturnValue({ progress: 0.4, isScrolling: true });
69+
render(<ProgressBar />);
70+
const fill = screen.getByTestId('progress-bar-fill');
71+
expect(fill).toHaveStyle('transition: width 0.2s cubic-bezier(0.4,0,0.2,1)');
72+
});
73+
74+
it('hides on mobile devices (<768px)', () => {
75+
setWindowWidth(500);
76+
useScrollProgress.mockReturnValue({ progress: 0.5, isScrolling: true });
77+
render(<ProgressBar />);
78+
const bar = screen.queryByTestId('progress-bar');
79+
expect(bar).not.toBeInTheDocument();
80+
});
81+
82+
it('renders 0% progress correctly', () => {
83+
useScrollProgress.mockReturnValue({ progress: 0, isScrolling: true });
84+
render(<ProgressBar />);
85+
const fill = screen.getByTestId('progress-bar-fill');
86+
expect(fill).toHaveStyle('width: 0%');
87+
});
88+
89+
it('renders 100% progress correctly', () => {
90+
useScrollProgress.mockReturnValue({ progress: 1, isScrolling: true });
91+
render(<ProgressBar />);
92+
const fill = screen.getByTestId('progress-bar-fill');
93+
expect(fill).toHaveStyle('width: 100%');
94+
});
95+
});

0 commit comments

Comments
 (0)