Skip to content

Commit 7e671b6

Browse files
committed
fix(landing): support horizontal wheel story navigation
1 parent 3087c62 commit 7e671b6

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,4 +421,74 @@ describe('FeaturedCustomer touch navigation', () => {
421421
expect(touch('touchend', 250, 200, 1, 0, previousPreview).defaultPrevented).toBe(true)
422422
expect(currentStory()).toBe('1 of 2: Rivian')
423423
})
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+
})
424494
})

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

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

3-
import { type TouchEvent, useRef, 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,6 +56,7 @@ 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)
5660
const touchStartRef = useRef<{ id: number; x: number; y: number } | null>(null)
5761
const [activeIndex, setActiveIndex] = useState(0)
5862
const [previewedIndex, setPreviewedIndex] = useState<number | null>(null)
@@ -61,6 +65,50 @@ export function FeaturedCustomer() {
6165
const nextStory =
6266
activeIndex < CUSTOMER_STORIES.length - 1 ? CUSTOMER_STORIES[activeIndex + 1] : null
6367

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+
64112
function handleTouchStart(event: TouchEvent<HTMLDivElement>) {
65113
const touch = event.touches[0]
66114
touchStartRef.current =
@@ -116,6 +164,7 @@ export function FeaturedCustomer() {
116164
</div>
117165

118166
<div
167+
ref={railRef}
119168
data-customer-carousel-rail='true'
120169
onTouchStart={handleTouchStart}
121170
onTouchEnd={handleTouchEnd}

0 commit comments

Comments
 (0)