Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions web/src/components/QuickNav/QuickNav.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,18 @@ export const QuickNavArrow = styled(Icon)`
transform var(--dur-fast) var(--ease-out);
`;

export const QuickNavLink = styled.a<{ $open: boolean; $delay: number }>`
export const QuickNavLink = styled.a<{ $active: boolean; $open: boolean; $delay: number }>`
display: grid;
grid-template-columns: 2rem 1fr auto;
min-height: 2.65rem;
flex: 1;
align-items: center;
padding: 0 0.25rem;
border-bottom: 1px solid var(--outline);
color: var(--ink-secondary);
background-color: transparent;
color: ${({ $active }) => ($active ? "var(--ink-primary)" : "var(--ink-secondary)")};
font-size: 0.93rem;
font-weight: ${({ $active }) => ($active ? 500 : 400)};
letter-spacing: -0.02em;
opacity: ${({ $open }) => ($open ? 1 : 0)};
transform: translateY(${({ $open }) => ($open ? 0 : "7px")});
Expand All @@ -117,9 +120,24 @@ export const QuickNavLink = styled.a<{ $open: boolean; $delay: number }>`
opacity 280ms ease ${({ $delay }) => `${$delay}ms`},
transform 420ms var(--ease-out) ${({ $delay }) => `${$delay}ms`};

&:hover,
${QuickNavIndex},
${QuickNavArrow} {
color: ${({ $active }) => ($active ? "var(--accent)" : undefined)};
}

${QuickNavArrow} {
transform: none;
}

&:hover {
background-color: rgba(244, 238, 228, 0.035);
color: var(--ink-primary);
}

&:focus-visible {
background: linear-gradient(90deg, rgba(232, 133, 97, 0.08), transparent 75%);
outline: 1px solid rgba(232, 133, 97, 0.35);
outline-offset: -1px;
background-color: rgba(232, 133, 97, 0.07);
color: var(--ink-primary);
}

Expand All @@ -132,7 +150,7 @@ export const QuickNavLink = styled.a<{ $open: boolean; $delay: number }>`

&:hover ${QuickNavArrow},
&:focus-visible ${QuickNavArrow} {
transform: translateX(3px);
transform: translateX(2px);
}

@media (prefers-reduced-motion: reduce) {
Expand Down
78 changes: 63 additions & 15 deletions web/src/components/QuickNav/QuickNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ const easeOutExpo = (progress: number) => {

export function QuickNav() {
const [open, setOpen] = useState(false);
const [activeHref, setActiveHref] = useState<string>(ITEMS[0].href);
const prefersReducedMotion = usePrefersReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const scrollFrameRef = useRef<number | null>(null);
const scrollDelayRef = useRef<number | null>(null);
const activeIndex = ITEMS.findIndex((item) => item.href === activeHref);

const cancelPendingScroll = () => {
if (scrollFrameRef.current !== null) {
Expand Down Expand Up @@ -100,8 +102,44 @@ export function QuickNav() {
[],
);

useEffect(() => {
let frame: number | null = null;

const updateActiveSection = () => {
frame = null;
const marker = window.innerHeight * 0.36;
let currentHref: string = ITEMS[0].href;

for (const item of ITEMS) {
const section = document.querySelector<HTMLElement>(item.href);
if (!section || section.getBoundingClientRect().top > marker) break;
currentHref = item.href;
}

setActiveHref((current) => (current === currentHref ? current : currentHref));
};

const scheduleUpdate = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(updateActiveSection);
};

scheduleUpdate();
window.addEventListener("scroll", scheduleUpdate, { passive: true });
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("hashchange", scheduleUpdate);

return () => {
window.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("hashchange", scheduleUpdate);
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, []);

const finishAt = (target: HTMLElement, href: string) => {
window.history.pushState(null, "", href);
setActiveHref(href);

const hadTabIndex = target.hasAttribute("tabindex");
if (!hadTabIndex) target.setAttribute("tabindex", "-1");
Expand Down Expand Up @@ -150,6 +188,7 @@ export function QuickNav() {
const onItemClick = (event: ReactMouseEvent<HTMLAnchorElement>, href: string) => {
event.preventDefault();
cancelPendingScroll();
setActiveHref(href);
setOpen(false);

const delay = prefersReducedMotion ? 0 : 60;
Expand All @@ -165,24 +204,33 @@ export function QuickNav() {
<QuickNavContent $open={open}>
<QuickNavHeading>
<QuickNavEyebrow>Site index</QuickNavEyebrow>
<QuickNavCount>{String(ITEMS.length).padStart(2, "0")}</QuickNavCount>
<QuickNavCount>
{String(activeIndex + 1).padStart(2, "0")} /{" "}
{String(ITEMS.length).padStart(2, "0")}
</QuickNavCount>
</QuickNavHeading>

<QuickNavLinks aria-label="Page sections">
{ITEMS.map((item, index) => (
<QuickNavLink
key={item.href}
href={item.href}
$open={open}
$delay={120 + index * 24}
tabIndex={open ? 0 : -1}
onClick={(event) => onItemClick(event, item.href)}
>
<QuickNavIndex>{String(index + 1).padStart(2, "0")}</QuickNavIndex>
<span>{item.label}</span>
<QuickNavArrow name="arrow" size={15} />
</QuickNavLink>
))}
{ITEMS.map((item, index) => {
const active = item.href === activeHref;

return (
<QuickNavLink
key={item.href}
href={item.href}
$active={active}
$open={open}
$delay={120 + index * 24}
aria-current={active ? "location" : undefined}
tabIndex={open ? 0 : -1}
onClick={(event) => onItemClick(event, item.href)}
>
<QuickNavIndex>{String(index + 1).padStart(2, "0")}</QuickNavIndex>
<span>{item.label}</span>
<QuickNavArrow name="arrow" size={15} />
</QuickNavLink>
);
})}
</QuickNavLinks>
</QuickNavContent>
</QuickNavPanel>
Expand Down