refactor: enhance TabBar component with hover effects and improved scrolling behavior

This commit is contained in:
Anish Sarkar 2026-05-13 17:55:33 +05:30
parent 0fecff45b9
commit 357714beda

View file

@ -2,7 +2,7 @@
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Plus, X } from "lucide-react"; import { Plus, X } from "lucide-react";
import { useCallback, useEffect, useRef } from "react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { import {
activeTabIdAtom, activeTabIdAtom,
closeTabAtom, closeTabAtom,
@ -45,6 +45,21 @@ export function TabBar({
const switchTab = useSetAtom(switchTabAtom); const switchTab = useSetAtom(switchTabAtom);
const closeTab = useSetAtom(closeTabAtom); const closeTab = useSetAtom(closeTabAtom);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [hoveredTabIndex, setHoveredTabIndex] = useState<number | null>(null);
const activeTabIndex = tabs.findIndex((tab) => tab.id === activeTabId);
const shouldHideSeparator = useCallback(
(separatorIndex: number) => {
// separatorIndex sits between tabs[separatorIndex - 1] and tabs[separatorIndex].
return (
hoveredTabIndex === separatorIndex - 1 ||
hoveredTabIndex === separatorIndex ||
activeTabIndex === separatorIndex - 1 ||
activeTabIndex === separatorIndex
);
},
[hoveredTabIndex, activeTabIndex]
);
const handleTabClick = useCallback( const handleTabClick = useCallback(
(tab: Tab) => { (tab: Tab) => {
@ -98,7 +113,19 @@ export function TabBar({
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
el.scrollLeft += e.deltaY > 0 ? 50 : -50;
const delta =
e.deltaMode === WheelEvent.DOM_DELTA_LINE
? e.deltaY * 16
: e.deltaMode === WheelEvent.DOM_DELTA_PAGE
? e.deltaY * el.clientWidth
: e.deltaY;
const maxScrollLeft = el.scrollWidth - el.clientWidth;
const nextScrollLeft = Math.min(maxScrollLeft, Math.max(0, el.scrollLeft + delta));
if (nextScrollLeft === el.scrollLeft) return;
el.scrollLeft = nextScrollLeft;
e.preventDefault(); e.preventDefault();
}; };
@ -146,54 +173,66 @@ export function TabBar({
{leftActions ? <div className="flex items-center gap-0.5 shrink-0">{leftActions}</div> : null} {leftActions ? <div className="flex items-center gap-0.5 shrink-0">{leftActions}</div> : null}
<div <div
ref={scrollRef} ref={scrollRef}
className="flex h-8 items-center flex-1 gap-3 pl-2 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-0" className="flex h-8 items-center flex-1 gap-0 pl-2 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-0"
> >
{tabs.map((tab) => { {tabs.map((tab, index) => {
const isActive = tab.id === activeTabId; const isActive = tab.id === activeTabId;
return ( return (
<button <Fragment key={tab.id}>
key={tab.id} {index > 0 ? (
type="button" <div
data-tab-id={tab.id} aria-hidden="true"
onClick={() => handleTabClick(tab)} className={cn(
className={cn( "mx-1.5 h-4 w-px shrink-0 bg-muted-foreground/20 transition-opacity duration-150 dark:bg-muted-foreground/25",
"group relative flex h-full items-center px-3 w-[180px] min-h-0 overflow-hidden text-[13px] font-medium rounded-md transition-colors duration-150 shrink-0", shouldHideSeparator(index) && "opacity-0"
isActive )}
? "bg-muted text-foreground" />
: "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground" ) : null}
)} <button
> type="button"
<span className="block min-w-0 flex-1 whitespace-nowrap overflow-hidden text-left"> data-tab-id={tab.id}
{tab.title} onClick={() => handleTabClick(tab)}
</span> onMouseEnter={() => setHoveredTabIndex(index)}
{/* Hover-only gradient + close overlay (sidebar pattern) — keeps pill width fixed and avoids ellipsis shift. */} onMouseLeave={() => setHoveredTabIndex(null)}
<div
className={cn( className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center rounded-r-md pl-8 pr-2 opacity-0 transition-opacity duration-150", "group relative flex h-full items-center px-3 w-[180px] min-h-0 overflow-hidden text-[13px] font-medium rounded-md transition-colors duration-150 shrink-0",
"group-hover:opacity-100 group-focus-within:opacity-100",
isActive isActive
? "bg-gradient-to-l from-muted from-60% to-transparent" ? "bg-muted text-foreground"
: "bg-gradient-to-l from-muted from-60% to-transparent" : "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)} )}
> >
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */} <span className="block min-w-0 flex-1 whitespace-nowrap overflow-hidden text-left">
<span {tab.title}
role="button"
tabIndex={0}
onClick={(e) => handleTabClose(e, tab.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTabClose(e as unknown as React.MouseEvent, tab.id);
}
}}
className="pointer-events-auto rounded-full p-0.5 transition-colors hover:bg-muted-foreground/15"
>
<X className="size-3" />
</span> </span>
</div> {/* Hover-only gradient + close overlay (sidebar pattern) — keeps pill width fixed and avoids ellipsis shift. */}
</button> <div
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center rounded-r-md pl-8 pr-2 opacity-0 transition-opacity duration-150",
"group-hover:opacity-100 group-focus-within:opacity-100",
isActive
? "bg-gradient-to-l from-muted from-60% to-transparent"
: "bg-gradient-to-l from-accent from-60% to-transparent"
)}
>
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
<span
role="button"
tabIndex={0}
onClick={(e) => handleTabClose(e, tab.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTabClose(e as unknown as React.MouseEvent, tab.id);
}
}}
className="pointer-events-auto rounded-full p-0.5 transition-colors hover:bg-accent hover:text-accent-foreground"
>
<X className="size-3" />
</span>
</div>
</button>
</Fragment>
); );
})} })}
{onNewChat && ( {onNewChat && (
@ -201,7 +240,7 @@ export function TabBar({
className={cn( className={cn(
// Solid bg + soft left-fade so tabs scrolling underneath the // Solid bg + soft left-fade so tabs scrolling underneath the
// + button get visually masked into the bar's background. // + button get visually masked into the bar's background.
"sticky right-0 z-10 flex h-full shrink-0 items-center bg-panel pl-3 pr-1", "sticky right-0 z-10 ml-3 flex h-full shrink-0 items-center bg-panel pl-3 pr-1",
"before:content-[''] before:absolute before:inset-y-0 before:-left-4 before:w-4 before:pointer-events-none", "before:content-[''] before:absolute before:inset-y-0 before:-left-4 before:w-4 before:pointer-events-none",
"before:bg-gradient-to-r before:from-transparent before:to-panel" "before:bg-gradient-to-r before:from-transparent before:to-panel"
)} )}
@ -209,7 +248,7 @@ export function TabBar({
<button <button
type="button" type="button"
onClick={onNewChat} onClick={onNewChat}
className="flex h-8 w-8 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:text-muted-foreground hover:bg-muted/40" className="flex h-8 w-8 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:bg-accent hover:text-accent-foreground"
title="New Chat" title="New Chat"
> >
<Plus className="size-4" /> <Plus className="size-4" />