2942ced3ec
The Codex tab strip wrapped onto a second row while Claude Code's stayed one scrollable row, so two panels that are meant to be twins read as different components. Both now render the same ConfigTabBar — the scroller lifted out of CcConfig, with the fade edges and chevrons that only appear when there is somewhere to scroll. The deferral note in CodexConfig that predicted this is gone with it. The bar also carries role="tablist"/"tab" and aria-selected now: Codex had them, Claude Code did not, and one of the two had to win. Cursor's placeholder tab goes away with the machinery that served only it — the optional Panel, the "soon" badge, the coming-soon card, and their four i18n keys. Element.scrollBy and friends move to test-setup: jsdom has none of them, and the tab bar now calls them from both pages.
164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
export interface ConfigTab<K extends string> {
|
|
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<K extends string> {
|
|
tabs: ConfigTab<K>[];
|
|
current: K;
|
|
onSelect: (key: K) => void;
|
|
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
|
|
testIdPrefix: string;
|
|
ariaLabel: string;
|
|
}
|
|
|
|
export function ConfigTabBar<K extends string>({
|
|
tabs,
|
|
current,
|
|
onSelect,
|
|
testIdPrefix,
|
|
ariaLabel,
|
|
}: ConfigTabBarProps<K>) {
|
|
const scrollRef = useRef<HTMLDivElement | null>(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<HTMLElement>('[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 (
|
|
<div className="relative rounded-xl border border-border bg-surface-1">
|
|
{/* Left edge gradient + chevron */}
|
|
<div
|
|
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
|
|
canScrollLeft ? "opacity-100" : "opacity-0"
|
|
}`}
|
|
/>
|
|
{canScrollLeft && (
|
|
<button
|
|
onClick={() => scrollByButton(-1)}
|
|
aria-label="scroll tabs left"
|
|
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
role="tablist"
|
|
aria-label={ariaLabel}
|
|
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
|
|
style={{ scrollbarWidth: "none" }}
|
|
>
|
|
{tabs.map(({ key, icon: Icon, label, count }) => {
|
|
const active = current === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
data-testid={`${testIdPrefix}-${key}`}
|
|
data-tab-active={active ? "true" : undefined}
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => onSelect(key)}
|
|
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
|
|
active
|
|
? "bg-accent/15 text-accent border border-accent/30"
|
|
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
<span>{label}</span>
|
|
{count !== null && (
|
|
<span
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
|
|
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
|
|
}`}
|
|
>
|
|
{count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Right edge gradient + chevron */}
|
|
<div
|
|
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
|
|
canScrollRight ? "opacity-100" : "opacity-0"
|
|
}`}
|
|
/>
|
|
{canScrollRight && (
|
|
<button
|
|
onClick={() => scrollByButton(1)}
|
|
aria-label="scroll tabs right"
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|