From f80c8988b284664f63f9b2a17dccc9d70eda5c64 Mon Sep 17 00:00:00 2001 From: mrholek Date: Sun, 14 Jun 2026 01:46:40 +0200 Subject: [PATCH] fix(CCarousel): use the vanilla swipe helper for touch navigation The inline touch handling used a 5px threshold and fired mid-gesture on touchmove, with no Pointer event support and no RTL awareness. Replace it with a Swipe helper ported from vanilla @coreui/coreui (pointer events with a touch fallback, 40px threshold, detection on release), wired via a single effect that respects RTL when mapping swipe direction to prev/next. --- .../src/components/carousel/CCarousel.tsx | 55 +++------ .../src/utils/__tests__/swipe.spec.ts | 82 +++++++++++++ packages/coreui-react/src/utils/index.ts | 2 + packages/coreui-react/src/utils/swipe.ts | 114 ++++++++++++++++++ 4 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 packages/coreui-react/src/utils/__tests__/swipe.spec.ts create mode 100644 packages/coreui-react/src/utils/swipe.ts diff --git a/packages/coreui-react/src/components/carousel/CCarousel.tsx b/packages/coreui-react/src/components/carousel/CCarousel.tsx index 361f4f26..30d86267 100644 --- a/packages/coreui-react/src/components/carousel/CCarousel.tsx +++ b/packages/coreui-react/src/components/carousel/CCarousel.tsx @@ -1,16 +1,8 @@ -import React, { - Children, - forwardRef, - HTMLAttributes, - TouchEvent, - useState, - useEffect, - useRef, -} from 'react' +import React, { Children, forwardRef, HTMLAttributes, useState, useEffect, useRef } from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' -import { isInViewport } from '../../utils' +import { isInViewport, isRTL, Swipe } from '../../utils' import { useForkedRef } from '../../hooks' import { CCarouselContext } from './CCarouselContext' @@ -95,13 +87,13 @@ export const CCarousel = forwardRef( const carouselRef = useRef(null) const forkedRef = useForkedRef(ref, carouselRef) const data = useRef({}).current + const handleControlClickRef = useRef<(direction: string) => void>(() => undefined) const [active, setActive] = useState(activeIndex) const [animating, setAnimating] = useState(false) const [customInterval, setCustomInterval] = useState() const [direction, setDirection] = useState('next') const [itemsNumber, setItemsNumber] = useState(0) - const [touchPosition, setTouchPosition] = useState(null) const [visible, setVisible] = useState() useEffect(() => { @@ -131,6 +123,19 @@ export const CCarousel = forwardRef( } }, []) + useEffect(() => { + if (!touch || !carouselRef.current) { + return + } + + const swipe = new Swipe(carouselRef.current, { + onLeft: () => handleControlClickRef.current(isRTL(carouselRef.current) ? 'prev' : 'next'), + onRight: () => handleControlClickRef.current(isRTL(carouselRef.current) ? 'next' : 'prev'), + }) + + return () => swipe.dispose() + }, [touch]) + useEffect(() => { return () => clearCycleTimeout() }, []) @@ -179,6 +184,7 @@ export const CCarousel = forwardRef( setActive(active === 0 ? itemsNumber - 1 : active - 1) } } + handleControlClickRef.current = handleControlClick const handleIndicatorClick = (index: number) => { if (active === index) { @@ -205,32 +211,6 @@ export const CCarousel = forwardRef( } } - const handleTouchMove = (e: TouchEvent) => { - const touchDown = touchPosition - - if (touchDown === null) { - return - } - - const currentTouch = e.touches[0].clientX - const diff = touchDown - currentTouch - - if (diff > 5) { - handleControlClick('next') - } - - if (diff < -5) { - handleControlClick('prev') - } - - setTouchPosition(null) - } - - const handleTouchStart = (e: TouchEvent) => { - const touchDown = e.touches[0].clientX - setTouchPosition(touchDown) - } - return (
( {...(dark && { 'data-coreui-theme': 'dark' })} onMouseEnter={_pause} onMouseLeave={cycle} - {...(touch && { onTouchStart: handleTouchStart, onTouchMove: handleTouchMove })} {...rest} ref={forkedRef} > diff --git a/packages/coreui-react/src/utils/__tests__/swipe.spec.ts b/packages/coreui-react/src/utils/__tests__/swipe.spec.ts new file mode 100644 index 00000000..a11ad7df --- /dev/null +++ b/packages/coreui-react/src/utils/__tests__/swipe.spec.ts @@ -0,0 +1,82 @@ +import Swipe from '../swipe' + +const touchEvent = (type: string, clientX: number): Event => { + const event = new Event(type) as Event & { touches: { clientX: number }[] } + event.touches = [{ clientX }] + return event +} + +describe('Swipe', () => { + let element: HTMLElement + const originalPointerEvent = window.PointerEvent + + beforeEach(() => { + element = document.createElement('div') + document.body.appendChild(element) + // Force the touch-event path and make the helper consider touch supported. + ;(window as unknown as { PointerEvent?: unknown }).PointerEvent = undefined + Object.defineProperty(navigator, 'maxTouchPoints', { value: 1, configurable: true }) + }) + + afterEach(() => { + element.remove() + ;(window as unknown as { PointerEvent?: unknown }).PointerEvent = originalPointerEvent + }) + + it('reports support when touch points are available', () => { + expect(Swipe.isSupported()).toBe(true) + }) + + it('calls onLeft when swiping left past the threshold', () => { + const onLeft = jest.fn() + const onRight = jest.fn() + const onEnd = jest.fn() + new Swipe(element, { onLeft, onRight, onEnd }) + + element.dispatchEvent(touchEvent('touchstart', 200)) + element.dispatchEvent(touchEvent('touchmove', 100)) + element.dispatchEvent(touchEvent('touchend', 100)) + + expect(onLeft).toHaveBeenCalledTimes(1) + expect(onRight).not.toHaveBeenCalled() + expect(onEnd).toHaveBeenCalledTimes(1) + }) + + it('calls onRight when swiping right past the threshold', () => { + const onLeft = jest.fn() + const onRight = jest.fn() + new Swipe(element, { onLeft, onRight }) + + element.dispatchEvent(touchEvent('touchstart', 100)) + element.dispatchEvent(touchEvent('touchmove', 200)) + element.dispatchEvent(touchEvent('touchend', 200)) + + expect(onRight).toHaveBeenCalledTimes(1) + expect(onLeft).not.toHaveBeenCalled() + }) + + it('ignores swipes below the threshold', () => { + const onLeft = jest.fn() + const onRight = jest.fn() + new Swipe(element, { onLeft, onRight }) + + element.dispatchEvent(touchEvent('touchstart', 200)) + element.dispatchEvent(touchEvent('touchmove', 180)) + element.dispatchEvent(touchEvent('touchend', 180)) + + expect(onLeft).not.toHaveBeenCalled() + expect(onRight).not.toHaveBeenCalled() + }) + + it('stops detecting swipes after dispose', () => { + const onLeft = jest.fn() + const swipe = new Swipe(element, { onLeft }) + swipe.dispose() + + element.dispatchEvent(touchEvent('touchstart', 200)) + element.dispatchEvent(touchEvent('touchmove', 100)) + element.dispatchEvent(touchEvent('touchend', 100)) + + expect(onLeft).not.toHaveBeenCalled() + }) +}) diff --git a/packages/coreui-react/src/utils/index.ts b/packages/coreui-react/src/utils/index.ts index 9d965598..2904d800 100644 --- a/packages/coreui-react/src/utils/index.ts +++ b/packages/coreui-react/src/utils/index.ts @@ -5,6 +5,7 @@ import getTransitionDurationFromElement from './getTransitionDurationFromElement import isInViewport from './isInViewport' import isRTL from './isRTL' import mergeClassNames from './mergeClassNames' +import Swipe from './swipe' export { executeAfterTransition, @@ -14,4 +15,5 @@ export { isInViewport, isRTL, mergeClassNames, + Swipe, } diff --git a/packages/coreui-react/src/utils/swipe.ts b/packages/coreui-react/src/utils/swipe.ts new file mode 100644 index 00000000..b5df0598 --- /dev/null +++ b/packages/coreui-react/src/utils/swipe.ts @@ -0,0 +1,114 @@ +const SWIPE_THRESHOLD = 40 +const POINTER_TYPE_TOUCH = 'touch' +const POINTER_TYPE_PEN = 'pen' +const CLASS_NAME_POINTER_EVENT = 'pointer-event' + +export interface SwipeCallbacks { + onLeft?: () => void + onRight?: () => void + onEnd?: () => void +} + +/** + * Detects horizontal swipe gestures on an element, using Pointer events when + * available and falling back to Touch events otherwise. A modified port of the + * vanilla `@coreui/coreui` swipe helper. + */ +class Swipe { + private readonly element: HTMLElement + private readonly callbacks: SwipeCallbacks + private deltaX = 0 + private readonly supportPointerEvents = Boolean(window.PointerEvent) + + constructor(element: HTMLElement, callbacks: SwipeCallbacks = {}) { + this.element = element + this.callbacks = callbacks + + if (!element || !Swipe.isSupported()) { + return + } + + this.initEvents() + } + + static isSupported(): boolean { + return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0 + } + + dispose(): void { + this.element.removeEventListener('pointerdown', this.start) + this.element.removeEventListener('pointerup', this.end) + this.element.removeEventListener('touchstart', this.start) + this.element.removeEventListener('touchmove', this.move) + this.element.removeEventListener('touchend', this.end) + this.element.classList.remove(CLASS_NAME_POINTER_EVENT) + } + + private readonly start = (event: PointerEvent | TouchEvent): void => { + if (!this.supportPointerEvents) { + this.deltaX = (event as TouchEvent).touches[0].clientX + return + } + + if (this.eventIsPointerPenTouch(event as PointerEvent)) { + this.deltaX = (event as PointerEvent).clientX + } + } + + private readonly move = (event: TouchEvent): void => { + this.deltaX = + event.touches && event.touches.length > 1 ? 0 : event.touches[0].clientX - this.deltaX + } + + private readonly end = (event: PointerEvent | TouchEvent): void => { + if (this.eventIsPointerPenTouch(event as PointerEvent)) { + this.deltaX = (event as PointerEvent).clientX - this.deltaX + } + + this.handleSwipe() + this.callbacks.onEnd?.() + } + + private handleSwipe(): void { + const absDeltaX = Math.abs(this.deltaX) + + if (absDeltaX <= SWIPE_THRESHOLD) { + return + } + + const direction = absDeltaX / this.deltaX + + this.deltaX = 0 + + if (!direction) { + return + } + + if (direction > 0) { + this.callbacks.onRight?.() + } else { + this.callbacks.onLeft?.() + } + } + + private eventIsPointerPenTouch(event: PointerEvent): boolean { + return ( + this.supportPointerEvents && + (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH) + ) + } + + private initEvents(): void { + if (this.supportPointerEvents) { + this.element.addEventListener('pointerdown', this.start) + this.element.addEventListener('pointerup', this.end) + this.element.classList.add(CLASS_NAME_POINTER_EVENT) + } else { + this.element.addEventListener('touchstart', this.start) + this.element.addEventListener('touchmove', this.move) + this.element.addEventListener('touchend', this.end) + } + } +} + +export default Swipe