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