/** * @file ConfigTabBar.tsx * @description The tab strip both config explorers wear: one horizontally * scrollable row with fade edges and chevrons that appear only when there is * something to scroll to. * * Shared rather than copied because the two pages must look the same. Codex * originally wrapped its tabs onto a second row — fine at eight tabs, but it * read as a different component sitting next to Claude Code's twelve-tab * scroller. One row that scrolls is the same shape at any tab count. * * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState, type ComponentType } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; export interface ConfigTab { key: K; icon: ComponentType<{ className?: string }>; label: string; /** Badge number, or null for tabs that count nothing (e.g. Overview). */ count: number | null; } interface ConfigTabBarProps { tabs: ConfigTab[]; current: K; onSelect: (key: K) => void; /** `data-testid` becomes `-`, e.g. "cc-tab-plugins". */ testIdPrefix: string; ariaLabel: string; } export function ConfigTabBar({ tabs, current, onSelect, testIdPrefix, ariaLabel, }: ConfigTabBarProps) { const scrollRef = useRef(null); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); // Update scroll affordances when content size or scroll position changes. const updateAffordances = useCallback(() => { const el = scrollRef.current; if (!el) return; setCanScrollLeft(el.scrollLeft > 1); setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); }, []); useEffect(() => { updateAffordances(); const el = scrollRef.current; if (!el) return; el.addEventListener("scroll", updateAffordances, { passive: true }); const ro = new ResizeObserver(updateAffordances); ro.observe(el); return () => { el.removeEventListener("scroll", updateAffordances); ro.disconnect(); }; }, [updateAffordances]); // Scroll the active tab into view when it changes (e.g. user picks a tab // that's offscreen, or window resize hides the active one). useEffect(() => { const el = scrollRef.current; if (!el) return; const active = el.querySelector('[data-tab-active="true"]'); if (!active) return; const elRect = el.getBoundingClientRect(); const activeRect = active.getBoundingClientRect(); if (activeRect.left < elRect.left + 8) { el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" }); } else if (activeRect.right > elRect.right - 8) { el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" }); } }, [current]); const scrollByButton = (dir: 1 | -1) => { const el = scrollRef.current; if (!el) return; el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" }); }; return (
{/* Left edge gradient + chevron */}
{canScrollLeft && ( )}
{tabs.map(({ key, icon: Icon, label, count }) => { const active = current === key; return ( ); })}
{/* Right edge gradient + chevron */}
{canScrollRight && ( )}
); }