a5be07ac0f
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
iOS Chrome (and some Android browsers) auto-hide their URL bar on scroll. This grows the visual viewport without changing the layout viewport, so a position:fixed bar pinned to bottom:0 — which is relative to the layout viewport — appears to float mid-screen with a gap beneath it. Safari masks the bug because its toolbar shrinks rather than fully retracting. Track the delta between the visual and layout viewports via the VisualViewport API and write it to a --mobile-bar-offset CSS var. The bar uses translate3d to apply that offset, which both fixes the gap and enables hardware compositing so it tracks the toolbar animation without flicker. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
/**
|
|
* Navigation Component
|
|
* Top header nav for desktop; bottom tab bar for mobile (≤640px).
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
import { useComparison } from '@/hooks/useComparison';
|
|
import styles from './Navigation.module.css';
|
|
|
|
type IconProps = { className?: string };
|
|
|
|
const SearchIcon = ({ className }: IconProps) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m20 20-3.5-3.5" />
|
|
</svg>
|
|
);
|
|
|
|
const CompareIcon = ({ className }: IconProps) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M4 7h13l-3-3" />
|
|
<path d="M20 17H7l3 3" />
|
|
</svg>
|
|
);
|
|
|
|
const RankingsIcon = ({ className }: IconProps) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M7 21V11" />
|
|
<path d="M12 21V4" />
|
|
<path d="M17 21v-7" />
|
|
</svg>
|
|
);
|
|
|
|
const AdmissionsIcon = ({ className }: IconProps) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<rect x="3" y="5" width="18" height="16" rx="2" />
|
|
<path d="M3 10h18M8 3v4M16 3v4" />
|
|
</svg>
|
|
);
|
|
|
|
export function Navigation() {
|
|
const pathname = usePathname();
|
|
const { selectedSchools } = useComparison();
|
|
|
|
const isActive = (path: string) => {
|
|
if (path === '/') return pathname === '/';
|
|
return pathname.startsWith(path);
|
|
};
|
|
|
|
/**
|
|
* iOS Chrome (and some Android browsers) auto-hide their URL bar on scroll,
|
|
* which grows the visual viewport without changing the layout viewport.
|
|
* `position: fixed; bottom: 0` sticks to the layout viewport, so our tab
|
|
* bar appears to float mid-screen with a gap beneath it. Track the delta
|
|
* via VisualViewport and apply it as a translate so the bar always sits
|
|
* flush against the visible bottom edge.
|
|
*/
|
|
useEffect(() => {
|
|
const vv = window.visualViewport;
|
|
if (!vv) return;
|
|
const root = document.documentElement;
|
|
const update = () => {
|
|
const offset = window.innerHeight - (vv.height + vv.offsetTop);
|
|
// Only positive offsets are meaningful (bar hidden → push down).
|
|
root.style.setProperty('--mobile-bar-offset', `${Math.max(0, offset)}px`);
|
|
};
|
|
update();
|
|
vv.addEventListener('resize', update);
|
|
vv.addEventListener('scroll', update);
|
|
return () => {
|
|
vv.removeEventListener('resize', update);
|
|
vv.removeEventListener('scroll', update);
|
|
root.style.removeProperty('--mobile-bar-offset');
|
|
};
|
|
}, []);
|
|
|
|
const items = [
|
|
{ href: '/', label: 'Search', Icon: SearchIcon },
|
|
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
|
{ href: '/rankings', label: 'Rankings', Icon: RankingsIcon },
|
|
{ href: '/admissions', label: 'Admissions', Icon: AdmissionsIcon },
|
|
] as const;
|
|
|
|
return (
|
|
<>
|
|
<header className={styles.header}>
|
|
<div className={styles.container}>
|
|
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
|
<span className={styles.logoIcon}>
|
|
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
|
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
|
</svg>
|
|
</span>
|
|
<span className={styles.logoText}>SchoolCompare</span>
|
|
</Link>
|
|
|
|
<nav className={styles.nav} aria-label="Main navigation">
|
|
{items.map(({ href, label }) => {
|
|
const active = isActive(href);
|
|
const showBadge = href === '/compare' && selectedSchools.length > 0;
|
|
return (
|
|
<Link
|
|
key={href}
|
|
href={href}
|
|
className={active ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
|
aria-current={active ? 'page' : undefined}
|
|
>
|
|
{label}
|
|
{showBadge && (
|
|
<span key={selectedSchools.length} className={styles.badge}>
|
|
{selectedSchools.length}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<nav className={styles.bottomBar} aria-label="Main navigation">
|
|
{items.map(({ href, label, Icon }) => {
|
|
const active = isActive(href);
|
|
const showBadge = href === '/compare' && selectedSchools.length > 0;
|
|
return (
|
|
<Link
|
|
key={href}
|
|
href={href}
|
|
className={active ? `${styles.tab} ${styles.tabActive}` : styles.tab}
|
|
aria-current={active ? 'page' : undefined}
|
|
>
|
|
<span className={styles.tabIconWrap}>
|
|
<Icon className={styles.tabIcon} />
|
|
{showBadge && (
|
|
<span key={selectedSchools.length} className={styles.tabBadge} aria-label={`${selectedSchools.length} selected`}>
|
|
{selectedSchools.length}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className={styles.tabLabel}>{label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</>
|
|
);
|
|
}
|