mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
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:
parent
6b5482e3b7
commit
d2830f91de
@ -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', {
|
||||
|
||||
@ -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) => (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user