@@ -126,4 +126,88 @@ describe('Drawer', () => {
126126 expect ( onOpenChange ) . toHaveBeenCalledWith ( false )
127127 } )
128128 } )
129+
130+ describe ( 'onClose transition semantics' , ( ) => {
131+ it ( 'does not call onClose when initially mounted with open=false' , ( ) => {
132+ const onClose = vi . fn ( )
133+ render (
134+ < Drawer open = { false } onOpenChange = { vi . fn ( ) } onClose = { onClose } snapPoints = { [ 'full' ] } >
135+ < Drawer . Content > Body</ Drawer . Content >
136+ </ Drawer > ,
137+ )
138+ expect ( onClose ) . not . toHaveBeenCalled ( )
139+ } )
140+
141+ it ( 'calls onClose exactly once when controlled open transitions true → false' , async ( ) => {
142+ const onClose = vi . fn ( )
143+ const { rerender } = render (
144+ < Drawer open = { true } onOpenChange = { vi . fn ( ) } onClose = { onClose } snapPoints = { [ 'full' ] } >
145+ < Drawer . Content > Body</ Drawer . Content >
146+ </ Drawer > ,
147+ )
148+ await screen . findByRole ( 'dialog' )
149+ expect ( onClose ) . not . toHaveBeenCalled ( )
150+
151+ rerender (
152+ < Drawer open = { false } onOpenChange = { vi . fn ( ) } onClose = { onClose } snapPoints = { [ 'full' ] } >
153+ < Drawer . Content > Body</ Drawer . Content >
154+ </ Drawer > ,
155+ )
156+
157+ await waitFor ( ( ) => {
158+ expect ( onClose ) . toHaveBeenCalledTimes ( 1 )
159+ } )
160+ } )
161+
162+ it ( 'does not call onClose on subsequent renders when already closed' , async ( ) => {
163+ const onClose = vi . fn ( )
164+ const { rerender } = render (
165+ < Drawer open = { true } onOpenChange = { vi . fn ( ) } onClose = { onClose } snapPoints = { [ 'full' ] } >
166+ < Drawer . Content > Body</ Drawer . Content >
167+ </ Drawer > ,
168+ )
169+ await screen . findByRole ( 'dialog' )
170+
171+ rerender (
172+ < Drawer open = { false } onOpenChange = { vi . fn ( ) } onClose = { onClose } snapPoints = { [ 'full' ] } >
173+ < Drawer . Content > Body</ Drawer . Content >
174+ </ Drawer > ,
175+ )
176+ await waitFor ( ( ) => {
177+ expect ( onClose ) . toHaveBeenCalledTimes ( 1 )
178+ } )
179+
180+ // Re-render again while still closed — onClose must not fire again
181+ rerender (
182+ < Drawer open = { false } onOpenChange = { vi . fn ( ) } onClose = { onClose } snapPoints = { [ 'full' ] } >
183+ < Drawer . Content > Body</ Drawer . Content >
184+ </ Drawer > ,
185+ )
186+ expect ( onClose ) . toHaveBeenCalledTimes ( 1 )
187+ } )
188+
189+ it ( 'calls onClose exactly once in the uncontrolled flow when closed via overlay click' , async ( ) => {
190+ const onClose = vi . fn ( )
191+ const onOpenChange = vi . fn ( )
192+ const user = userEvent . setup ( )
193+
194+ render (
195+ < Drawer defaultOpen onOpenChange = { onOpenChange } onClose = { onClose } snapPoints = { [ 'full' ] } >
196+ < Drawer . Content > Body</ Drawer . Content >
197+ </ Drawer > ,
198+ )
199+ await screen . findByRole ( 'dialog' )
200+ expect ( onClose ) . not . toHaveBeenCalled ( )
201+
202+ const [ overlay ] = screen . getAllByRole ( 'button' , { hidden : true } )
203+ if ( ! overlay ) expect . fail ( 'expected modal overlay button' )
204+ await user . click ( overlay )
205+
206+ await waitFor ( ( ) => {
207+ expect ( onClose ) . toHaveBeenCalledTimes ( 1 )
208+ } )
209+ // Ensure no extra calls after the transition settles
210+ expect ( onClose ) . toHaveBeenCalledTimes ( 1 )
211+ } )
212+ } )
129213} )
0 commit comments