1- import { render , screen , waitFor } from '@testing-library/react' ;
1+ import { act , fireEvent , render , screen , waitFor , within } from '@testing-library/react' ;
22import userEvent from '@testing-library/user-event' ;
3+ import { useState } from 'react' ;
34import { describe , expect , it , vi } from 'vitest' ;
45
56import { Pen } from '../../icons/glyphs/pen' ;
@@ -17,6 +18,27 @@ function renderMenu(actions: Parameters<typeof ActionMenu>[0]['actions']) {
1718 ) ;
1819}
1920
21+ function AsyncMenu ( { onRemove = ( ) => undefined } : { onRemove ?: ( ) => void } ) {
22+ const [ status , setStatus ] = useState < 'idle' | 'pending' > ( 'idle' ) ;
23+ return (
24+ < MosaicProvider >
25+ < ActionMenu
26+ label = 'Manage passkey'
27+ actions = { [
28+ { label : 'Regenerate' , status, onClick : ( ) => setStatus ( 'pending' ) } ,
29+ { label : 'Remove' , onClick : onRemove } ,
30+ ] }
31+ />
32+ < button
33+ type = 'button'
34+ onClick = { ( ) => setStatus ( 'idle' ) }
35+ >
36+ Finish request
37+ </ button >
38+ </ MosaicProvider >
39+ ) ;
40+ }
41+
2042describe ( 'Mosaic ActionMenu' , ( ) => {
2143 it ( 'renders nothing when there are no actions' , ( ) => {
2244 renderMenu ( [ ] ) ;
@@ -49,6 +71,54 @@ describe('Mosaic ActionMenu', () => {
4971 await waitFor ( ( ) => expect ( screen . queryByRole ( 'menu' ) ) . not . toBeInTheDocument ( ) ) ;
5072 } ) ;
5173
74+ it ( 'keeps an opted-in action open while pending and closes immediately when it settles' , async ( ) => {
75+ const user = userEvent . setup ( ) ;
76+ const onRemove = vi . fn ( ) ;
77+ render ( < AsyncMenu onRemove = { onRemove } /> ) ;
78+ await user . click ( screen . getByRole ( 'button' , { name : 'Manage passkey' } ) ) ;
79+ await user . click ( await screen . findByRole ( 'menuitem' , { name : 'Regenerate' } ) ) ;
80+ const menu = screen . getByRole ( 'menu' ) ;
81+ expect ( within ( menu ) . getByRole ( 'menuitem' , { name : 'Regenerate' } ) ) . toHaveAttribute ( 'aria-busy' , 'true' ) ;
82+ expect ( within ( menu ) . getByRole ( 'menuitem' , { name : 'Remove' } ) ) . toHaveAttribute ( 'aria-disabled' , 'true' ) ;
83+ await user . click ( within ( menu ) . getByRole ( 'menuitem' , { name : 'Remove' } ) ) ;
84+ expect ( onRemove ) . not . toHaveBeenCalled ( ) ;
85+ await user . keyboard ( '{Escape}' ) ;
86+ expect ( screen . getByRole ( 'menu' ) ) . toBeInTheDocument ( ) ;
87+ await user . click ( document . body ) ;
88+ expect ( screen . getByRole ( 'menu' ) ) . toBeInTheDocument ( ) ;
89+ await user . click ( screen . getByRole ( 'button' , { name : 'Finish request' } ) ) ;
90+ await waitFor ( ( ) => expect ( screen . queryByRole ( 'menu' ) ) . not . toBeInTheDocument ( ) ) ;
91+ } ) ;
92+
93+ it ( 'reserves an indicator immediately and delays its named spinner' , async ( ) => {
94+ const user = userEvent . setup ( ) ;
95+ render ( < AsyncMenu /> ) ;
96+ await user . click ( screen . getByRole ( 'button' , { name : 'Manage passkey' } ) ) ;
97+ const item = await screen . findByRole ( 'menuitem' , { name : 'Regenerate' } ) ;
98+ expect ( item . querySelector ( '.cl-menu-indicator' ) ) . toBeInTheDocument ( ) ;
99+ vi . useFakeTimers ( ) ;
100+ try {
101+ fireEvent . click ( item ) ;
102+ expect ( screen . queryByRole ( 'progressbar' , { name : 'Regenerate' } ) ) . not . toBeInTheDocument ( ) ;
103+ await act ( ( ) => vi . advanceTimersByTime ( 149 ) ) ;
104+ expect ( screen . queryByRole ( 'progressbar' , { name : 'Regenerate' } ) ) . not . toBeInTheDocument ( ) ;
105+ await act ( ( ) => vi . advanceTimersByTime ( 1 ) ) ;
106+ expect ( screen . getByRole ( 'progressbar' , { name : 'Regenerate' } ) ) . toBeInTheDocument ( ) ;
107+ fireEvent . click ( screen . getByRole ( 'button' , { name : 'Finish request' } ) ) ;
108+ expect ( screen . queryByRole ( 'menu' ) ) . not . toBeInTheDocument ( ) ;
109+ } finally {
110+ vi . useRealTimers ( ) ;
111+ }
112+ } ) ;
113+
114+ it ( 'closes an opted-in action that hands off before becoming pending' , async ( ) => {
115+ const user = userEvent . setup ( ) ;
116+ renderMenu ( [ { label : 'Open dialog' , status : 'idle' , onClick : ( ) => undefined } ] ) ;
117+ await user . click ( screen . getByRole ( 'button' , { name : 'Manage passkey' } ) ) ;
118+ await user . click ( await screen . findByRole ( 'menuitem' , { name : 'Open dialog' } ) ) ;
119+ await waitFor ( ( ) => expect ( screen . queryByRole ( 'menu' ) ) . not . toBeInTheDocument ( ) ) ;
120+ } ) ;
121+
52122 it ( 'keeps a disabled trigger focusable without allowing pointer or keyboard activation' , async ( ) => {
53123 const onClick = vi . fn ( ) ;
54124 render (
0 commit comments