@@ -707,6 +707,194 @@ describe.each([
707707 } ) ;
708708 } ) ;
709709 } ) ;
710+
711+ describe ( 'Modal scroll content hysteresis' , ( ) => {
712+ it ( 'should handle missing contentRef gracefully' , ( ) => {
713+ const { container } = render (
714+ < Component
715+ open
716+ modalHeading = "Test Modal"
717+ primaryButtonText = "Submit"
718+ secondaryButtonText = "Cancel" >
719+ < p > Test content</ p >
720+ </ Component >
721+ ) ;
722+
723+ const modalContent = container . querySelector ( `.${ prefix } --modal-content` ) ;
724+
725+ // Temporarily remove the ref to trigger the guard clause
726+ Object . defineProperty ( modalContent , 'scrollHeight' , {
727+ get : ( ) => undefined ,
728+ configurable : true ,
729+ } ) ;
730+
731+ // Modal should still render without errors
732+ expect ( modalContent ) . toBeInTheDocument ( ) ;
733+ } ) ;
734+
735+ it ( 'should set isScrollable to true when content is clearly scrollable (diff > 5)' , ( ) => {
736+ const { container, rerender } = render (
737+ < Component
738+ open
739+ modalHeading = "Test Modal"
740+ primaryButtonText = "Submit"
741+ secondaryButtonText = "Cancel" >
742+ < div style = { { height : '50px' } } > Small content</ div >
743+ </ Component >
744+ ) ;
745+
746+ const modalContent = container . querySelector ( `.${ prefix } --modal-content` ) ;
747+
748+ // Mock scrollHeight > clientHeight by more than 5px
749+ Object . defineProperty ( modalContent , 'scrollHeight' , {
750+ get : ( ) => 500 ,
751+ configurable : true ,
752+ } ) ;
753+ Object . defineProperty ( modalContent , 'clientHeight' , {
754+ get : ( ) => 400 , // diff = 100, which is > 5
755+ configurable : true ,
756+ } ) ;
757+
758+ // Trigger re-render to execute useEffect
759+ rerender (
760+ < Component
761+ open
762+ modalHeading = "Test Modal"
763+ primaryButtonText = "Submit"
764+ secondaryButtonText = "Cancel" >
765+ < div style = { { height : '500px' } } >
766+ Large content that needs scrolling
767+ </ div >
768+ </ Component >
769+ ) ;
770+
771+ // Should have scroll-content class
772+ expect ( modalContent ) . toHaveClass ( `${ prefix } --modal-scroll-content` ) ;
773+ } ) ;
774+
775+ it ( 'should set isScrollable to false when content is clearly not scrollable (diff < -5)' , ( ) => {
776+ const { container, rerender } = render (
777+ < Component
778+ open
779+ modalHeading = "Test Modal"
780+ primaryButtonText = "Submit"
781+ secondaryButtonText = "Cancel" >
782+ < div style = { { height : '500px' } } > Large content</ div >
783+ </ Component >
784+ ) ;
785+
786+ const modalContent = container . querySelector ( `.${ prefix } --modal-content` ) ;
787+
788+ // First make it scrollable
789+ Object . defineProperty ( modalContent , 'scrollHeight' , {
790+ get : ( ) => 500 ,
791+ configurable : true ,
792+ } ) ;
793+ Object . defineProperty ( modalContent , 'clientHeight' , {
794+ get : ( ) => 400 ,
795+ configurable : true ,
796+ } ) ;
797+
798+ rerender (
799+ < Component
800+ open
801+ modalHeading = "Test Modal"
802+ primaryButtonText = "Submit"
803+ secondaryButtonText = "Cancel" >
804+ < div style = { { height : '500px' } } > Large content</ div >
805+ </ Component >
806+ ) ;
807+
808+ // Now make it clearly not scrollable (diff < -5)
809+ Object . defineProperty ( modalContent , 'scrollHeight' , {
810+ get : ( ) => 300 ,
811+ configurable : true ,
812+ } ) ;
813+ Object . defineProperty ( modalContent , 'clientHeight' , {
814+ get : ( ) => 400 , // diff = -100, which is < -5
815+ configurable : true ,
816+ } ) ;
817+
818+ // Trigger re-render
819+ rerender (
820+ < Component
821+ open
822+ modalHeading = "Test Modal"
823+ primaryButtonText = "Submit"
824+ secondaryButtonText = "Cancel" >
825+ < div style = { { height : '50px' } } > Small content</ div >
826+ </ Component >
827+ ) ;
828+
829+ // Should NOT have scroll-content class
830+ expect ( modalContent ) . not . toHaveClass ( `${ prefix } --modal-scroll-content` ) ;
831+ } ) ;
832+
833+ it ( 'should maintain current state when diff is in dead zone (-5 to 5)' , ( ) => {
834+ const { container, rerender } = render (
835+ < Component
836+ open
837+ modalHeading = "Test Modal"
838+ primaryButtonText = "Submit"
839+ secondaryButtonText = "Cancel" >
840+ < div style = { { height : '100px' } } > Content</ div >
841+ </ Component >
842+ ) ;
843+
844+ const modalContent = container . querySelector ( `.${ prefix } --modal-content` ) ;
845+
846+ // Start with scrollable state
847+ Object . defineProperty ( modalContent , 'scrollHeight' , {
848+ get : ( ) => 400 ,
849+ configurable : true ,
850+ } ) ;
851+ Object . defineProperty ( modalContent , 'clientHeight' , {
852+ get : ( ) => 300 , // diff = 100 > 5, so scrollable
853+ configurable : true ,
854+ } ) ;
855+
856+ rerender (
857+ < Component
858+ open
859+ modalHeading = "Test Modal"
860+ primaryButtonText = "Submit"
861+ secondaryButtonText = "Cancel" >
862+ < div style = { { height : '400px' } } > Large content</ div >
863+ </ Component >
864+ ) ;
865+
866+ const initialHasClass = modalContent . classList . contains (
867+ `${ prefix } --modal-scroll-content`
868+ ) ;
869+
870+ // Now change to dead zone (diff = 2, which is between -5 and 5)
871+ Object . defineProperty ( modalContent , 'scrollHeight' , {
872+ get : ( ) => 302 ,
873+ configurable : true ,
874+ } ) ;
875+ Object . defineProperty ( modalContent , 'clientHeight' , {
876+ get : ( ) => 300 , // diff = 2, in dead zone
877+ configurable : true ,
878+ } ) ;
879+
880+ rerender (
881+ < Component
882+ open
883+ modalHeading = "Test Modal"
884+ primaryButtonText = "Submit"
885+ secondaryButtonText = "Cancel" >
886+ < div style = { { height : '302px' } } > Content in dead zone</ div >
887+ </ Component >
888+ ) ;
889+
890+ const finalHasClass = modalContent . classList . contains (
891+ `${ prefix } --modal-scroll-content`
892+ ) ;
893+
894+ // Class should remain the same (hysteresis prevents change)
895+ expect ( initialHasClass ) . toBe ( finalHasClass ) ;
896+ } ) ;
897+ } ) ;
710898} ) ;
711899
712900describe ( 'state' , ( ) => {
0 commit comments