Skip to content

Commit 349353d

Browse files
authored
fix(landing): support swiping customer stories (#7645)
* fix(landing): support swiping customer stories * fix(landing): support horizontal wheel story navigation
1 parent d9ae065 commit 349353d

2 files changed

Lines changed: 270 additions & 2 deletions

File tree

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

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,189 @@ 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+
425+
describe('wheel navigation', () => {
426+
beforeEach(() => {
427+
vi.useFakeTimers()
428+
Object.defineProperty(rail, 'clientWidth', { value: 390 })
429+
})
430+
431+
function wheel(options: WheelEventInit) {
432+
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...options })
433+
act(() => rail.dispatchEvent(event))
434+
return event
435+
}
436+
437+
it('accumulates horizontal movement and navigates only once until the gesture ends', () => {
438+
wheel({ deltaX: 20, deltaY: 2 })
439+
expect(currentStory()).toBe('1 of 2: Rivian')
440+
act(() => vi.advanceTimersByTime(20))
441+
expect(wheel({ deltaX: 35, deltaY: 3 }).defaultPrevented).toBe(true)
442+
expect(currentStory()).toBe('2 of 2: eXp Realty')
443+
444+
for (let index = 0; index < 5; index += 1) {
445+
act(() => vi.advanceTimersByTime(100))
446+
wheel({ deltaX: -80 })
447+
expect(currentStory()).toBe('2 of 2: eXp Realty')
448+
}
449+
450+
act(() => vi.advanceTimersByTime(250))
451+
wheel({ deltaX: -80 })
452+
expect(currentStory()).toBe('1 of 2: Rivian')
453+
act(() => vi.advanceTimersByTime(250))
454+
wheel({ deltaX: -80 })
455+
expect(currentStory()).toBe('1 of 2: Rivian')
456+
})
457+
458+
it.each([
459+
{ deltaY: 80, shiftKey: true },
460+
{ deltaX: 80, shiftKey: true },
461+
{ deltaY: 4, deltaMode: 1, shiftKey: true },
462+
{ deltaY: 1, deltaMode: 2, shiftKey: true },
463+
])('supports horizontal and Shift+wheel deltas: %j', (options) => {
464+
expect(wheel(options).defaultPrevented).toBe(true)
465+
expect(currentStory()).toBe('2 of 2: eXp Realty')
466+
})
467+
468+
it.each([
469+
{ deltaY: 120 },
470+
{ deltaX: 20, deltaY: 120 },
471+
{ deltaX: 120, ctrlKey: true },
472+
{ deltaY: 120, ctrlKey: true, shiftKey: true },
473+
{ deltaX: 120, metaKey: true },
474+
])('preserves browser scrolling and zoom: %j', (options) => {
475+
expect(wheel(options).defaultPrevented).toBe(false)
476+
expect(currentStory()).toBe('1 of 2: Rivian')
477+
})
478+
479+
it('discards incomplete movement after an idle gap or vertical scrolling', () => {
480+
wheel({ deltaX: 30 })
481+
act(() => vi.advanceTimersByTime(250))
482+
wheel({ deltaX: 30 })
483+
expect(currentStory()).toBe('1 of 2: Rivian')
484+
wheel({ deltaY: 100 })
485+
wheel({ deltaX: 30 })
486+
expect(currentStory()).toBe('1 of 2: Rivian')
487+
})
488+
489+
it('removes the wheel listener when the carousel unmounts', () => {
490+
act(() => root.render(null))
491+
expect(wheel({ deltaX: 120 }).defaultPrevented).toBe(false)
492+
})
493+
})
494+
})

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

Lines changed: 84 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, useEffect, useRef, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import {
66
FeaturedCustomerCard,
@@ -13,6 +13,9 @@ import {
1313
LANDING_STAGE_RADIUS,
1414
} from '@/app/(landing)/components/landing-layout'
1515

16+
const WHEEL_GESTURE_GAP_MS = 200
17+
const WHEEL_THRESHOLD_PX = 50
18+
1619
const CUSTOMER_STORIES: FeaturedCustomerStory[] = [
1720
{
1821
id: 'rivian',
@@ -53,13 +56,86 @@ const CUSTOMER_STORIES: FeaturedCustomerStory[] = [
5356
* between stories, with the arrow that has nowhere to go disabled.
5457
*/
5558
export function FeaturedCustomer() {
59+
const railRef = useRef<HTMLDivElement>(null)
60+
const touchStartRef = useRef<{ id: number; x: number; y: number } | null>(null)
5661
const [activeIndex, setActiveIndex] = useState(0)
5762
const [previewedIndex, setPreviewedIndex] = useState<number | null>(null)
5863
const activeStory = CUSTOMER_STORIES[activeIndex]
5964
const previousStory = activeIndex > 0 ? CUSTOMER_STORIES[activeIndex - 1] : null
6065
const nextStory =
6166
activeIndex < CUSTOMER_STORIES.length - 1 ? CUSTOMER_STORIES[activeIndex + 1] : null
6267

68+
useEffect(() => {
69+
const rail = railRef.current
70+
if (!rail) return
71+
72+
let distance = 0
73+
let lastEventAt = 0
74+
let advanced = false
75+
76+
const handleWheel = (event: WheelEvent) => {
77+
if (event.ctrlKey || event.metaKey) return
78+
79+
const now = performance.now()
80+
if (now - lastEventAt > WHEEL_GESTURE_GAP_MS) {
81+
distance = 0
82+
advanced = false
83+
}
84+
lastEventAt = now
85+
86+
const deltaX = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX
87+
const deltaY = event.shiftKey ? 0 : event.deltaY
88+
if (deltaX === 0 || Math.abs(deltaX) <= Math.abs(deltaY)) {
89+
distance = 0
90+
return
91+
}
92+
93+
event.preventDefault()
94+
if (advanced) return
95+
96+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rail.clientWidth : 1
97+
distance += deltaX * unit
98+
if (Math.abs(distance) < WHEEL_THRESHOLD_PX) return
99+
100+
advanced = true
101+
const direction = distance > 0 ? 1 : -1
102+
setPreviewedIndex(null)
103+
setActiveIndex((index) =>
104+
Math.max(0, Math.min(CUSTOMER_STORIES.length - 1, index + direction))
105+
)
106+
}
107+
108+
rail.addEventListener('wheel', handleWheel, { passive: false })
109+
return () => rail.removeEventListener('wheel', handleWheel)
110+
}, [])
111+
112+
function handleTouchStart(event: TouchEvent<HTMLDivElement>) {
113+
const touch = event.touches[0]
114+
touchStartRef.current =
115+
event.touches.length === 1
116+
? { id: touch.identifier, x: touch.clientX, y: touch.clientY }
117+
: null
118+
}
119+
120+
function handleTouchEnd(event: TouchEvent<HTMLDivElement>) {
121+
const start = touchStartRef.current
122+
touchStartRef.current = null
123+
if (!start || event.touches.length > 0) return
124+
125+
const touch = Array.from(event.changedTouches).find((touch) => touch.identifier === start.id)
126+
if (!touch) return
127+
128+
const deltaX = touch.clientX - start.x
129+
const deltaY = touch.clientY - start.y
130+
if (Math.abs(deltaX) < 50 || Math.abs(deltaX) <= Math.abs(deltaY)) return
131+
132+
event.preventDefault()
133+
setPreviewedIndex(null)
134+
setActiveIndex((index) =>
135+
Math.max(0, Math.min(CUSTOMER_STORIES.length - 1, index + (deltaX < 0 ? 1 : -1)))
136+
)
137+
}
138+
63139
return (
64140
<section
65141
id='featured-customer'
@@ -88,9 +164,15 @@ export function FeaturedCustomer() {
88164
</div>
89165

90166
<div
167+
ref={railRef}
91168
data-customer-carousel-rail='true'
169+
onTouchStart={handleTouchStart}
170+
onTouchEnd={handleTouchEnd}
171+
onTouchCancel={() => {
172+
touchStartRef.current = null
173+
}}
92174
className={cn(
93-
'transition-[translate] duration-600 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none xl:pr-24',
175+
'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',
94176
activeIndex > 0 && 'xl:translate-x-24'
95177
)}
96178
>

0 commit comments

Comments
 (0)