/** * Renders the row of tabs of the index, profile and search pages. * * @module */ import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; /** A tab. */ export interface Tab { /** What {@link TabBar} reports when it is chosen. */ key: T; /** What it shows. */ label: ReactNode; } /** The props of {@link TabBar}. */ export interface TabBarProps { /** The tabs, in order. */ tabs: Tab[]; /** The active tab's key. */ activeTab: T; /** Called with the key of the tab chosen. */ onChange: (key: T) => void; /** An extra class for the scroller. */ className?: string; /** An extra class for the row. */ innerClassName?: string; /** * Optional content rendered before the tabs (e.g. a scope selector). Sits * in the same line, aligned with them, and stays where it is while they * scroll: it is outside the row that scrolls, which clips them at its edge. */ leading?: ReactNode; } /** * Renders a row of tabs that scrolls when they don't all fit, keeping the * active one in view. */ export function TabBar( { tabs, activeTab, onChange, className, innerClassName, leading }: TabBarProps, ): React.JSX.Element { const rowRef = useRef(null); const [overflowing, setOverflowing] = useState(false); // Tabs hidden past either edge, which the stylesheet fades only then, so a // fade never dims a tab that is fully in view. const [moreLeft, setMoreLeft] = useState(false); const [moreRight, setMoreRight] = useState(false); useLayoutEffect(() => { const row = rowRef.current; if (!row) return; const measure = () => { setOverflowing(row.scrollWidth > row.clientWidth); setMoreLeft(row.scrollLeft > 1); setMoreRight(row.scrollLeft + row.clientWidth < row.scrollWidth - 1); }; const resize = new ResizeObserver(measure); resize.observe(row); for (const child of row.children) resize.observe(child); row.addEventListener("scroll", measure, { passive: true }); measure(); return () => { resize.disconnect(); row.removeEventListener("scroll", measure); }; }, [tabs.length]); // When they don't all fit, keep the active tab in view, clear of the fade // over the row's right edge, scrolling the row alone, never the page. useLayoutEffect(() => { const row = rowRef.current; const active = row?.querySelector(".feed-sort-btn.active"); if (!row || !active || !overflowing) return; // The fade is 2rem wide (--tab-fade-end on .feed-sort). const fade = parseFloat(getComputedStyle(document.documentElement).fontSize) * 2; const r = row.getBoundingClientRect(), b = active.getBoundingClientRect(); if (b.left < r.left) row.scrollLeft -= r.left - b.left; else if (b.right > r.right - fade) { row.scrollLeft += b.right - r.right + fade; } }, [activeTab, overflowing]); return (
{leading &&
{leading}
}
{tabs.map(({ key, label }) => ( ))}
); }