Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 17 additions & 38 deletions packages/coreui-react/src/components/carousel/CCarousel.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -95,13 +87,13 @@
const carouselRef = useRef<HTMLDivElement>(null)
const forkedRef = useForkedRef(ref, carouselRef)
const data = useRef<DataType>({}).current
const handleControlClickRef = useRef<(direction: string) => void>(() => undefined)

const [active, setActive] = useState<number>(activeIndex)
const [animating, setAnimating] = useState<boolean>(false)
const [customInterval, setCustomInterval] = useState<boolean | number>()
const [direction, setDirection] = useState<string>('next')
const [itemsNumber, setItemsNumber] = useState<number>(0)
const [touchPosition, setTouchPosition] = useState<number | null>(null)
const [visible, setVisible] = useState<boolean>()

useEffect(() => {
Expand All @@ -112,7 +104,7 @@
if (visible) {
cycle()
}
}, [visible])

Check warning on line 107 in packages/coreui-react/src/components/carousel/CCarousel.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'cycle'. Either include it or remove the dependency array

useEffect(() => {
if (animating) {
Expand All @@ -121,7 +113,7 @@
cycle()
onSlid?.(active, direction)
}
}, [animating])

Check warning on line 116 in packages/coreui-react/src/components/carousel/CCarousel.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has missing dependencies: 'active', 'cycle', 'direction', 'onSlid', and 'onSlide'. Either include them or remove the dependency array. If 'onSlide' changes too often, find the parent component that defines it and wrap that definition in useCallback

useEffect(() => {
window.addEventListener('scroll', handleScroll)
Expand All @@ -131,9 +123,22 @@
}
}, [])

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()
}, [])

Check warning on line 141 in packages/coreui-react/src/components/carousel/CCarousel.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'clearCycleTimeout'. Either include it or remove the dependency array

const clearCycleTimeout = () => {
if (data.timeout) {
Expand Down Expand Up @@ -179,6 +184,7 @@
setActive(active === 0 ? itemsNumber - 1 : active - 1)
}
}
handleControlClickRef.current = handleControlClick

const handleIndicatorClick = (index: number) => {
if (active === index) {
Expand All @@ -205,32 +211,6 @@
}
}

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 (
<div
className={classNames(
Expand All @@ -243,7 +223,6 @@
{...(dark && { 'data-coreui-theme': 'dark' })}
onMouseEnter={_pause}
onMouseLeave={cycle}
{...(touch && { onTouchStart: handleTouchStart, onTouchMove: handleTouchMove })}
{...rest}
ref={forkedRef}
>
Expand Down
82 changes: 82 additions & 0 deletions packages/coreui-react/src/utils/__tests__/swipe.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
2 changes: 2 additions & 0 deletions packages/coreui-react/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,4 +15,5 @@ export {
isInViewport,
isRTL,
mergeClassNames,
Swipe,
}
114 changes: 114 additions & 0 deletions packages/coreui-react/src/utils/swipe.ts
Original file line number Diff line number Diff line change
@@ -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
Loading