@@ -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+ } )
0 commit comments