@@ -15,11 +15,17 @@ vi.mock("@/lib/MapContext", () => {
1515} ) ;
1616vi . mock ( "@openmapx/core" , async ( importOriginal ) => {
1717 const actual = await importOriginal < typeof import ( "@openmapx/core" ) > ( ) ;
18- const state = { reverseData : null as { address : string ; city : string } | null } ;
18+ const state = {
19+ reverseData : null as { address : string ; city : string } | null ,
20+ requiredLocale : null as string | null ,
21+ } ;
1922 return {
2023 ...actual ,
2124 __test : state ,
22- useReverseGeocoding : ( ) => ( { data : state . reverseData , isLoading : false } ) ,
25+ useReverseGeocoding : ( _coordinates : [ number , number ] | null , locale ?: string ) => ( {
26+ data : ! state . requiredLocale || locale === state . requiredLocale ? state . reverseData : null ,
27+ isLoading : false ,
28+ } ) ,
2329 } ;
2430} ) ;
2531vi . mock ( "@/lib/deepLink" , async ( importOriginal ) => {
@@ -45,7 +51,10 @@ import { MapContextMenu } from "./MapContextMenu";
4551
4652const coreTest = (
4753 coreModule as unknown as {
48- __test : { reverseData : { address : string ; city : string } | null } ;
54+ __test : {
55+ reverseData : { address : string ; city : string } | null ;
56+ requiredLocale : string | null ;
57+ } ;
4958 }
5059) . __test ;
5160const mapContextTest = (
@@ -121,6 +130,22 @@ function deferred<T>() {
121130 return { promise, resolve } ;
122131}
123132
133+ function focusVisibleStyle ( element : HTMLElement ) : CSSStyleDeclaration | undefined {
134+ const generatedClass = [ ...element . classList ] . find ( ( className ) => className . startsWith ( "css-" ) ) ;
135+ if ( ! generatedClass ) return undefined ;
136+ for ( const sheet of document . styleSheets ) {
137+ for ( const rule of sheet . cssRules ) {
138+ if (
139+ rule instanceof CSSStyleRule &&
140+ rule . selectorText === `.${ generatedClass } .Mui-focusVisible`
141+ ) {
142+ return rule . style ;
143+ }
144+ }
145+ }
146+ return undefined ;
147+ }
148+
124149beforeEach ( ( ) => {
125150 fake = createFakeMap ( {
126151 baseLayers : [ { id : "poi-label" , type : "symbol" , "source-layer" : "poi" } as never ] ,
@@ -139,6 +164,7 @@ beforeEach(() => {
139164 mapContextTest . mapReady = true ;
140165 mapContextTest . styleVersion = 0 ;
141166 coreTest . reverseData = null ;
167+ coreTest . requiredLocale = null ;
142168 shareUrlMock . mockReset ( ) ;
143169 shareUrlMock . mockResolvedValue ( "shared" ) ;
144170 useDirectionsStore . getState ( ) . close ( ) ;
@@ -218,6 +244,23 @@ describe("MapContextMenu opening", () => {
218244 expect ( screen . getByText ( "1000 Jefferson Drive" ) ) . toBeDefined ( ) ;
219245 } ) ;
220246
247+ it ( "clamps long identity title and address to at most two lines" , ( ) => {
248+ coreTest . reverseData = {
249+ city : "A deliberately long reverse-geocoded city identity" ,
250+ address : "A deliberately long reverse-geocoded street address" ,
251+ } ;
252+ render ( < MapContextMenu /> ) ;
253+
254+ openAtMapPoint ( ) ;
255+
256+ for ( const text of [ coreTest . reverseData . city , coreTest . reverseData . address ] ) {
257+ const style = getComputedStyle ( screen . getByText ( text ) ) ;
258+ expect ( style . display ) . toBe ( "-webkit-box" ) ;
259+ expect ( style . webkitLineClamp ) . toBe ( "2" ) ;
260+ expect ( style . overflow ) . toBe ( "hidden" ) ;
261+ }
262+ } ) ;
263+
221264 it ( "clears only the plain-map click target when opening" , ( ) => {
222265 useMapClickStore . setState ( { clickedLngLat : [ 1 , 2 ] } ) ;
223266 const selectedPlace = createPlace ( {
@@ -350,6 +393,20 @@ describe("MapContextMenu route and place actions", () => {
350393 expect ( useSidebarStore . getState ( ) . activeDetailId ) . toBeNull ( ) ;
351394 } ) ;
352395
396+ it ( "uses the active-locale reverse-geocoded address for a named POI" , async ( ) => {
397+ fake . setRenderedFeatures ( "poi-label" , [ poiFeature ( ) ] ) ;
398+ coreTest . requiredLocale = "en" ;
399+ coreTest . reverseData = { city : "Washington" , address : "1000 Jefferson Drive" } ;
400+ render ( < MapContextMenu /> ) ;
401+ openAtMapPoint ( ) ;
402+
403+ await userEvent . click (
404+ screen . getByRole ( "menuitem" , { name : "mapContextMenu.openPlaceDetails" } ) ,
405+ ) ;
406+
407+ expect ( usePlaceStore . getState ( ) . selectedPlace ?. address ) . toBe ( "1000 Jefferson Drive" ) ;
408+ } ) ;
409+
353410 it ( "uses canonical coordinate identity and preserves an unrelated sidebar" , async ( ) => {
354411 coreTest . reverseData = { city : "Washington" , address : "1000 Jefferson Drive" } ;
355412 useSidebarStore . setState ( { activeSidebarId : PANEL . CATEGORY } ) ;
@@ -401,6 +458,59 @@ describe("MapContextMenu keyboard behavior", () => {
401458 } ) ;
402459 }
403460
461+ for ( const status of [ "navigating" , "rerouting" ] as const ) {
462+ it ( `suppresses keyboard opening and native behavior without mutating state while ${ status } ` , ( ) => {
463+ useNavigationStore . setState ( { status } ) ;
464+ useMapClickStore . setState ( { clickedLngLat : [ 1 , 2 ] } ) ;
465+ const selectedPlace = createPlace ( {
466+ primaryScheme : "test" ,
467+ ids : { test : "kept" } ,
468+ name : "Kept place" ,
469+ address : "Kept address" ,
470+ coordinates : [ 3 , 4 ] ,
471+ } ) ;
472+ usePlaceStore . setState ( { selectedPlace } ) ;
473+ useSidebarStore . setState ( { activeSidebarId : PANEL . CATEGORY } ) ;
474+ const waypointsBefore = useDirectionsStore . getState ( ) . waypoints ;
475+ render ( < MapContextMenu /> ) ;
476+ const event = new KeyboardEvent ( "keydown" , {
477+ key : "ContextMenu" ,
478+ bubbles : true ,
479+ cancelable : true ,
480+ } ) ;
481+
482+ fireEvent ( fake . state . canvas , event ) ;
483+
484+ expect ( event . defaultPrevented ) . toBe ( true ) ;
485+ expect ( screen . queryByRole ( "menu" ) ) . toBeNull ( ) ;
486+ expect ( useMapClickStore . getState ( ) . clickedLngLat ) . toEqual ( [ 1 , 2 ] ) ;
487+ expect ( usePlaceStore . getState ( ) . selectedPlace ) . toBe ( selectedPlace ) ;
488+ expect ( useDirectionsStore . getState ( ) . waypoints ) . toBe ( waypointsBefore ) ;
489+ expect ( useSidebarStore . getState ( ) . activeSidebarId ) . toBe ( PANEL . CATEGORY ) ;
490+ } ) ;
491+ }
492+
493+ it ( "defines visible theme-aware focus rings for both keyboard-focused route pills" , async ( ) => {
494+ render ( < MapContextMenu /> ) ;
495+ openAtMapPoint ( ) ;
496+ const from = screen . getByRole ( "menuitem" , { name : "mapContextMenu.fromHere" } ) ;
497+ const to = screen . getByRole ( "menuitem" , { name : "mapContextMenu.toHere" } ) ;
498+
499+ await userEvent . keyboard ( "{ArrowRight}" ) ;
500+ expect ( document . activeElement ) . toBe ( to ) ;
501+ const toStyle = focusVisibleStyle ( to ) ;
502+ expect ( toStyle ?. outline . startsWith ( "2px solid " ) ) . toBe ( true ) ;
503+ expect ( toStyle ?. outline . includes ( "transparent" ) ) . toBe ( false ) ;
504+ expect ( toStyle ?. outlineOffset ) . toBe ( "2px" ) ;
505+
506+ await userEvent . keyboard ( "{ArrowLeft}" ) ;
507+ expect ( document . activeElement ) . toBe ( from ) ;
508+ const fromStyle = focusVisibleStyle ( from ) ;
509+ expect ( fromStyle ?. outline . startsWith ( "2px solid " ) ) . toBe ( true ) ;
510+ expect ( fromStyle ?. outline . includes ( "transparent" ) ) . toBe ( false ) ;
511+ expect ( fromStyle ?. outlineOffset ) . toBe ( "2px" ) ;
512+ } ) ;
513+
404514 it ( "supports roving focus, submenu return, Escape order, and canvas restoration" , async ( ) => {
405515 render ( < MapContextMenu /> ) ;
406516 fireEvent . keyDown ( fake . state . canvas , { key : "ContextMenu" } ) ;
@@ -454,6 +564,19 @@ describe("MapContextMenu keyboard behavior", () => {
454564} ) ;
455565
456566describe ( "MapContextMenu copy behavior" , ( ) => {
567+ it ( "exposes copy submenu state on its trigger" , async ( ) => {
568+ render ( < MapContextMenu /> ) ;
569+ openAtMapPoint ( ) ;
570+ const copy = screen . getByRole ( "menuitem" , { name : "mapContextMenu.copyLocation" } ) ;
571+
572+ expect ( copy . getAttribute ( "aria-haspopup" ) ) . toBe ( "menu" ) ;
573+ expect ( copy . getAttribute ( "aria-expanded" ) ) . toBe ( "false" ) ;
574+
575+ await userEvent . click ( copy ) ;
576+
577+ expect ( copy . getAttribute ( "aria-expanded" ) ) . toBe ( "true" ) ;
578+ } ) ;
579+
457580 it ( "opens the copy submenu to the left when the right edge has insufficient room" , async ( ) => {
458581 render ( < MapContextMenu /> ) ;
459582 openAtMapPoint ( ) ;
@@ -622,6 +745,33 @@ describe("MapContextMenu share behavior", () => {
622745} ) ;
623746
624747describe ( "MapContextMenu dismissal and semantics" , ( ) => {
748+ it ( "does not steal focus when the map moves with no open context menu" , async ( ) => {
749+ render (
750+ < >
751+ < input aria-label = "Outside focus target" />
752+ < MapContextMenu />
753+ </ > ,
754+ ) ;
755+ const outside = screen . getByRole ( "textbox" , { name : "Outside focus target" } ) ;
756+ outside . focus ( ) ;
757+
758+ act ( ( ) => fake . emit ( "movestart" ) ) ;
759+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
760+
761+ expect ( document . activeElement ) . toBe ( outside ) ;
762+ } ) ;
763+
764+ it ( "restores canvas focus when a style reload dismisses an open menu" , async ( ) => {
765+ const view = render ( < MapContextMenu /> ) ;
766+ openAtMapPoint ( ) ;
767+
768+ mapContextTest . styleVersion += 1 ;
769+ view . rerender ( < MapContextMenu /> ) ;
770+
771+ expect ( screen . queryByRole ( "menu" ) ) . toBeNull ( ) ;
772+ await waitFor ( ( ) => expect ( document . activeElement ) . toBe ( fake . state . canvas ) ) ;
773+ } ) ;
774+
625775 it ( "dismisses on outside click, map movement, and style-version change" , async ( ) => {
626776 const view = render ( < MapContextMenu /> ) ;
627777 openAtMapPoint ( ) ;
0 commit comments