@@ -877,4 +877,205 @@ describe('ZoomableImage controlled transform behavior', () => {
877877
878878 expectCurrentTransform ( 0 , 0 , 0.5 ) ;
879879 } ) ;
880+
881+ describe ( 'control button zoom animation' , ( ) => {
882+ // jsdom has no TransitionEvent constructor, and fireEvent.transitionEnd does
883+ // not deliver `propertyName` to React's synthetic event. Build the event
884+ // manually (mirroring firePointerEvent) so the handler's property filter
885+ // sees a real value.
886+ const fireTransitionEnd = ( element : Element , propertyName : string ) => {
887+ const event = new Event ( 'transitionend' , {
888+ bubbles : true ,
889+ cancelable : true ,
890+ } ) ;
891+ Object . defineProperty ( event , 'propertyName' , {
892+ configurable : true ,
893+ value : propertyName ,
894+ } ) ;
895+ fireEvent ( element , event ) ;
896+ } ;
897+
898+ const setupSceneWithRef = (
899+ viewportSize : { width : number ; height : number } ,
900+ imageSize : { width : number ; height : number } ,
901+ ) => {
902+ const imageRef = createRef < ZoomableImageRef > ( ) ;
903+
904+ render (
905+ < ZoomableImage
906+ ref = { imageRef }
907+ imagePath = "/tmp/photo.jpg"
908+ alt = "test image"
909+ rotation = { 0 }
910+ /> ,
911+ ) ;
912+
913+ const viewport = screen . getByTestId ( 'zoom-viewport' ) ;
914+ const content = screen . getByTestId ( 'zoom-content' ) ;
915+ const image = screen . getByAltText ( 'test image' ) ;
916+
917+ mockElementRect (
918+ viewport ,
919+ { ...viewportSize , left : 0 , top : 0 } ,
920+ { clientWidth : viewportSize . width , clientHeight : viewportSize . height } ,
921+ ) ;
922+ mockImageDimensions ( image , imageSize ) ;
923+ fireEvent . load ( image ) ;
924+
925+ return { imageRef, viewport, content, image } ;
926+ } ;
927+
928+ test ( 'enables a smooth transition for a button zoom that changes scale' , ( ) => {
929+ const { imageRef, content } = setupSceneWithRef (
930+ { width : 800 , height : 600 } ,
931+ { width : 400 , height : 300 } ,
932+ ) ;
933+
934+ act ( ( ) => {
935+ imageRef . current ?. zoomIn ( ) ;
936+ } ) ;
937+
938+ expect ( content . style . transition ) . toBe ( 'transform 250ms ease-out' ) ;
939+ expect ( getCurrentTransform ( ) . scale ) . toBeCloseTo ( 1.5 ) ;
940+ } ) ;
941+
942+ test ( 'switching to the wheel cancels the in-flight button transition' , ( ) => {
943+ const { imageRef, viewport, content } = setupSceneWithRef (
944+ { width : 800 , height : 600 } ,
945+ { width : 400 , height : 300 } ,
946+ ) ;
947+
948+ act ( ( ) => {
949+ imageRef . current ?. zoomIn ( ) ;
950+ } ) ;
951+ expect ( content . style . transition ) . toBe ( 'transform 250ms ease-out' ) ;
952+
953+ fireEvent . wheel ( viewport , { deltaY : - 100 , clientX : 400 , clientY : 300 } ) ;
954+
955+ // Wheel zoom must stay instant: the transition is cleared immediately.
956+ expect ( content . style . transition ) . toBe ( '' ) ;
957+ } ) ;
958+
959+ test ( 'transitionend ends the animation so later transforms are instant' , ( ) => {
960+ const { imageRef, content } = setupSceneWithRef (
961+ { width : 800 , height : 600 } ,
962+ { width : 400 , height : 300 } ,
963+ ) ;
964+
965+ act ( ( ) => {
966+ imageRef . current ?. zoomIn ( ) ;
967+ } ) ;
968+ expect ( content . style . transition ) . toBe ( 'transform 250ms ease-out' ) ;
969+
970+ act ( ( ) => {
971+ fireTransitionEnd ( content , 'transform' ) ;
972+ } ) ;
973+
974+ expect ( content . style . transition ) . toBe ( '' ) ;
975+ } ) ;
976+
977+ test ( 'ignores unrelated transitionend events' , ( ) => {
978+ const { imageRef, content } = setupSceneWithRef (
979+ { width : 800 , height : 600 } ,
980+ { width : 400 , height : 300 } ,
981+ ) ;
982+
983+ act ( ( ) => {
984+ imageRef . current ?. zoomIn ( ) ;
985+ } ) ;
986+
987+ // A bubbled, non-transform transitionend must not clear the animation.
988+ act ( ( ) => {
989+ fireTransitionEnd ( content , 'opacity' ) ;
990+ } ) ;
991+
992+ expect ( content . style . transition ) . toBe ( 'transform 250ms ease-out' ) ;
993+ } ) ;
994+
995+ test ( 'does not animate a button zoom that is clamped at maximum scale' , ( ) => {
996+ const { imageRef, content } = setupSceneWithRef (
997+ { width : 800 , height : 600 } ,
998+ { width : 800 , height : 600 } ,
999+ ) ;
1000+
1001+ // Saturate at MAX_SCALE; well past the 1.5^n needed to reach 8.
1002+ for ( let i = 0 ; i < 12 ; i += 1 ) {
1003+ act ( ( ) => {
1004+ imageRef . current ?. zoomIn ( ) ;
1005+ } ) ;
1006+ }
1007+
1008+ expect ( getCurrentTransform ( ) . scale ) . toBeCloseTo ( 8 ) ;
1009+ // The final click could not change the transform, so no transition runs.
1010+ expect ( content . style . transition ) . toBe ( '' ) ;
1011+ } ) ;
1012+
1013+ test ( 'does not animate a button zoom that is clamped at minimum scale' , ( ) => {
1014+ const { imageRef, content } = setupSceneWithRef (
1015+ { width : 800 , height : 600 } ,
1016+ { width : 400 , height : 300 } ,
1017+ ) ;
1018+
1019+ // The image already fits at minimum scale; zooming out is a no-op.
1020+ act ( ( ) => {
1021+ imageRef . current ?. zoomOut ( ) ;
1022+ } ) ;
1023+
1024+ expect ( getCurrentTransform ( ) . scale ) . toBeCloseTo ( 1 ) ;
1025+ expect ( content . style . transition ) . toBe ( '' ) ;
1026+ } ) ;
1027+
1028+ test ( 'ignores a transform transitionend bubbled from a child element' , ( ) => {
1029+ const { imageRef, content, image } = setupSceneWithRef (
1030+ { width : 800 , height : 600 } ,
1031+ { width : 400 , height : 300 } ,
1032+ ) ;
1033+
1034+ act ( ( ) => {
1035+ imageRef . current ?. zoomIn ( ) ;
1036+ } ) ;
1037+ expect ( content . style . transition ) . toBe ( 'transform 250ms ease-out' ) ;
1038+
1039+ // A transform transitionend from the child <img> bubbles to the content
1040+ // handler, but must be ignored (target !== currentTarget).
1041+ act ( ( ) => {
1042+ fireTransitionEnd ( image , 'transform' ) ;
1043+ } ) ;
1044+
1045+ expect ( content . style . transition ) . toBe ( 'transform 250ms ease-out' ) ;
1046+ } ) ;
1047+
1048+ test ( 'skips the transition when reduced motion is preferred' , ( ) => {
1049+ const matchMediaSpy = jest . spyOn ( window , 'matchMedia' ) . mockImplementation (
1050+ ( query : string ) =>
1051+ ( {
1052+ matches : query . includes ( 'prefers-reduced-motion' ) ,
1053+ media : query ,
1054+ onchange : null ,
1055+ addListener : jest . fn ( ) ,
1056+ removeListener : jest . fn ( ) ,
1057+ addEventListener : jest . fn ( ) ,
1058+ removeEventListener : jest . fn ( ) ,
1059+ dispatchEvent : jest . fn ( ) ,
1060+ } ) as unknown as MediaQueryList ,
1061+ ) ;
1062+
1063+ try {
1064+ const { imageRef, content } = setupSceneWithRef (
1065+ { width : 800 , height : 600 } ,
1066+ { width : 400 , height : 300 } ,
1067+ ) ;
1068+
1069+ act ( ( ) => {
1070+ imageRef . current ?. zoomIn ( ) ;
1071+ } ) ;
1072+
1073+ // The zoom still applies, but without the CSS transition.
1074+ expect ( getCurrentTransform ( ) . scale ) . toBeCloseTo ( 1.5 ) ;
1075+ expect ( content . style . transition ) . toBe ( '' ) ;
1076+ } finally {
1077+ matchMediaSpy . mockRestore ( ) ;
1078+ }
1079+ } ) ;
1080+ } ) ;
8801081} ) ;
0 commit comments