'use client' import React, { useState, useEffect, useLayoutEffect, useDeferredValue, useMemo, useRef, useCallback, memo, startTransition } from 'react' import { useSearchParams, useRouter, usePathname } from '../../embed-shims/next-navigation' import { cn } from '../../utils/cn' export interface TabItem { id: string label: string /** Optional — text-only tabs (e.g. the homepage content-strip switcher) omit it. */ icon?: React.ComponentType> component?: React.ComponentType indicator?: 'success' | 'warning' | 'error' } export interface TabNavigationUrlSyncOptions { paramName?: string // Default: 'tab' replaceState?: boolean // Default: true (use replace instead of push) } interface TabNavigationProps { // Legacy controlled mode (when urlSync is disabled) activeTab?: string onTabChange?: (tabId: string) => void tabs: TabItem[] className?: string shadowClassName?: string // Tailwind class for shadow gradient color, e.g. "from-black" or "from-red-500" /** Force the right-edge gradient to always render, independent of scroll state. */ showRightGradient?: boolean /** Force the left-edge gradient to always render, independent of scroll state. */ showLeftGradient?: boolean /** Tabs grow to share the bar's full width equally (Figma segmented-underline * look, e.g. the 480px homepage strip switcher). Default: natural width. */ stretchTabs?: boolean // URL sync mode urlSync?: boolean | TabNavigationUrlSyncOptions defaultTab?: string // Fallback when no valid tab in URL or initial value /** * Render prop for the tab BODY. Receives the tab to render and, additively, * a `isStale` flag — true while the bar has already moved to a newly clicked * tab but the body is still showing the PREVIOUS one (see `deferredActiveTab` * below). Use it to dim the body or show a pending hint; without it a slow * tab looks like a tab that simply did nothing. */ children?: (activeTab: string, state: { isStale: boolean }) => React.ReactNode } /** Where the underline sits, in the strip's own coordinates. */ interface IndicatorRect { left: number width: number } const HIDDEN_INDICATOR: IndicatorRect = { left: 0, width: 0 } const StatusDot = ({ indicator, className }: { indicator: NonNullable; className?: string }) => (
) interface TabBarProps { tabs: TabItem[] activeTab: string onTabChange: (tabId: string) => void className?: string shadowClassName?: string showLeftGradient: boolean showRightGradient: boolean stretchTabs: boolean } /** * The strip itself — buttons, overflow scrolling, edge fades, underline. * * Split out of `TabNavigation` for one reason, and it is a performance one: the * strip keeps state that changes for reasons having nothing to do with which tab * is open (scroll position → edge fades, ResizeObserver → re-measure). While * that state lived on `TabNavigation`, every one of those updates re-rendered * the component that CALLS the body render prop, so merely scrolling the tab * strip on a narrow screen re-rendered whatever heavy table was open underneath. * Down here it re-renders only the bar; `TabNavigation` — and the body — never * hear about it. */ const TabBar = memo(function TabBar({ tabs, activeTab, onTabChange, className, shadowClassName, showLeftGradient, showRightGradient, stretchTabs, }: TabBarProps) { const scrollRef = useRef(null) const activeTabRef = useRef(null) const isFirstActiveScrollRef = useRef(true) const [canScrollLeft, setCanScrollLeft] = useState(false) const [canScrollRight, setCanScrollRight] = useState(false) // The underline is ONE element that slides, not a div mounted under whichever // tab is active — that version could only ever pop from tab to tab. Position // is measured rather than derived from CSS because the tabs are natural-width // (label-dependent) and live in a scroll container, so nothing but layout // knows where the active one actually is. const [indicator, setIndicator] = useState(HIDDEN_INDICATOR) // Suppresses the slide for the FIRST placement: a deep link landing on the // third tab should not open with the underline gliding in from the left edge. const hasPlacedIndicatorRef = useRef(false) const measureIndicator = useCallback(() => { const active = activeTabRef.current // No active tab (unknown id, or an empty tabs array) → collapse it away // rather than leaving it under whichever tab it last sat on. const next = active ? { left: active.offsetLeft, width: active.offsetWidth } : HIDDEN_INDICATOR setIndicator(prev => (prev.left === next.left && prev.width === next.width ? prev : next)) }, []) const updateScrollShadows = useCallback(() => { const el = scrollRef.current if (!el) return setCanScrollLeft(el.scrollLeft > 0) setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) }, []) // Layout effect: the underline's first frame must already be under the right // tab. In a passive effect it would paint at the previous position first. useLayoutEffect(() => { measureIndicator() }, [measureIndicator, activeTab, tabs]) // Flipped only AFTER a real position has been committed, so the transition // class is absent for that first paint and present from the next change on. useLayoutEffect(() => { if (indicator.width > 0) hasPlacedIndicatorRef.current = true }, [indicator]) useEffect(() => { const el = scrollRef.current if (!el) return updateScrollShadows() // Translate a vertical mouse wheel into horizontal scroll. macOS mice emit // only deltaY, which the browser won't apply to a horizontal-only overflow // container, so the strip is otherwise un-scrollable with a plain mouse. // Non-passive so preventDefault() can suppress the page from scrolling. const onWheel = (e: WheelEvent) => { // A trackpad's horizontal gesture (deltaX) already scrolls natively; a // pinch-zoom arrives as wheel+ctrlKey — leave both untouched. if (e.deltaX !== 0 || e.ctrlKey) return if (el.scrollWidth <= el.clientWidth) return // deltaY isn't always pixels: Firefox mice report lines (deltaMode 1), // some devices pages (mode 2). Normalize so the scroll isn't a no-op. const delta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaMode === 2 ? e.deltaY * el.clientWidth : e.deltaY // Only consume the event while there's room to scroll in that direction; // at either edge, let it bubble so the page scrolls (native chaining). const canGoRight = el.scrollLeft + el.clientWidth < el.scrollWidth - 1 const canGoLeft = el.scrollLeft > 0 if ((delta > 0 && !canGoRight) || (delta < 0 && !canGoLeft)) return el.scrollLeft += delta e.preventDefault() } // Scrolling moves the viewport, not the content — `offsetLeft` is unchanged, // so only the fades need recomputing. A resize moves both. el.addEventListener('scroll', updateScrollShadows, { passive: true }) el.addEventListener('wheel', onWheel, { passive: false }) const ro = new ResizeObserver(() => { updateScrollShadows() measureIndicator() }) ro.observe(el) // Web fonts land after first paint and change every label's width, which // moves every tab but fires no resize on the strip itself. let cancelled = false document.fonts?.ready.then(() => { if (!cancelled) measureIndicator() }).catch(() => {}) return () => { cancelled = true el.removeEventListener('scroll', updateScrollShadows) el.removeEventListener('wheel', onWheel) ro.disconnect() } }, [updateScrollShadows, measureIndicator]) // Bring the active tab into view when it changes (e.g. clicking a partly // off-screen tab, or a URL-driven change). Uses the browser's own smooth // scroll and moves ONLY the strip — never the page — by nudging just enough // to reveal the tab on whichever edge it's clipped. Complements the native // trackpad / Shift+wheel / mouse-wheel scrolling rather than fighting it. useEffect(() => { const el = scrollRef.current const active = activeTabRef.current if (!el || !active) return // On mount (e.g. a deep link landing on an off-screen tab, such as // `?tab=software` as the last of many), snap instantly instead of visibly // sliding right after first paint — smooth is reserved for later tab changes. const behavior = isFirstActiveScrollRef.current ? 'auto' : 'smooth' isFirstActiveScrollRef.current = false const elRect = el.getBoundingClientRect() const aRect = active.getBoundingClientRect() if (aRect.left < elRect.left) { el.scrollBy({ left: aRect.left - elRect.left, behavior }) } else if (aRect.right > elRect.right) { el.scrollBy({ left: aRect.right - elRect.right, behavior }) } }, [activeTab]) const leftFade = canScrollLeft || showLeftGradient const rightFade = canScrollRight || showRightGradient const borderStyle = useMemo(() => { const c = 'var(--color-border-default)' if (leftFade && rightFade) { return { background: `linear-gradient(to right, transparent 0, ${c} 40px, ${c} calc(100% - 40px), transparent 100%)` } } if (leftFade) { return { background: `linear-gradient(to right, transparent 0, ${c} 40px, ${c} 100%)` } } if (rightFade) { return { background: `linear-gradient(to right, ${c} 0, ${c} calc(100% - 40px), transparent 100%)` } } return { background: c } }, [leftFade, rightFade]) // Real width, moved by `translateX`. This used to be a 1px bar stretched with // `scaleX(width)` — transform and opacity being the only properties the // compositor can animate without laying out again — but scaling from a ONE // PIXEL base turns that pixel into an error multiplier. // // A CSS pixel only lands on a whole device pixel at integer scale factors. At // 125% / 150% OS scaling, a scaled Mac resolution, or any browser zoom, the // base is rasterized at the nearest whole device pixel — call it 0.8 or 1.33 // CSS px — and `scaleX(150)` multiplies that rounding by 150. The underline // then renders a fifth short or a third long, per tab, with `measureIndicator` // having been perfectly correct all along. It shows up from the SECOND tab // change on, because that is when the transition promotes the bar into its own // composited layer, where it is rasterized in its own 1×4 coordinate space and // only then stretched. // // Animating `width` costs a layout per frame for this one element — it is // `absolute`, so nothing else in the strip is laid out with it, and 200ms of // that for a 4px-tall bar is not measurable. Correctness at every zoom level // is worth more than keeping one property on the compositor. const isPlaced = indicator.width > 0 const indicatorStyle: React.CSSProperties = { width: `${indicator.width}px`, transform: `translateX(${indicator.left}px)`, opacity: isPlaced ? 1 : 0, } const shouldAnimateIndicator = hasPlacedIndicatorRef.current return (
{/* scrollbar-hide: tabs stay swipe/wheel-scrollable, bar never shows. `relative` makes this the offsetParent the underline is measured against. */}
{tabs.map((tab) => { const isActive = activeTab === tab.id return ( ) })} {/* One underline for the whole strip, sliding between tabs. Inside the scroll container so it travels with the content. Width comes from the style below in real pixels — see why it is not a scaled 1px bar. */}
{/* Fade shadows — visible when content overflows or when forced via props */} {leftFade && (
)} {rightFade && (
)} {/* Bottom border — a gradient-capable 1px line that fades to transparent on any edge that has an active fade shadow. */}
) }) export function TabNavigation({ activeTab: controlledActiveTab, onTabChange: controlledOnTabChange, tabs, className, shadowClassName, showRightGradient = false, showLeftGradient = false, stretchTabs = false, urlSync = false, defaultTab, children }: TabNavigationProps) { const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() // Determine URL sync settings const isUrlSyncEnabled = !!urlSync const paramName = typeof urlSync === 'object' ? (urlSync.paramName || 'tab') : 'tab' const replaceState = typeof urlSync === 'object' ? (urlSync.replaceState !== false) : true // Valid tab IDs set const validTabIds = useMemo(() => new Set(tabs.map(t => t.id)), [tabs]) // A string, so the sync effect below depends on a VALUE rather than on the // `tabs` array identity — a consumer passing an inline array literal would // otherwise re-run that effect on every one of its renders. const fallbackTab = defaultTab || tabs[0]?.id || '' // What the URL currently says. Read during render (not in the effect) so the // effect can compare it against the last value it ACTED on. const urlTab = isUrlSyncEnabled ? (searchParams?.get(paramName) || '') : '' // Get initial tab value const getInitialTab = () => { if (isUrlSyncEnabled && validTabIds.has(urlTab)) return urlTab return fallbackTab } // Internal state for URL sync mode const [internalActiveTab, setInternalActiveTab] = useState(getInitialTab) // Use internal state if URL sync is enabled, otherwise use controlled prop const activeTab = isUrlSyncEnabled ? internalActiveTab : (controlledActiveTab || '') // The last URL value this component reconciled against. const lastSyncedUrlTabRef = useRef(urlTab) // Tabs written to the URL whose navigation has not landed yet, oldest first. // Until a write lands, `searchParams` still reports the PREVIOUS tab — and // reading that as somebody else editing the URL is exactly what used to push // the tab back to where the click started. const inFlightUrlTabsRef = useRef([]) // Sync with URL changes (back/forward navigation, a link into a tab). // // Not guarded on `nextTab !== internalActiveTab`, which is what this used to // compare. That re-ran whenever our own click moved the state, and at that // moment `router.replace` has not landed — so it read the tab we just left, // decided the state was wrong, and pushed the tab BACK, only for the arriving // URL to move it forward again. Whether the flicker was visible came down to // whether the navigation beat the passive-effect flush. // // The three cases below are the three things that can actually have happened. useEffect(() => { if (!isUrlSyncEnabled) return // 1. One of our own writes arriving. The state moved on the click, so there // is nothing to set — just drop it, along with anything written before it // that a faster second click has already superseded. const landed = inFlightUrlTabsRef.current.indexOf(urlTab) if (landed !== -1) { inFlightUrlTabsRef.current.splice(0, landed + 1) lastSyncedUrlTabRef.current = urlTab return } // 2. The URL has not moved. Either a write is still in flight and this run // is an unrelated re-render landing in the gap (a `tabs` array with a // fresh identity, say — which a guard on the URL alone would misread as // an external change and use to undo the click), or the TAB LIST itself // changed. The latter is why a URL guard is not enough on its own: a // consumer whose tabs load asynchronously mounts with an empty list, // resolves to no tab, and would then wait for a URL change that never // comes — deep link dropped, nothing ever active. So re-resolve, but only // when what we are showing is no longer in the list. if (urlTab === lastSyncedUrlTabRef.current) { setInternalActiveTab(prev => validTabIds.has(prev) ? prev : validTabIds.has(urlTab) ? urlTab : fallbackTab, ) return } // 3. The URL moved somewhere we did not write it: back/forward, a redirect, // a link elsewhere on the page. That wins, and anything still in flight // is stale by definition. inFlightUrlTabsRef.current.length = 0 lastSyncedUrlTabRef.current = urlTab setInternalActiveTab(validTabIds.has(urlTab) ? urlTab : fallbackTab) }, [isUrlSyncEnabled, urlTab, validTabIds, fallbackTab]) // Everything the click handler reads but must not be re-created for. Held in // a ref so `handleTabChange` is reference-stable: it is the one prop that // would otherwise break `TabBar`'s memo on every navigation, since // `searchParams` gets a new identity each time ANY query param moves. const navRef = useRef({ isUrlSyncEnabled, controlledOnTabChange, searchParams, pathname, paramName, replaceState }) // Filled in an effect rather than in the render body. With `useDeferredValue` // this component renders more than once per commit, and React is free to // discard a render outright — a ref written during one of those would hand the // click handler a `pathname`/`searchParams` pair that never committed, and the // click would write its query string against the wrong URL. A click can only // arrive after a commit, so the handler still reads current values. useEffect(() => { navRef.current = { isUrlSyncEnabled, controlledOnTabChange, searchParams, pathname, paramName, replaceState } }) const handleTabChange = useCallback((tabId: string) => { const nav = navRef.current if (!nav.isUrlSyncEnabled) { // Legacy controlled mode nav.controlledOnTabChange?.(tabId) return } // The bar follows the click immediately — this update stays urgent. setInternalActiveTab(tabId) // ...and is what the URL is about to say. Recorded so the sync effect knows // the param arriving later is ours, and knows that until it does arrive the // URL is merely stale rather than disagreeing with us. Skipped when it is // already the newest thing in flight, so a double-click on one tab does not // queue the same id twice. const inFlight = inFlightUrlTabsRef.current if (inFlight[inFlight.length - 1] !== tabId) inFlight.push(tabId) const params = new URLSearchParams(nav.searchParams?.toString()) params.set(nav.paramName, tabId) const method = nav.replaceState ? 'replace' : 'push' // Non-urgent: the navigation re-renders every `useSearchParams()` subscriber // on the page, which is a lot of work for a query param nobody is waiting // on. In a transition it yields to the click feedback instead of competing // with it. startTransition(() => { // `scroll: false` because only a query param moves here. The App Router // otherwise scrolls to the top on every navigation, which throws away the // reading position of anyone using a tab strip partway down a page. router[method](`${nav.pathname}?${params.toString()}`, { scroll: false }) }) nav.controlledOnTabChange?.(tabId) }, [router]) // The tab BAR follows the click immediately; the tab BODY is deferred, which // makes React treat swapping it as a transition. That is the whole fix for the // flash: a tab whose data is not in the client cache yet SUSPENDS the moment it // mounts, and outside a transition React has to show that Suspense fallback — // so the skeleton appears for a frame before the real content lands. Inside a // transition React keeps the previous tab on screen and swaps only once the new // one is ready. Deferring the body rather than the whole state keeps the // underline responsive, so the click still feels instant. // // Two things this asks of the consumer, both worth knowing: // - The render prop runs on BOTH passes (once with the old id, once with the // new). Put anything expensive behind `memo` so the first pass is free. // - A SLOW tab leaves the previous tab's content under the new tab's // underline with nothing saying so — worse than a skeleton, because stale // data reads as fresh data. `isStale` is handed to the render prop so the // body can mark itself while it waits. const deferredActiveTab = useDeferredValue(activeTab) const isStale = activeTab !== deferredActiveTab return ( <> {/* Render children with active tab if provided */} {children && children(deferredActiveTab, { isStale })} ) } // Utility function to get tab by id export const getTabById = (tabs: TabItem[], tabId: string): TabItem | undefined => tabs.find(tab => tab.id === tabId) // Utility function to get tab component export const getTabComponent = (tabs: TabItem[], tabId: string): React.ComponentType | null => { const tab = getTabById(tabs, tabId) return tab?.component || null }