Skip to content

Commit 3087c62

Browse files
committed
fix(landing): support swiping customer stories
1 parent 00c8413 commit 3087c62

2 files changed

Lines changed: 151 additions & 2 deletions

File tree

apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,119 @@ describe('FeaturedCustomer', () => {
306306
})
307307
})
308308
})
309+
310+
describe('FeaturedCustomer touch navigation', () => {
311+
let host: HTMLDivElement
312+
let root: ReturnType<typeof createRoot>
313+
let rail: HTMLElement
314+
315+
beforeEach(() => {
316+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
317+
host = document.createElement('div')
318+
document.body.append(host)
319+
root = createRoot(host)
320+
act(() => root.render(<FeaturedCustomer />))
321+
rail = host.querySelector('[data-customer-carousel-rail="true"]') as HTMLElement
322+
})
323+
324+
afterEach(() => {
325+
act(() => root.unmount())
326+
})
327+
328+
function touch(
329+
type: string,
330+
x: number,
331+
y: number,
332+
identifier = 1,
333+
remainingTouches = type === 'touchstart' ? 1 : 0,
334+
target: Element = rail
335+
) {
336+
const point = { identifier, clientX: x, clientY: y }
337+
const event = new Event(type, { bubbles: true, cancelable: true })
338+
Object.defineProperties(event, {
339+
touches: { value: Array.from({ length: remainingTouches }, () => point) },
340+
changedTouches: { value: [point] },
341+
})
342+
act(() => target.dispatchEvent(event))
343+
return event
344+
}
345+
346+
function currentStory() {
347+
return host.querySelector('[aria-current="true"]')?.getAttribute('aria-label')
348+
}
349+
350+
it('swipes both ways, stops at each end, and preserves video playback state', () => {
351+
const videos = host.querySelectorAll('video')
352+
videos[0].currentTime = 12
353+
touch('touchstart', 100, 200)
354+
touch('touchend', 250, 205)
355+
expect(currentStory()).toBe('1 of 2: Rivian')
356+
357+
touch('touchstart', 250, 200)
358+
const end = touch('touchend', 100, 205)
359+
expect(currentStory()).toBe('2 of 2: eXp Realty')
360+
expect(end.defaultPrevented).toBe(true)
361+
expect(vi.mocked(videos[0].pause).mock.contexts).toContain(videos[0])
362+
expect(vi.mocked(videos[1].play).mock.contexts).toContain(videos[1])
363+
364+
touch('touchstart', 250, 200)
365+
touch('touchend', 100, 205)
366+
expect(currentStory()).toBe('2 of 2: eXp Realty')
367+
368+
touch('touchstart', 100, 200)
369+
touch('touchend', 250, 205)
370+
expect(currentStory()).toBe('1 of 2: Rivian')
371+
expect(host.querySelector('video')).toBe(videos[0])
372+
expect(videos[0].currentTime).toBe(12)
373+
})
374+
375+
it.each<[string, number, number]>([
376+
['tap', 250, 200],
377+
['short drag', 225, 205],
378+
['vertical scroll', 240, 50],
379+
['mostly vertical diagonal', 160, 50],
380+
])('leaves a %s gesture to the browser', (_gesture, x, y) => {
381+
touch('touchstart', 250, 200)
382+
const end = touch('touchend', x, y)
383+
expect(currentStory()).toBe('1 of 2: Rivian')
384+
expect(end.defaultPrevented).toBe(false)
385+
})
386+
387+
it('ignores cancelled and unrelated touches', () => {
388+
touch('touchstart', 250, 200)
389+
touch('touchcancel', 150, 200)
390+
expect(touch('touchend', 100, 200).defaultPrevented).toBe(false)
391+
touch('touchstart', 250, 200)
392+
expect(touch('touchend', 100, 200, 2).defaultPrevented).toBe(false)
393+
expect(currentStory()).toBe('1 of 2: Rivian')
394+
})
395+
396+
it('does not turn a pinch into a swipe and accepts the next single touch', () => {
397+
touch('touchstart', 250, 200)
398+
touch('touchstart', 200, 200, 2, 2)
399+
expect(touch('touchend', 100, 200, 2, 1).defaultPrevented).toBe(false)
400+
expect(touch('touchend', 100, 200).defaultPrevented).toBe(false)
401+
expect(currentStory()).toBe('1 of 2: Rivian')
402+
403+
touch('touchstart', 250, 200)
404+
touch('touchend', 100, 200)
405+
expect(currentStory()).toBe('2 of 2: eXp Realty')
406+
})
407+
408+
it('preserves preview taps and consumes a swipe starting on the preview button', () => {
409+
const preview = host.querySelector<HTMLButtonElement>(
410+
'[aria-label="Open eXp Realty customer story"]'
411+
)!
412+
touch('touchstart', 250, 200, 1, 1, preview)
413+
expect(touch('touchend', 250, 200, 1, 0, preview).defaultPrevented).toBe(false)
414+
act(() => preview.click())
415+
expect(currentStory()).toBe('2 of 2: eXp Realty')
416+
417+
const previousPreview = host.querySelector<HTMLButtonElement>(
418+
'[aria-label="Open Rivian customer story"]'
419+
)!
420+
touch('touchstart', 100, 200, 1, 1, previousPreview)
421+
expect(touch('touchend', 250, 200, 1, 0, previousPreview).defaultPrevented).toBe(true)
422+
expect(currentStory()).toBe('1 of 2: Rivian')
423+
})
424+
})

apps/sim/app/(landing)/components/featured-customer/featured-customer.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { type TouchEvent, useRef, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import {
66
FeaturedCustomerCard,
@@ -53,13 +53,41 @@ const CUSTOMER_STORIES: FeaturedCustomerStory[] = [
5353
* between stories, with the arrow that has nowhere to go disabled.
5454
*/
5555
export function FeaturedCustomer() {
56+
const touchStartRef = useRef<{ id: number; x: number; y: number } | null>(null)
5657
const [activeIndex, setActiveIndex] = useState(0)
5758
const [previewedIndex, setPreviewedIndex] = useState<number | null>(null)
5859
const activeStory = CUSTOMER_STORIES[activeIndex]
5960
const previousStory = activeIndex > 0 ? CUSTOMER_STORIES[activeIndex - 1] : null
6061
const nextStory =
6162
activeIndex < CUSTOMER_STORIES.length - 1 ? CUSTOMER_STORIES[activeIndex + 1] : null
6263

64+
function handleTouchStart(event: TouchEvent<HTMLDivElement>) {
65+
const touch = event.touches[0]
66+
touchStartRef.current =
67+
event.touches.length === 1
68+
? { id: touch.identifier, x: touch.clientX, y: touch.clientY }
69+
: null
70+
}
71+
72+
function handleTouchEnd(event: TouchEvent<HTMLDivElement>) {
73+
const start = touchStartRef.current
74+
touchStartRef.current = null
75+
if (!start || event.touches.length > 0) return
76+
77+
const touch = Array.from(event.changedTouches).find((touch) => touch.identifier === start.id)
78+
if (!touch) return
79+
80+
const deltaX = touch.clientX - start.x
81+
const deltaY = touch.clientY - start.y
82+
if (Math.abs(deltaX) < 50 || Math.abs(deltaX) <= Math.abs(deltaY)) return
83+
84+
event.preventDefault()
85+
setPreviewedIndex(null)
86+
setActiveIndex((index) =>
87+
Math.max(0, Math.min(CUSTOMER_STORIES.length - 1, index + (deltaX < 0 ? 1 : -1)))
88+
)
89+
}
90+
6391
return (
6492
<section
6593
id='featured-customer'
@@ -89,8 +117,13 @@ export function FeaturedCustomer() {
89117

90118
<div
91119
data-customer-carousel-rail='true'
120+
onTouchStart={handleTouchStart}
121+
onTouchEnd={handleTouchEnd}
122+
onTouchCancel={() => {
123+
touchStartRef.current = null
124+
}}
92125
className={cn(
93-
'transition-[translate] duration-600 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none xl:pr-24',
126+
'touch-pan-y touch-pinch-zoom transition-[translate] duration-600 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none xl:pr-24',
94127
activeIndex > 0 && 'xl:translate-x-24'
95128
)}
96129
>

0 commit comments

Comments
 (0)