feat(tabbar): mouse-wheel horizontal scroll on hover, scrollbar hidden (#19)

Hovering the tab strip and spinning the wheel now moves the strip

horizontally — same UX as native browser tab bars. The scrollbar itself

stays hidden so the strip looks like clean app chrome.

Three coordinated changes:

1. The scroll region's overflow flips from overflow-x-hidden to

   overflow-x-auto so native scroll mechanics kick in. The visible

   scrollbar is suppressed via the existing project pattern of

   Tailwind arbitrary-value utilities — [scrollbar-width:none]

   covers Firefox and modern Chromium, [&::-webkit-scrollbar]:hidden

   covers older WebKit and the embedded Electron renderer.

2. New non-passive 'wheel' listener on the scroll region: when the

   cursor is over it AND the wheel input is deltaY-dominated (i.e.

   a plain vertical mouse wheel, no trackpad), translate to

   horizontal scrollLeft and preventDefault so the page below the

   tab bar doesn't ALSO scroll. Trackpad horizontal swipe input

   (deltaX-dominated) passes through untouched so its native

   momentum / direction feel survives.

3. Pre-existing test 'keeps the overflow button flush against window

   controls on Windows' selected the scroll region by class name

   '.overflow-x-hidden' — bumped to '.overflow-x-auto' to match the

   new geometry.

Tested:

- bun run test src/components/layout/TabBar.test.tsx — 27/27 (24

  existing + 3 new):

  * 'exposes the scroll region with overflow-x-auto and a hidden

     scrollbar' — pins the CSS contract

  * 'translates a vertical wheel into a horizontal scroll on the

     tab strip' — fires wheel{deltaY:120}, asserts scrollLeft=120

  * 'passes horizontal wheel input through untouched (trackpad

     sideways swipe)' — fires wheel{deltaX:80,deltaY:10}, asserts

     scrollLeft unchanged

- bun run lint (desktop tsc --noEmit) — clean

Confidence: high. Scope-risk: narrow — single component, two-line CSS

swap + an isolated effect, no API or store changes.

Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
小橙子 2026-06-11 23:02:33 +08:00 committed by GitHub
parent 6b5482e3b7
commit d2830f91de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 141 additions and 2 deletions

View File

@ -212,6 +212,108 @@ describe('TabBar', () => {
})
})
it('exposes the scroll region with overflow-x-auto and a hidden scrollbar', async () => {
// Pins the CSS contract introduced when we enabled wheel scroll
// on the tab strip: the strip MUST scroll natively
// (overflow-x-auto) so vertical wheel input translated to
// scrollLeft actually moves it, but the visible scrollbar MUST
// stay hidden so the tab strip looks like a clean app chrome
// surface (mirrors the look of native browser tab bars).
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({
tabs: [{ sessionId: 'tab-1', title: 'A', type: 'session', status: 'idle' }],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
const region = screen.getByTestId('tab-bar-scroll-region')
expect(region).toHaveClass('overflow-x-auto')
expect(region.className).toContain('[scrollbar-width:none]')
expect(region.className).toContain('[&::-webkit-scrollbar]:hidden')
})
it('translates a vertical wheel into a horizontal scroll on the tab strip', async () => {
// The desktop wheel-scroll affordance: hover the tab strip,
// spin the mouse wheel, the strip moves horizontally. JSDOM's
// scrollLeft writes are persistent on the element, so we can
// assert directly on it.
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'A', type: 'session', status: 'idle' },
{ sessionId: 'tab-2', title: 'B', type: 'session', status: 'idle' },
],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
const region = screen.getByTestId('tab-bar-scroll-region')
// Force the region into an "overflowing" geometry — JSDOM
// doesn't lay things out, so scrollWidth defaults to 0 and the
// handler short-circuits without our help.
Object.defineProperty(region, 'scrollWidth', { configurable: true, value: 800 })
Object.defineProperty(region, 'clientWidth', { configurable: true, value: 200 })
region.scrollLeft = 0
fireEvent.wheel(region, { deltaY: 120, deltaX: 0 })
expect(region.scrollLeft).toBe(120)
})
it('passes horizontal wheel input through untouched (trackpad sideways swipe)', async () => {
// Trackpad two-finger horizontal swipe already produces a
// deltaX-dominated wheel event the browser scrolls natively.
// We must NOT also mutate scrollLeft, or the strip jumps by 2x.
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'A', type: 'session', status: 'idle' },
{ sessionId: 'tab-2', title: 'B', type: 'session', status: 'idle' },
],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
const region = screen.getByTestId('tab-bar-scroll-region')
Object.defineProperty(region, 'scrollWidth', { configurable: true, value: 800 })
Object.defineProperty(region, 'clientWidth', { configurable: true, value: 200 })
region.scrollLeft = 50
fireEvent.wheel(region, { deltaY: 10, deltaX: 80 })
// We did NOT add deltaY's 10 to scrollLeft — the deltaX-
// dominated event was a pass-through.
expect(region.scrollLeft).toBe(50)
})
it('keeps the overflow button flush against window controls on Windows', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
@ -235,7 +337,7 @@ describe('TabBar', () => {
render(<TabBar />)
})
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-auto')
expect(scrollRegion).toBeInTheDocument()
Object.defineProperty(scrollRegion!, 'clientWidth', {

View File

@ -155,6 +155,43 @@ export function TabBar() {
el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' })
}
/**
* Translate vertical mouse-wheel input into horizontal tab-bar scroll
* so users on a plain mouse (no trackpad) can wheel-scroll the tab
* strip just by hovering it. Trackpad users get this for free
* `deltaX` from a two-finger sideways swipe already scrolls the
* container natively, and we deliberately do NOT touch deltaX-
* dominated wheel events so that input keeps its native feel and
* momentum.
*
* Why preventDefault: when the cursor is over the tab bar AND we
* successfully consumed the wheel into horizontal scroll, the user
* doesn't want the page below the tab bar to also scroll vertically
* (jumping back to the same scroll experience as native browser
* tab strips). preventDefault here is safe because we attach the
* handler via a non-passive native listener (React's onWheel maps
* to addEventListener with passive:true on some platforms; using
* the imperative form below avoids that pitfall).
*/
useEffect(() => {
const el = scrollRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
// Trackpad horizontal swipe already scrolls horizontally —
// pass through so momentum / direction feel native.
if (Math.abs(e.deltaX) >= Math.abs(e.deltaY)) return
// Nothing to scroll → bubble normally so the page can scroll.
if (el.scrollWidth <= el.clientWidth) return
// Map vertical wheel into horizontal scroll.
// Use deltaY directly; it's already a "lines or pixels"
// delta the browser tuned for one notch of wheel travel.
el.scrollLeft += e.deltaY
e.preventDefault()
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const closeTabWithCleanup = useCallback((tab: Tab) => {
if (isSessionTab(tab)) {
useWorkspacePanelStore.getState().clearSession(tab.sessionId)
@ -344,7 +381,7 @@ export function TabBar() {
ref={scrollRef}
data-testid="tab-bar-scroll-region"
data-desktop-drag-region={isDesktopRuntime ? true : undefined}
className="flex-1 flex items-stretch overflow-x-hidden"
className="flex-1 flex items-stretch overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onDragOver={(e) => e.preventDefault()}
>
{tabs.map((tab, index) => (