Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | 4x 17x 17x 17x 17x 22x 17x 22x 22x 22x 22x 22x 22x 17x 11x 11x 11x 11x 11x 11x 11x 11x 11x 17x 11x 11x 11x 11x 10x 10x 10x 11x 17x 46x 1x 46x | 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { CaretDownIcon } from './icons';
export interface TabBarProps {
tabs: { name: string; url: string; active: boolean }[];
}
/**
* Tab strip for pages that have nav-declared tabs (see `getTabContext` in
* `src/lib/navigation.ts`). Each tab is a URL — the strip is a `<nav>` of
* `<Link>`s, not an ARIA tablist, because activating a tab navigates to a new
* route rather than swapping panels in-place. At the Small breakpoint the strip
* collapses into a native `<select>`.
*/
export function TabBar({ tabs }: TabBarProps) {
const router = useRouter();
const scrollRef = useRef<HTMLDivElement>(null);
// Edge-fade flags: whether there's scrolled-past content on each side.
const [fadeStart, setFadeStart] = useState(false);
const [fadeEnd, setFadeEnd] = useState(false);
// The value that drives the mobile <select>; fall back to the first tab.
const activeUrl = (tabs.find((t) => t.active) ?? tabs[0])?.url ?? '';
const updateFades = useCallback(() => {
const el = scrollRef.current;
Iif (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const canScroll = scrollWidth - clientWidth > 1;
setFadeStart(canScroll && scrollLeft > 1);
setFadeEnd(canScroll && scrollLeft + clientWidth < scrollWidth - 1);
}, []);
// Keep fades in sync with scroll position and any size change.
useEffect(() => {
const el = scrollRef.current;
Iif (!el) return;
updateFades();
el.addEventListener('scroll', updateFades, { passive: true });
const ro = new ResizeObserver(updateFades);
ro.observe(el);
return () => {
el.removeEventListener('scroll', updateFades);
ro.disconnect();
};
}, [updateFades, tabs]);
// On load / navigation, center the active tab so it's never hidden off-screen.
useEffect(() => {
const el = scrollRef.current;
Iif (!el) return;
const active = el.querySelector<HTMLElement>('.site-tabs__link--active');
if (active) {
const sRect = el.getBoundingClientRect();
const aRect = active.getBoundingClientRect();
el.scrollLeft += aRect.left - sRect.left - (sRect.width - aRect.width) / 2;
}
updateFades();
}, [tabs, updateFades]);
return (
<>
<div
className={`site-tabs${fadeStart ? ' site-tabs--fade-start' : ''}${fadeEnd ? ' site-tabs--fade-end' : ''}`}
>
<div className="site-tabs__scroll" ref={scrollRef}>
<nav className="site-tabs__track" aria-label="Page sections">
{tabs.map((tab) => (
<Link
key={tab.url}
href={tab.url}
aria-current={tab.active ? 'page' : undefined}
className={tab.active ? 'site-tabs__link site-tabs__link--active' : 'site-tabs__link'}
>
{tab.name}
</Link>
))}
</nav>
</div>
</div>
<div className="site-tabs__select">
<select
className="site-tabs__select-input"
value={activeUrl}
onChange={(e) => router.push(e.target.value)}
aria-label="Select tab"
>
{tabs.map((tab) => (
<option key={tab.url} value={tab.url}>
{tab.name}
</option>
))}
</select>
<CaretDownIcon className="site-tabs__select-caret" aria-hidden="true" />
</div>
</>
);
}
|