@@ -56,6 +56,7 @@ const mapContextTest = (
5656const shareUrlMock = ( deepLinkModule as unknown as { __test : ReturnType < typeof vi . fn > } ) . __test ;
5757
5858let fake : ReturnType < typeof createFakeMap > ;
59+ let domContextCoordinates : [ number , number ] ;
5960
6061function poiFeature ( name = "Smithsonian Institution Building" ) : MapGeoJSONFeature {
6162 return {
@@ -95,10 +96,44 @@ function openAtMapPoint(
9596 return preventDefault ;
9697}
9798
99+ function dispatchDomMapContextMenu ( coordinates : [ number , number ] ) {
100+ domContextCoordinates = coordinates ;
101+ const modalRoot = document . querySelector < HTMLElement > ( ".MuiPopover-root" ) ;
102+ const blockingLayer =
103+ modalRoot && getComputedStyle ( modalRoot ) . pointerEvents !== "none"
104+ ? ( modalRoot . querySelector < HTMLElement > ( ".MuiBackdrop-root" ) ?? modalRoot )
105+ : fake . state . canvas ;
106+ const event = new MouseEvent ( "contextmenu" , {
107+ bubbles : true ,
108+ cancelable : true ,
109+ clientX : 240 ,
110+ clientY : 260 ,
111+ } ) ;
112+ fireEvent ( blockingLayer , event ) ;
113+ return { blockingLayer, event } ;
114+ }
115+
116+ function deferred < T > ( ) {
117+ let resolve ! : ( value : T ) => void ;
118+ const promise = new Promise < T > ( ( done ) => {
119+ resolve = done ;
120+ } ) ;
121+ return { promise, resolve } ;
122+ }
123+
98124beforeEach ( ( ) => {
99125 fake = createFakeMap ( {
100126 baseLayers : [ { id : "poi-label" , type : "symbol" , "source-layer" : "poi" } as never ] ,
101127 } ) ;
128+ domContextCoordinates = [ - 77.02573 , 38.88859 ] ;
129+ fake . state . canvas . addEventListener ( "contextmenu" , ( event ) => {
130+ const [ lng , lat ] = domContextCoordinates ;
131+ fake . emit ( "contextmenu" , {
132+ point : { x : event . clientX , y : event . clientY } ,
133+ lngLat : { lng, lat } ,
134+ originalEvent : event ,
135+ } ) ;
136+ } ) ;
102137 document . body . appendChild ( fake . state . canvas ) ;
103138 mapContextTest . mapRef . current = fake . map ;
104139 mapContextTest . mapReady = true ;
@@ -402,6 +437,20 @@ describe("MapContextMenu keyboard behavior", () => {
402437
403438 await waitFor ( ( ) => expect ( document . activeElement ) . toBe ( from ) ) ;
404439 } ) ;
440+
441+ it ( "lets an already-open menu receive a second real DOM map contextmenu" , async ( ) => {
442+ render ( < MapContextMenu /> ) ;
443+ dispatchDomMapContextMenu ( [ - 77.02573 , 38.88859 ] ) ;
444+ expect ( screen . getByText ( "38.888590, -77.025730" ) ) . toBeDefined ( ) ;
445+
446+ const second = dispatchDomMapContextMenu ( [ 13.405 , 52.52 ] ) ;
447+
448+ expect ( second . blockingLayer ) . toBe ( fake . state . canvas ) ;
449+ expect ( second . event . defaultPrevented ) . toBe ( true ) ;
450+ expect ( screen . getByText ( "52.520000, 13.405000" ) ) . toBeDefined ( ) ;
451+ await userEvent . click ( screen . getByRole ( "menuitem" , { name : "mapContextMenu.fromHere" } ) ) ;
452+ expect ( useDirectionsStore . getState ( ) . origin ) . toEqual ( [ 13.405 , 52.52 ] ) ;
453+ } ) ;
405454} ) ;
406455
407456describe ( "MapContextMenu copy behavior" , ( ) => {
@@ -454,6 +503,46 @@ describe("MapContextMenu copy behavior", () => {
454503 expect ( screen . getByText ( "mapContextMenu.copied" ) ) . toBeDefined ( ) ;
455504 } ) ;
456505
506+ it ( "ignores a pending copy completion after unmount" , async ( ) => {
507+ const pending = deferred < void > ( ) ;
508+ installClipboard ( vi . fn ( ( ) => pending . promise ) ) ;
509+ const view = render ( < MapContextMenu /> ) ;
510+ openAtMapPoint ( ) ;
511+ const submenu = await openCopySubmenu ( ) ;
512+ await userEvent . click (
513+ within ( submenu ) . getByRole ( "menuitem" , { name : / 3 8 \. 8 8 8 5 9 0 , - 7 7 \. 0 2 5 7 3 0 / } ) ,
514+ ) ;
515+ const focusCanvas = vi . spyOn ( fake . state . canvas , "focus" ) ;
516+
517+ view . unmount ( ) ;
518+ focusCanvas . mockClear ( ) ;
519+ await act ( async ( ) => pending . resolve ( ) ) ;
520+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
521+
522+ expect ( focusCanvas ) . not . toHaveBeenCalled ( ) ;
523+ } ) ;
524+
525+ it ( "does not let a pending copy from target A close or update target B" , async ( ) => {
526+ const pending = deferred < void > ( ) ;
527+ installClipboard ( vi . fn ( ( ) => pending . promise ) ) ;
528+ render ( < MapContextMenu /> ) ;
529+ openAtMapPoint ( ) ;
530+ const submenu = await openCopySubmenu ( ) ;
531+ await userEvent . click (
532+ within ( submenu ) . getByRole ( "menuitem" , { name : / 3 8 \. 8 8 8 5 9 0 , - 7 7 \. 0 2 5 7 3 0 / } ) ,
533+ ) ;
534+
535+ openAtMapPoint ( { coordinates : [ 13.405 , 52.52 ] } ) ;
536+ await act ( async ( ) => pending . resolve ( ) ) ;
537+
538+ expect (
539+ within ( screen . getByRole ( "menu" , { name : "mapContextMenu.ariaLabel" } ) ) . getByText (
540+ "52.520000, 13.405000" ,
541+ ) ,
542+ ) . toBeDefined ( ) ;
543+ expect ( screen . queryByText ( "mapContextMenu.copied" ) ) . toBeNull ( ) ;
544+ } ) ;
545+
457546 for ( const mode of [ "rejected" , "unavailable" ] as const ) {
458547 it ( `keeps the main card open and shows failure when Clipboard is ${ mode } ` , async ( ) => {
459548 if ( mode === "rejected" ) installClipboard ( vi . fn ( ) . mockRejectedValue ( new Error ( "denied" ) ) ) ;
@@ -500,13 +589,44 @@ describe("MapContextMenu share behavior", () => {
500589 expect ( window . location . href ) . toBe ( hrefBefore ) ;
501590 } ) ;
502591 }
592+
593+ it ( "ignores a pending share completion after unmount" , async ( ) => {
594+ const pending = deferred < "copied" > ( ) ;
595+ shareUrlMock . mockImplementation ( ( ) => pending . promise ) ;
596+ const view = render ( < MapContextMenu /> ) ;
597+ openAtMapPoint ( ) ;
598+ await userEvent . click ( screen . getByRole ( "menuitem" , { name : "mapContextMenu.shareLocation" } ) ) ;
599+ const focusCanvas = vi . spyOn ( fake . state . canvas , "focus" ) ;
600+
601+ view . unmount ( ) ;
602+ focusCanvas . mockClear ( ) ;
603+ await act ( async ( ) => pending . resolve ( "copied" ) ) ;
604+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
605+
606+ expect ( focusCanvas ) . not . toHaveBeenCalled ( ) ;
607+ } ) ;
608+
609+ it ( "does not let a pending share from target A close or update target B" , async ( ) => {
610+ const pending = deferred < "copied" > ( ) ;
611+ shareUrlMock . mockImplementation ( ( ) => pending . promise ) ;
612+ render ( < MapContextMenu /> ) ;
613+ openAtMapPoint ( ) ;
614+ await userEvent . click ( screen . getByRole ( "menuitem" , { name : "mapContextMenu.shareLocation" } ) ) ;
615+
616+ openAtMapPoint ( { coordinates : [ 13.405 , 52.52 ] } ) ;
617+ await act ( async ( ) => pending . resolve ( "copied" ) ) ;
618+
619+ expect ( screen . getByText ( "52.520000, 13.405000" ) ) . toBeDefined ( ) ;
620+ expect ( screen . queryByText ( "mapContextMenu.linkCopied" ) ) . toBeNull ( ) ;
621+ } ) ;
503622} ) ;
504623
505624describe ( "MapContextMenu dismissal and semantics" , ( ) => {
506- it ( "dismisses on outside click, map movement, and style-version change" , ( ) => {
625+ it ( "dismisses on outside click, map movement, and style-version change" , async ( ) => {
507626 const view = render ( < MapContextMenu /> ) ;
508627 openAtMapPoint ( ) ;
509- fireEvent . click ( document . querySelector ( ".MuiBackdrop-root" ) as Element ) ;
628+ await new Promise < void > ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
629+ fireEvent . click ( fake . state . canvas ) ;
510630 expect ( screen . queryByRole ( "menu" ) ) . toBeNull ( ) ;
511631
512632 openAtMapPoint ( ) ;
0 commit comments