From 6adca9619c33c092f6def6db1bb7e3a44e2c562d Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 15 Jul 2026 20:55:47 +0300 Subject: [PATCH] feat(Tabs): add drag-to-scroll for horizontal tabs --- .../src/components/Tabs/Tabs.module.css | 11 + .../src/components/Tabs/Tabs.test.tsx | 265 ++++++++++++++++++ .../components/src/components/Tabs/Tabs.tsx | 10 + .../components/Tabs/components/Tab/Tab.tsx | 9 +- .../src/components/Tabs/hooks/index.ts | 1 + .../components/Tabs/hooks/useDragScroll.ts | 217 ++++++++++++++ 6 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/components/Tabs/hooks/index.ts create mode 100644 packages/components/src/components/Tabs/hooks/useDragScroll.ts diff --git a/packages/components/src/components/Tabs/Tabs.module.css b/packages/components/src/components/Tabs/Tabs.module.css index 78229a3c..ea4b09ed 100644 --- a/packages/components/src/components/Tabs/Tabs.module.css +++ b/packages/components/src/components/Tabs/Tabs.module.css @@ -70,6 +70,17 @@ .horizontal { flex-direction: column; + &[data-horizontal-scrollable] { + .scrollBox { + cursor: grab; + + &[data-dragging] { + cursor: grabbing; + user-select: none; + } + } + } + .scrollBox { display: flex; inline-size: 100%; diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index 1d17e33a..0d443bfe 100644 --- a/packages/components/src/components/Tabs/Tabs.test.tsx +++ b/packages/components/src/components/Tabs/Tabs.test.tsx @@ -170,6 +170,29 @@ describe('Tabs', () => { const hasNextScrollButton = (container: HTMLElement) => !!findScrollButton(container, ariaLabelNextBtn); + const firePointerEvent = ( + element: Element, + type: string, + { + pointerId, + pointerType, + ...eventInit + }: MouseEventInit & { pointerId: number; pointerType: string } + ) => { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + ...eventInit, + }); + + Object.defineProperties(event, { + pointerId: { value: pointerId }, + pointerType: { value: pointerType }, + }); + + fireEvent(element, event); + }; + // Mock scrollTo method (jsdom doesn't support scrolling) Object.defineProperty(HTMLElement.prototype, 'scrollTo', { value(options?: ScrollToOptions | number, y?: number) { @@ -494,6 +517,248 @@ describe('Tabs', () => { expect(Number(scrollBox?.scrollLeft) < 100).toBeTruthy(); }); + + it('should drag horizontal tabs with the primary mouse button', () => { + const { container } = render(renderComponent({})); + + const scrollBox = container.querySelector( + `.${s.scrollBox}` + ) as HTMLElement; + + Object.defineProperty(scrollBox, 'scrollLeft', { + value: 100, + configurable: true, + writable: true, + }); + + Object.defineProperty(scrollBox, 'clientWidth', { value: 100 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); + fireEvent.scroll(scrollBox); + + firePointerEvent(scrollBox, 'pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 100, + }); + + firePointerEvent(scrollBox, 'pointermove', { + pointerId: 1, + pointerType: 'mouse', + clientX: 60, + }); + + expect(scrollBox.scrollLeft).toBe(140); + expect(scrollBox).toHaveAttribute('data-dragging', 'true'); + + firePointerEvent(scrollBox, 'pointercancel', { + pointerId: 1, + pointerType: 'mouse', + }); + + expect(scrollBox).not.toHaveAttribute('data-dragging'); + }); + + it('should not drag non-overflowing, vertical, or touch tabs', () => { + const { container, rerender } = render( + renderComponent({ orientation: 'vertical' }) + ); + + let scrollBox = container.querySelector(`.${s.scrollBox}`) as HTMLElement; + + Object.defineProperty(scrollBox, 'scrollLeft', { + value: 100, + configurable: true, + writable: true, + }); + + firePointerEvent(scrollBox, 'pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 100, + }); + + firePointerEvent(scrollBox, 'pointermove', { + pointerId: 1, + pointerType: 'mouse', + clientX: 60, + }); + + expect(scrollBox.scrollLeft).toBe(100); + + scrollBox.scrollLeft = 0; + rerender(renderComponent({})); + + scrollBox = container.querySelector(`.${s.scrollBox}`) as HTMLElement; + + firePointerEvent(scrollBox, 'pointerdown', { + pointerId: 2, + pointerType: 'mouse', + button: 0, + clientX: 100, + }); + + firePointerEvent(scrollBox, 'pointermove', { + pointerId: 2, + pointerType: 'mouse', + clientX: 60, + }); + + expect(scrollBox.scrollLeft).toBe(0); + + Object.defineProperty(scrollBox, 'clientWidth', { value: 100 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); + scrollBox.scrollLeft = 100; + fireEvent.scroll(scrollBox); + + firePointerEvent(scrollBox, 'pointerdown', { + pointerId: 3, + pointerType: 'touch', + button: 0, + clientX: 100, + }); + + firePointerEvent(scrollBox, 'pointermove', { + pointerId: 3, + pointerType: 'touch', + clientX: 60, + }); + + expect(scrollBox.scrollLeft).toBe(100); + }); + + it('should not select a tab when its pointer interaction becomes a drag', () => { + const onSelectionChange = vi.fn(); + + const { container } = render( + renderComponent({ selectedKey: 1, onSelectionChange }) + ); + + const scrollBox = container.querySelector( + `.${s.scrollBox}` + ) as HTMLElement; + + const tab = screen.getByTestId(TAB__TEST_ID); + + Object.defineProperty(scrollBox, 'clientWidth', { value: 100 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); + fireEvent.scroll(scrollBox); + + firePointerEvent(tab, 'pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 100, + }); + + // React Aria uses mouse events as a PointerEvent fallback in JSDOM. + fireEvent.mouseDown(tab, { button: 0, clientX: 100 }); + + expect(onSelectionChange).not.toHaveBeenCalled(); + + firePointerEvent(scrollBox, 'pointermove', { + pointerId: 1, + pointerType: 'mouse', + clientX: 60, + }); + + firePointerEvent(scrollBox, 'pointerup', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 60, + }); + + fireEvent.mouseUp(scrollBox, { button: 0, clientX: 60 }); + fireEvent.click(tab); + + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should select an overflowed tab when a mouse press is released without dragging', () => { + const onSelectionChange = vi.fn(); + + const { container } = render( + renderComponent({ selectedKey: 1, onSelectionChange }) + ); + + const scrollBox = container.querySelector( + `.${s.scrollBox}` + ) as HTMLElement; + + const tab = screen.getByTestId(TAB__TEST_ID); + + Object.defineProperty(scrollBox, 'clientWidth', { value: 100 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); + fireEvent.scroll(scrollBox); + + fireEvent.mouseDown(tab, { button: 0 }); + + expect(onSelectionChange).not.toHaveBeenCalled(); + + fireEvent.mouseUp(tab, { button: 0 }); + fireEvent.click(tab); + + expect(onSelectionChange).toHaveBeenCalledOnce(); + expect(onSelectionChange).toHaveBeenCalledWith('2'); + }); + + it('should keep scrolling with inertia after mouse release', () => { + let frameCallback: FrameRequestCallback | undefined; + + const requestAnimationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + frameCallback = callback; + + return 1; + }); + + const { container } = render(renderComponent({})); + + const scrollBox = container.querySelector( + `.${s.scrollBox}` + ) as HTMLElement; + + Object.defineProperty(scrollBox, 'scrollLeft', { + value: 100, + configurable: true, + writable: true, + }); + + Object.defineProperty(scrollBox, 'clientWidth', { value: 100 }); + Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 }); + fireEvent.scroll(scrollBox); + + firePointerEvent(scrollBox, 'pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 100, + }); + + firePointerEvent(scrollBox, 'pointermove', { + pointerId: 1, + pointerType: 'mouse', + clientX: 60, + }); + + firePointerEvent(scrollBox, 'pointerup', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 60, + }); + + const scrollLeftAfterDrag = scrollBox.scrollLeft; + + act(() => frameCallback?.(16)); + + expect(scrollBox.scrollLeft).toBeGreaterThan(scrollLeftAfterDrag); + + requestAnimationFrameSpy.mockRestore(); + }); }); }); diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx index 5517a86f..80e01849 100644 --- a/packages/components/src/components/Tabs/Tabs.tsx +++ b/packages/components/src/components/Tabs/Tabs.tsx @@ -23,6 +23,7 @@ import { TabPanel, TabScrollButton, } from './components'; +import { useDragScroll } from './hooks'; import intlMessages from './intl.json'; import type { TabProps as TabItemProps } from './Tab'; import s from './Tabs.module.css'; @@ -107,6 +108,8 @@ export function TabsRender( const el = scrollBoxRef.current; if (!el) return; + cancelInertia(); + if (orientation === 'horizontal') { el.scrollTo({ left: value, behavior }); } else { @@ -160,6 +163,10 @@ export function TabsRender( const hasVerticalOverflow = !isHorizontal && (verticalOverflow.start || verticalOverflow.end); + const { isDragging, cancelInertia, dragScrollProps } = useDragScroll( + hasHorizontalOverflow + ); + const activeHorizontalOverflow = isHorizontal ? horizontalOverflow : { start: false, end: false }; @@ -280,9 +287,11 @@ export function TabsRender( const scrollBoxProps = mergeProps( { + ...dragScrollProps, ref: scrollBoxRef, className: s.scrollBox, onScroll: updateScrollState, + 'data-dragging': isDragging || undefined, 'data-overflow-inline-start': activeHorizontalOverflow.start || undefined, 'data-overflow-inline-end': activeHorizontalOverflow.end || undefined, 'data-overflow-block-start': activeVerticalOverflow.start || undefined, @@ -339,6 +348,7 @@ export function TabsRender( orientation={orientation} isStretched={isStretched} isUnderlined={isUnderlined} + shouldSelectOnPressUp={hasHorizontalOverflow} closeButtonProps={slotProps?.closeButton} {...(onRemove && typeof onRemove === 'function' && { diff --git a/packages/components/src/components/Tabs/components/Tab/Tab.tsx b/packages/components/src/components/Tabs/components/Tab/Tab.tsx index 64bb911e..7ef763f7 100644 --- a/packages/components/src/components/Tabs/components/Tab/Tab.tsx +++ b/packages/components/src/components/Tabs/components/Tab/Tab.tsx @@ -28,6 +28,7 @@ export type TabProps = { orientation: TabOrientation; isUnderlined?: boolean; isStretched?: boolean; + shouldSelectOnPressUp?: boolean; onFocused?: () => void; onRemove?: () => void; closeButtonProps?: IconButtonProps; @@ -40,6 +41,7 @@ export function Tab({ orientation, isUnderlined = false, isStretched = false, + shouldSelectOnPressUp = false, onFocused, onRemove, closeButtonProps, @@ -50,7 +52,12 @@ export function Tab({ const allowsRemoving = !!onRemove; const domRef = useDOMRef(innerRef); - const { tabProps, isSelected, isDisabled } = useTab({ key }, state, domRef); + + const { tabProps, isSelected, isDisabled } = useTab( + { key, shouldSelectOnPressUp }, + state, + domRef + ); const { href, diff --git a/packages/components/src/components/Tabs/hooks/index.ts b/packages/components/src/components/Tabs/hooks/index.ts new file mode 100644 index 00000000..18b2748e --- /dev/null +++ b/packages/components/src/components/Tabs/hooks/index.ts @@ -0,0 +1 @@ +export * from './useDragScroll'; diff --git a/packages/components/src/components/Tabs/hooks/useDragScroll.ts b/packages/components/src/components/Tabs/hooks/useDragScroll.ts new file mode 100644 index 00000000..d5c47797 --- /dev/null +++ b/packages/components/src/components/Tabs/hooks/useDragScroll.ts @@ -0,0 +1,217 @@ +import { useEffect, useRef } from 'react'; +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from 'react'; + +import { useBoolean } from '@koobiq/react-core'; + +const DRAG_THRESHOLD = 4; +const MIN_INERTIA_VELOCITY = 0.02; +const MAX_INERTIA_VELOCITY = 3; +const MAX_FRAME_DURATION = 32; +const FRICTION_PER_MILLISECOND = 0.003; + +type DragState = { + pointerId: number; + startX: number; + lastX: number; + lastTimestamp: number; + velocity: number; + hasDragged: boolean; +}; + +/** Adds mouse drag-to-scroll and momentum without affecting native touch scrolling. */ +export const useDragScroll = (isEnabled: boolean) => { + const dragStateRef = useRef(null); + const inertiaFrameRef = useRef(null); + const clickResetTimerRef = useRef | null>(null); + const suppressClickRef = useRef(false); + const [isDragging, { on: startDragging, off: stopDragging }] = useBoolean(); + + const cancelInertia = () => { + if (inertiaFrameRef.current !== null) { + cancelAnimationFrame(inertiaFrameRef.current); + inertiaFrameRef.current = null; + } + }; + + const startInertia = (element: HTMLElement, initialVelocity: number) => { + cancelInertia(); + + if (Math.abs(initialVelocity) < MIN_INERTIA_VELOCITY) return; + + let velocity = initialVelocity; + let previousTimestamp: number | null = null; + + const step = (timestamp: number) => { + const frameDuration = Math.min( + previousTimestamp === null ? 16 : timestamp - previousTimestamp, + MAX_FRAME_DURATION + ); + + previousTimestamp = timestamp; + + const previousScrollLeft = element.scrollLeft; + element.scrollLeft += velocity * frameDuration; + + const reachedBoundary = element.scrollLeft === previousScrollLeft; + + velocity *= Math.exp(-FRICTION_PER_MILLISECOND * frameDuration); + + if (reachedBoundary || Math.abs(velocity) < MIN_INERTIA_VELOCITY) { + inertiaFrameRef.current = null; + + return; + } + + inertiaFrameRef.current = requestAnimationFrame(step); + }; + + inertiaFrameRef.current = requestAnimationFrame(step); + }; + + const resetDrag = () => { + dragStateRef.current = null; + stopDragging(); + }; + + const onPointerDownCapture = (event: ReactPointerEvent) => { + cancelInertia(); + + if (clickResetTimerRef.current !== null) { + clearTimeout(clickResetTimerRef.current); + clickResetTimerRef.current = null; + } + + suppressClickRef.current = false; + + if (!isEnabled || event.pointerType !== 'mouse' || event.button !== 0) { + return; + } + + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + lastX: event.clientX, + lastTimestamp: event.timeStamp, + velocity: 0, + hasDragged: false, + }; + }; + + const onPointerMoveCapture = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + + if (!dragState || event.pointerId !== dragState.pointerId) return; + + const totalDistance = event.clientX - dragState.startX; + + if (!dragState.hasDragged && Math.abs(totalDistance) < DRAG_THRESHOLD) { + return; + } + + if (!dragState.hasDragged) { + dragState.hasDragged = true; + suppressClickRef.current = true; + startDragging(); + event.currentTarget.setPointerCapture?.(event.pointerId); + } + + event.preventDefault(); + + const distance = event.clientX - dragState.lastX; + const elapsed = Math.max(event.timeStamp - dragState.lastTimestamp, 1); + + const instantVelocity = Math.max( + -MAX_INERTIA_VELOCITY, + Math.min(MAX_INERTIA_VELOCITY, -distance / elapsed) + ); + + event.currentTarget.scrollLeft -= distance; + + dragState.velocity = + dragState.velocity === 0 + ? instantVelocity + : dragState.velocity * 0.7 + instantVelocity * 0.3; + + dragState.lastX = event.clientX; + dragState.lastTimestamp = event.timeStamp; + }; + + const onPointerUpCapture = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + + if (!dragState || event.pointerId !== dragState.pointerId) return; + + if (dragState.hasDragged) { + event.preventDefault(); + + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const releaseDelay = event.timeStamp - dragState.lastTimestamp; + + const releaseVelocity = + releaseDelay > 80 + ? 0 + : dragState.velocity * + Math.exp(-FRICTION_PER_MILLISECOND * releaseDelay); + + startInertia(event.currentTarget, releaseVelocity); + + clickResetTimerRef.current = setTimeout(() => { + suppressClickRef.current = false; + clickResetTimerRef.current = null; + }); + } + + resetDrag(); + }; + + const onPointerCancelCapture = (event: ReactPointerEvent) => { + if (dragStateRef.current?.pointerId !== event.pointerId) return; + + suppressClickRef.current = false; + resetDrag(); + }; + + const onClickCapture = (event: ReactMouseEvent) => { + if (!suppressClickRef.current) return; + + suppressClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }; + + useEffect(() => { + if (!isEnabled) { + suppressClickRef.current = false; + resetDrag(); + } + + return () => { + cancelInertia(); + suppressClickRef.current = false; + + if (clickResetTimerRef.current !== null) { + clearTimeout(clickResetTimerRef.current); + clickResetTimerRef.current = null; + } + }; + }, [isEnabled]); + + return { + isDragging, + cancelInertia, + dragScrollProps: { + onClickCapture, + onPointerDownCapture, + onPointerMoveCapture, + onPointerUpCapture, + onPointerCancelCapture, + onWheelCapture: cancelInertia, + }, + }; +};