Change Wikipedia pills from filter tabs to scrollspy navigation

Pills now highlight based on which section is visible as you scroll.
Clicking a pill smooth-scrolls to that section. The 'All' pill and
filter-based empty states are removed since all sections are always shown.
This commit is contained in:
Alex Gleason
2026-03-23 13:13:54 -05:00
parent 030b277e8f
commit 12700fd025
+104 -46
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import {
@@ -11,7 +11,6 @@ import {
Loader2,
Newspaper,
Search,
Sparkles,
Star,
TrendingUp,
X,
@@ -33,7 +32,7 @@ import { cn } from '@/lib/utils';
// Types
// ---------------------------------------------------------------------------
type Section = 'all' | 'featured' | 'mostread' | 'onthisday' | 'news';
type Section = 'featured' | 'mostread' | 'news' | 'onthisday';
interface SectionMeta {
label: string;
@@ -44,14 +43,14 @@ interface SectionMeta {
// Constants
// ---------------------------------------------------------------------------
const SECTIONS: Record<Exclude<Section, 'all'>, SectionMeta> = {
const SECTIONS: Record<Section, SectionMeta> = {
featured: { label: 'Featured', icon: <Star className="size-3.5" /> },
mostread: { label: 'Trending', icon: <TrendingUp className="size-3.5" /> },
onthisday: { label: 'On This Day', icon: <Calendar className="size-3.5" /> },
news: { label: 'In the News', icon: <Newspaper className="size-3.5" /> },
onthisday: { label: 'On This Day', icon: <Calendar className="size-3.5" /> },
};
const SECTION_ORDER: Section[] = ['all', 'featured', 'mostread', 'news', 'onthisday'];
const SECTION_ORDER: Section[] = ['featured', 'mostread', 'news', 'onthisday'];
// ---------------------------------------------------------------------------
// Helpers
@@ -80,6 +79,61 @@ function truncateExtract(text: string, maxLen = 150): string {
return text.slice(0, maxLen).replace(/\s+\S*$/, '') + '\u2026';
}
// ---------------------------------------------------------------------------
// Scrollspy hook
// ---------------------------------------------------------------------------
function useScrollspy(
sectionRefs: Record<Section, RefObject<HTMLElement | null>>,
navBarRef: RefObject<HTMLElement | null>,
) {
const [active, setActive] = useState<Section>(SECTION_ORDER[0]);
// Guard against scroll-into-view triggering the observer
const isScrollingRef = useRef(false);
useEffect(() => {
const navBarHeight = navBarRef.current?.offsetHeight ?? 48;
// Trigger when a section crosses just below the sticky nav bar
const rootMargin = `-${navBarHeight + 8}px 0px -60% 0px`;
const observer = new IntersectionObserver(
(entries) => {
if (isScrollingRef.current) return;
// Pick the first visible section in DOM order
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
const id = visible[0].target.getAttribute('data-section') as Section;
if (id) setActive(id);
}
},
{ rootMargin, threshold: 0 },
);
for (const key of SECTION_ORDER) {
const el = sectionRefs[key].current;
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [sectionRefs, navBarRef]);
const scrollTo = useCallback((section: Section) => {
const el = sectionRefs[section].current;
if (!el) return;
const navBarHeight = navBarRef.current?.offsetHeight ?? 48;
const top = el.getBoundingClientRect().top + window.scrollY - navBarHeight - 8;
isScrollingRef.current = true;
setActive(section);
window.scrollTo({ top, behavior: 'smooth' });
// Release the guard after the smooth scroll finishes
setTimeout(() => { isScrollingRef.current = false; }, 800);
}, [sectionRefs, navBarRef]);
return { active, scrollTo };
}
// ---------------------------------------------------------------------------
// Section pill
// ---------------------------------------------------------------------------
@@ -89,12 +143,19 @@ function SectionPill({ section, active, onClick }: {
active: boolean;
onClick: () => void;
}) {
const meta = section === 'all'
? { label: 'All', icon: <Sparkles className="size-3.5" /> }
: SECTIONS[section];
const meta = SECTIONS[section];
const pillRef = useRef<HTMLButtonElement>(null);
// Auto-scroll the pill into view when it becomes active
useEffect(() => {
if (active && pillRef.current) {
pillRef.current.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}, [active]);
return (
<button
ref={pillRef}
type="button"
onClick={onClick}
className={cn(
@@ -524,7 +585,6 @@ function WikipediaLoadingSkeleton() {
export function WikipediaPage() {
const { config } = useAppContext();
const [activeSection, setActiveSection] = useState<Section>('all');
const { data: feed, isLoading, isError } = useWikipediaFeatured();
useSeoMeta({
@@ -532,6 +592,21 @@ export function WikipediaPage() {
description: 'Explore today\'s featured Wikipedia content \u2014 trending articles, on this day, in the news, and more.',
});
// Section refs for scrollspy
const navBarRef = useRef<HTMLDivElement>(null);
const featuredRef = useRef<HTMLDivElement>(null);
const mostreadRef = useRef<HTMLDivElement>(null);
const newsRef = useRef<HTMLDivElement>(null);
const onthisdayRef = useRef<HTMLDivElement>(null);
const sectionRefs: Record<Section, RefObject<HTMLElement | null>> = {
featured: featuredRef,
mostread: mostreadRef,
news: newsRef,
onthisday: onthisdayRef,
};
const { active, scrollTo } = useScrollspy(sectionRefs, navBarRef);
// Filter most-read to remove "Main Page" and "Special:" pages
const mostReadArticles = useMemo(() => {
if (!feed?.mostread?.articles) return [];
@@ -549,8 +624,6 @@ export function WikipediaPage() {
return feed?.news ?? [];
}, [feed?.news]);
const showSection = (s: Exclude<Section, 'all'>) => activeSection === 'all' || activeSection === s;
return (
<main className="pb-16 sidebar:pb-0">
{/* Header */}
@@ -581,15 +654,18 @@ export function WikipediaPage() {
{/* Search bar */}
<WikipediaSearchBar />
{/* Section filter pills */}
<div className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 border-b border-border">
{/* Scrollspy navigation pills */}
<div
ref={navBarRef}
className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 border-b border-border"
>
<div className="flex gap-2 px-4 py-2.5 overflow-x-auto">
{SECTION_ORDER.map((s) => (
<SectionPill
key={s}
section={s}
active={activeSection === s}
onClick={() => setActiveSection(s)}
active={active === s}
onClick={() => scrollTo(s)}
/>
))}
</div>
@@ -608,19 +684,19 @@ export function WikipediaPage() {
) : (
<div className="px-4 pt-4 pb-4 space-y-6">
{/* Today's Featured Article */}
{showSection('featured') && feed?.tfa && (
<section>
{feed?.tfa && (
<div ref={featuredRef} data-section="featured">
<SectionHeading
icon={<Star className="size-3.5 text-amber-500" />}
title="Today's Featured Article"
/>
<FeaturedArticleCard page={feed.tfa} />
</section>
</div>
)}
{/* Most Read */}
{showSection('mostread') && mostReadArticles.length > 0 && (
<section>
{mostReadArticles.length > 0 && (
<div ref={mostreadRef} data-section="mostread">
<SectionHeading
icon={<TrendingUp className="size-3.5 text-primary" />}
title="Trending"
@@ -636,12 +712,12 @@ export function WikipediaPage() {
/>
))}
</div>
</section>
</div>
)}
{/* In the News */}
{showSection('news') && newsItems.length > 0 && (
<section>
{newsItems.length > 0 && (
<div ref={newsRef} data-section="news">
<SectionHeading
icon={<Newspaper className="size-3.5 text-sky-500" />}
title="In the News"
@@ -651,12 +727,12 @@ export function WikipediaPage() {
<NewsCard key={i} item={item} />
))}
</div>
</section>
</div>
)}
{/* On This Day */}
{showSection('onthisday') && onThisDayEvents.length > 0 && (
<section>
{onThisDayEvents.length > 0 && (
<div ref={onthisdayRef} data-section="onthisday">
<SectionHeading
icon={<Calendar className="size-3.5 text-violet-500" />}
title="On This Day"
@@ -667,25 +743,7 @@ export function WikipediaPage() {
<OnThisDayCard key={i} event={event} />
))}
</div>
</section>
)}
{/* Empty state for filtered view */}
{activeSection !== 'all' && !isLoading && (
(() => {
const isEmpty =
(activeSection === 'featured' && !feed?.tfa) ||
(activeSection === 'mostread' && mostReadArticles.length === 0) ||
(activeSection === 'onthisday' && onThisDayEvents.length === 0) ||
(activeSection === 'news' && newsItems.length === 0);
return isEmpty ? (
<div className="py-16 text-center">
<Sparkles className="size-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">No content available for this section today.</p>
</div>
) : null;
})()
</div>
)}
</div>
)}