diff --git a/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx b/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx index fc854b8dc42..44690864584 100644 --- a/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx +++ b/apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx @@ -306,3 +306,189 @@ describe('FeaturedCustomer', () => { }) }) }) + +describe('FeaturedCustomer touch navigation', () => { + let host: HTMLDivElement + let root: ReturnType + let rail: HTMLElement + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + act(() => root.render()) + rail = host.querySelector('[data-customer-carousel-rail="true"]') as HTMLElement + }) + + afterEach(() => { + act(() => root.unmount()) + }) + + function touch( + type: string, + x: number, + y: number, + identifier = 1, + remainingTouches = type === 'touchstart' ? 1 : 0, + target: Element = rail + ) { + const point = { identifier, clientX: x, clientY: y } + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperties(event, { + touches: { value: Array.from({ length: remainingTouches }, () => point) }, + changedTouches: { value: [point] }, + }) + act(() => target.dispatchEvent(event)) + return event + } + + function currentStory() { + return host.querySelector('[aria-current="true"]')?.getAttribute('aria-label') + } + + it('swipes both ways, stops at each end, and preserves video playback state', () => { + const videos = host.querySelectorAll('video') + videos[0].currentTime = 12 + touch('touchstart', 100, 200) + touch('touchend', 250, 205) + expect(currentStory()).toBe('1 of 2: Rivian') + + touch('touchstart', 250, 200) + const end = touch('touchend', 100, 205) + expect(currentStory()).toBe('2 of 2: eXp Realty') + expect(end.defaultPrevented).toBe(true) + expect(vi.mocked(videos[0].pause).mock.contexts).toContain(videos[0]) + expect(vi.mocked(videos[1].play).mock.contexts).toContain(videos[1]) + + touch('touchstart', 250, 200) + touch('touchend', 100, 205) + expect(currentStory()).toBe('2 of 2: eXp Realty') + + touch('touchstart', 100, 200) + touch('touchend', 250, 205) + expect(currentStory()).toBe('1 of 2: Rivian') + expect(host.querySelector('video')).toBe(videos[0]) + expect(videos[0].currentTime).toBe(12) + }) + + it.each<[string, number, number]>([ + ['tap', 250, 200], + ['short drag', 225, 205], + ['vertical scroll', 240, 50], + ['mostly vertical diagonal', 160, 50], + ])('leaves a %s gesture to the browser', (_gesture, x, y) => { + touch('touchstart', 250, 200) + const end = touch('touchend', x, y) + expect(currentStory()).toBe('1 of 2: Rivian') + expect(end.defaultPrevented).toBe(false) + }) + + it('ignores cancelled and unrelated touches', () => { + touch('touchstart', 250, 200) + touch('touchcancel', 150, 200) + expect(touch('touchend', 100, 200).defaultPrevented).toBe(false) + touch('touchstart', 250, 200) + expect(touch('touchend', 100, 200, 2).defaultPrevented).toBe(false) + expect(currentStory()).toBe('1 of 2: Rivian') + }) + + it('does not turn a pinch into a swipe and accepts the next single touch', () => { + touch('touchstart', 250, 200) + touch('touchstart', 200, 200, 2, 2) + expect(touch('touchend', 100, 200, 2, 1).defaultPrevented).toBe(false) + expect(touch('touchend', 100, 200).defaultPrevented).toBe(false) + expect(currentStory()).toBe('1 of 2: Rivian') + + touch('touchstart', 250, 200) + touch('touchend', 100, 200) + expect(currentStory()).toBe('2 of 2: eXp Realty') + }) + + it('preserves preview taps and consumes a swipe starting on the preview button', () => { + const preview = host.querySelector( + '[aria-label="Open eXp Realty customer story"]' + )! + touch('touchstart', 250, 200, 1, 1, preview) + expect(touch('touchend', 250, 200, 1, 0, preview).defaultPrevented).toBe(false) + act(() => preview.click()) + expect(currentStory()).toBe('2 of 2: eXp Realty') + + const previousPreview = host.querySelector( + '[aria-label="Open Rivian customer story"]' + )! + touch('touchstart', 100, 200, 1, 1, previousPreview) + expect(touch('touchend', 250, 200, 1, 0, previousPreview).defaultPrevented).toBe(true) + expect(currentStory()).toBe('1 of 2: Rivian') + }) + + describe('wheel navigation', () => { + beforeEach(() => { + vi.useFakeTimers() + Object.defineProperty(rail, 'clientWidth', { value: 390 }) + }) + + function wheel(options: WheelEventInit) { + const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...options }) + act(() => rail.dispatchEvent(event)) + return event + } + + it('accumulates horizontal movement and navigates only once until the gesture ends', () => { + wheel({ deltaX: 20, deltaY: 2 }) + expect(currentStory()).toBe('1 of 2: Rivian') + act(() => vi.advanceTimersByTime(20)) + expect(wheel({ deltaX: 35, deltaY: 3 }).defaultPrevented).toBe(true) + expect(currentStory()).toBe('2 of 2: eXp Realty') + + for (let index = 0; index < 5; index += 1) { + act(() => vi.advanceTimersByTime(100)) + wheel({ deltaX: -80 }) + expect(currentStory()).toBe('2 of 2: eXp Realty') + } + + act(() => vi.advanceTimersByTime(250)) + wheel({ deltaX: -80 }) + expect(currentStory()).toBe('1 of 2: Rivian') + act(() => vi.advanceTimersByTime(250)) + wheel({ deltaX: -80 }) + expect(currentStory()).toBe('1 of 2: Rivian') + }) + + it.each([ + { deltaY: 80, shiftKey: true }, + { deltaX: 80, shiftKey: true }, + { deltaY: 4, deltaMode: 1, shiftKey: true }, + { deltaY: 1, deltaMode: 2, shiftKey: true }, + ])('supports horizontal and Shift+wheel deltas: %j', (options) => { + expect(wheel(options).defaultPrevented).toBe(true) + expect(currentStory()).toBe('2 of 2: eXp Realty') + }) + + it.each([ + { deltaY: 120 }, + { deltaX: 20, deltaY: 120 }, + { deltaX: 120, ctrlKey: true }, + { deltaY: 120, ctrlKey: true, shiftKey: true }, + { deltaX: 120, metaKey: true }, + ])('preserves browser scrolling and zoom: %j', (options) => { + expect(wheel(options).defaultPrevented).toBe(false) + expect(currentStory()).toBe('1 of 2: Rivian') + }) + + it('discards incomplete movement after an idle gap or vertical scrolling', () => { + wheel({ deltaX: 30 }) + act(() => vi.advanceTimersByTime(250)) + wheel({ deltaX: 30 }) + expect(currentStory()).toBe('1 of 2: Rivian') + wheel({ deltaY: 100 }) + wheel({ deltaX: 30 }) + expect(currentStory()).toBe('1 of 2: Rivian') + }) + + it('removes the wheel listener when the carousel unmounts', () => { + act(() => root.render(null)) + expect(wheel({ deltaX: 120 }).defaultPrevented).toBe(false) + }) + }) +}) diff --git a/apps/sim/app/(landing)/components/featured-customer/featured-customer.tsx b/apps/sim/app/(landing)/components/featured-customer/featured-customer.tsx index 391fca73637..796408e825c 100644 --- a/apps/sim/app/(landing)/components/featured-customer/featured-customer.tsx +++ b/apps/sim/app/(landing)/components/featured-customer/featured-customer.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { type TouchEvent, useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { FeaturedCustomerCard, @@ -13,6 +13,9 @@ import { LANDING_STAGE_RADIUS, } from '@/app/(landing)/components/landing-layout' +const WHEEL_GESTURE_GAP_MS = 200 +const WHEEL_THRESHOLD_PX = 50 + const CUSTOMER_STORIES: FeaturedCustomerStory[] = [ { id: 'rivian', @@ -53,6 +56,8 @@ const CUSTOMER_STORIES: FeaturedCustomerStory[] = [ * between stories, with the arrow that has nowhere to go disabled. */ export function FeaturedCustomer() { + const railRef = useRef(null) + const touchStartRef = useRef<{ id: number; x: number; y: number } | null>(null) const [activeIndex, setActiveIndex] = useState(0) const [previewedIndex, setPreviewedIndex] = useState(null) const activeStory = CUSTOMER_STORIES[activeIndex] @@ -60,6 +65,77 @@ export function FeaturedCustomer() { const nextStory = activeIndex < CUSTOMER_STORIES.length - 1 ? CUSTOMER_STORIES[activeIndex + 1] : null + useEffect(() => { + const rail = railRef.current + if (!rail) return + + let distance = 0 + let lastEventAt = 0 + let advanced = false + + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || event.metaKey) return + + const now = performance.now() + if (now - lastEventAt > WHEEL_GESTURE_GAP_MS) { + distance = 0 + advanced = false + } + lastEventAt = now + + const deltaX = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX + const deltaY = event.shiftKey ? 0 : event.deltaY + if (deltaX === 0 || Math.abs(deltaX) <= Math.abs(deltaY)) { + distance = 0 + return + } + + event.preventDefault() + if (advanced) return + + const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rail.clientWidth : 1 + distance += deltaX * unit + if (Math.abs(distance) < WHEEL_THRESHOLD_PX) return + + advanced = true + const direction = distance > 0 ? 1 : -1 + setPreviewedIndex(null) + setActiveIndex((index) => + Math.max(0, Math.min(CUSTOMER_STORIES.length - 1, index + direction)) + ) + } + + rail.addEventListener('wheel', handleWheel, { passive: false }) + return () => rail.removeEventListener('wheel', handleWheel) + }, []) + + function handleTouchStart(event: TouchEvent) { + const touch = event.touches[0] + touchStartRef.current = + event.touches.length === 1 + ? { id: touch.identifier, x: touch.clientX, y: touch.clientY } + : null + } + + function handleTouchEnd(event: TouchEvent) { + const start = touchStartRef.current + touchStartRef.current = null + if (!start || event.touches.length > 0) return + + const touch = Array.from(event.changedTouches).find((touch) => touch.identifier === start.id) + if (!touch) return + + const deltaX = touch.clientX - start.x + const deltaY = touch.clientY - start.y + if (Math.abs(deltaX) < 50 || Math.abs(deltaX) <= Math.abs(deltaY)) return + + event.preventDefault() + setPreviewedIndex(null) + setActiveIndex((index) => + Math.max(0, Math.min(CUSTOMER_STORIES.length - 1, index + (deltaX < 0 ? 1 : -1))) + ) + } + return (