feat: 标签页栏支持鼠标滚轮横向滚动 (#61)
普通鼠标滚轮只产生垂直 delta,overflow-x 的横向滚动容器不会消费它, 事件会冒泡导致页面滚动。给 TabBar 的滚动容器绑定原生非 passive wheel 监听(React 的 onWheel 在根节点是 passive,preventDefault 无效), 在标签条溢出时把 deltaY(含 deltaX、deltaMode 归一化)转为 scrollLeft: - 未溢出时完全不拦截,页面正常滚动(空 pane 亦然) - 按住 Shift/Ctrl/Cmd/Alt 时放行,保留浏览器原生横滚/缩放 - 每个 pane 的 TabBar 独立监听,只滚动悬停的标签条 新增 tests/tab-bar-wheel.spec.tsx(jsdom 挂载,7 个用例)。
This commit is contained in:
+23
-2
@@ -5,7 +5,7 @@
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
@@ -72,6 +72,27 @@ export function TabBar(props: {
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
|
||||
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
|
||||
// native NON-passive listener: React registers onWheel passively at the
|
||||
// root, where preventDefault() is a no-op. Modifier keys keep their native
|
||||
// meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
|
||||
// does not overflow leaves the event alone so the page scrolls normally.
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (el === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (el.scrollWidth <= el.clientWidth) return
|
||||
event.preventDefault()
|
||||
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
|
||||
el.scrollLeft += (event.deltaX + event.deltaY) * unit
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { el.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
||||
@@ -107,7 +128,7 @@ export function TabBar(props: {
|
||||
if (payload !== null) onDropTab(payload, null)
|
||||
}}
|
||||
>
|
||||
<div className={css.tabList}>
|
||||
<div ref={listRef} className={css.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tab-strip wheel-scroll tests: a plain mouse wheel over the tab row emits
|
||||
* deltaY, which a horizontal scrollport (`overflow-x: auto`) never consumes —
|
||||
* the handler in TabBar translates it into horizontal scrolling. It must
|
||||
* consume the event ONLY when the strip actually overflows (else the page
|
||||
* scrolls normally), must leave modifier-key gestures to the browser (shift
|
||||
* = native horizontal scroll, ctrl/cmd = zoom), and must scale deltaMode
|
||||
* lines/pages like the browser would.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
import { TabBar } from '../src/client/TabBar.tsx'
|
||||
import type { SidebarTab } from '../src/client/state.ts'
|
||||
|
||||
function mountBar(overflow: boolean): { list: HTMLElement; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const tabs: SidebarTab[] = [
|
||||
{ id: 't1', type: 'explorer', title: 'Explorer' },
|
||||
{ id: 't2', type: 'git', title: 'Git' },
|
||||
]
|
||||
const root: Root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(TabBar, {
|
||||
paneId: 'pane:1',
|
||||
tabs,
|
||||
active: 't1',
|
||||
onActivate: () => {},
|
||||
onClose: () => {},
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
})
|
||||
const list = container.querySelector('[class*="tabList"]') as HTMLElement
|
||||
expect(list).not.toBeNull()
|
||||
// jsdom has no layout: stub the scrollport geometry to simulate overflow.
|
||||
Object.defineProperty(list, 'scrollWidth', { value: overflow ? 600 : 200, configurable: true })
|
||||
Object.defineProperty(list, 'clientWidth', { value: 200, configurable: true })
|
||||
return {
|
||||
list,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a wheel event on the strip and return whether it was consumed. */
|
||||
function wheel(list: HTMLElement, init: WheelEventInit): WheelEvent {
|
||||
const event = new WheelEvent('wheel', { cancelable: true, bubbles: true, ...init })
|
||||
list.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('TabBar wheel → horizontal scroll', () => {
|
||||
it('translates a vertical wheel delta into scrollLeft when the strip overflows', () => {
|
||||
const { list, unmount } = mountBar(true)
|
||||
try {
|
||||
list.scrollLeft = 0
|
||||
const event = wheel(list, { deltaY: 120 })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(list.scrollLeft).toBe(120)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('scans left for a negative delta', () => {
|
||||
const { list, unmount } = mountBar(true)
|
||||
try {
|
||||
list.scrollLeft = 500
|
||||
const event = wheel(list, { deltaY: -80 })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(list.scrollLeft).toBe(420)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not consume the event when the strip does not overflow', () => {
|
||||
const { list, unmount } = mountBar(false)
|
||||
try {
|
||||
list.scrollLeft = 0
|
||||
const event = wheel(list, { deltaY: 120 })
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(list.scrollLeft).toBe(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves modifier-key gestures to the browser', () => {
|
||||
const { list, unmount } = mountBar(true)
|
||||
try {
|
||||
list.scrollLeft = 0
|
||||
const ctrl = wheel(list, { deltaY: 120, ctrlKey: true })
|
||||
const shift = wheel(list, { deltaY: 120, shiftKey: true })
|
||||
const meta = wheel(list, { deltaY: 120, metaKey: true })
|
||||
expect(ctrl.defaultPrevented).toBe(false)
|
||||
expect(shift.defaultPrevented).toBe(false)
|
||||
expect(meta.defaultPrevented).toBe(false)
|
||||
expect(list.scrollLeft).toBe(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('scales line-mode deltas by 16px per line', () => {
|
||||
const { list, unmount } = mountBar(true)
|
||||
try {
|
||||
list.scrollLeft = 0
|
||||
const event = wheel(list, { deltaY: 3, deltaMode: 1 })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(list.scrollLeft).toBe(48)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('adds a horizontal trackpad delta to the scroll', () => {
|
||||
const { list, unmount } = mountBar(true)
|
||||
try {
|
||||
list.scrollLeft = 0
|
||||
const event = wheel(list, { deltaX: 40 })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(list.scrollLeft).toBe(40)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops scrolling after unmount', () => {
|
||||
const { list, unmount } = mountBar(true)
|
||||
list.scrollLeft = 0
|
||||
unmount()
|
||||
const event = wheel(list, { deltaY: 120 })
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(list.scrollLeft).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user