55 */
66import projectPoint from '../project-point' ;
77
8+ /**
9+ * Elements inside a popup that can take focus.
10+ */
11+ const FOCUSABLE_SELECTOR = [
12+ 'a[href]' ,
13+ 'button:not([disabled])' ,
14+ 'input:not([disabled])' ,
15+ 'select:not([disabled])' ,
16+ 'textarea:not([disabled])' ,
17+ '[tabindex]:not([tabindex="-1"])' ,
18+ ] . join ( ',' ) ;
19+
20+ /**
21+ * Distinguishes popups across every map block on the page, so a pin's
22+ * aria-controls always points at its own popup.
23+ */
24+ let popupCount = 0 ;
25+
26+ /**
27+ * Build the accessible name for a pin.
28+ *
29+ * The pin's visible text is at most its number, which tells a screen reader
30+ * user nothing about where it points. Prefer the marker's heading, and keep
31+ * the number in front of it so the accessible name still contains the visible
32+ * label (WCAG 2.5.3).
33+ *
34+ * @param {HTMLElement } marker Marker block element.
35+ * @param {number } index Zero-based marker index.
36+ * @param {boolean } numberedPins Whether pins display their marker number.
37+ * @return {string } Accessible name, empty if the marker has no usable text.
38+ */
39+ function getPinLabel ( marker , index , numberedPins ) {
40+ const title =
41+ marker . querySelector ( 'h1, h2, h3, h4, h5, h6' ) ?. textContent ?. trim ( ) ||
42+ marker . querySelector ( 'a' ) ?. textContent ?. trim ( ) ||
43+ '' ;
44+
45+ if ( ! numberedPins ) {
46+ return title ;
47+ }
48+
49+ return title ? `${ index + 1 } . ${ title } ` : `${ index + 1 } ` ;
50+ }
51+
852/**
953 * Initialize a static map instance.
1054 *
@@ -29,18 +73,45 @@ export default function initStaticMap( {
2973 const projection = mapDiv . dataset . projection || 'equirectangular' ;
3074
3175 let popup = null ;
76+ let activePin = null ;
77+
78+ /**
79+ * Close the open popup, if any.
80+ *
81+ * @param {Object } [options] Options.
82+ * @param {boolean } [options.restoreFocus] Return focus to the pin that opened it.
83+ */
84+ const closePopup = ( { restoreFocus = false } = { } ) => {
85+ const pin = activePin ;
3286
33- const closePopup = ( ) => {
3487 if ( popup ) {
3588 popup . remove ( ) ;
36- popup = null ;
3789 }
38- Array . from ( mapDiv . querySelectorAll ( '.marker' ) ) . forEach ( ( pin ) =>
39- pin . classList . remove ( 'active' )
90+ popup = null ;
91+ activePin = null ;
92+
93+ Array . from ( mapDiv . querySelectorAll ( '.marker' ) ) . forEach (
94+ ( pinEl ) => {
95+ pinEl . classList . remove ( 'active' ) ;
96+ pinEl . setAttribute ( 'aria-expanded' , 'false' ) ;
97+ pinEl . removeAttribute ( 'aria-controls' ) ;
98+ }
4099 ) ;
100+
101+ if ( restoreFocus && pin ) {
102+ pin . focus ( ) ;
103+ }
41104 } ;
42105
43- const openPopup = ( marker , pinEl , index ) => {
106+ /**
107+ * Open the popup for a marker.
108+ *
109+ * @param {HTMLElement } marker Marker block element.
110+ * @param {HTMLElement } pinEl Pin button for that marker.
111+ * @param {number } index Zero-based marker index.
112+ * @param {boolean } viaKeyboard Whether the pin was activated from the keyboard.
113+ */
114+ const openPopup = ( marker , pinEl , index , viaKeyboard ) => {
44115 closePopup ( ) ;
45116
46117 const content = document . createElement ( 'div' ) ;
@@ -51,20 +122,64 @@ export default function initStaticMap( {
51122 )
52123 ) ;
53124
54- popup = document . createElement ( 'div' ) ;
55- popup . className = 'hm-map-popup hm-map-popup--static' ;
56- popup . style . left = pinEl . style . left ;
57- popup . style . top = pinEl . style . top ;
125+ popupCount += 1 ;
126+
127+ const popupEl = document . createElement ( 'div' ) ;
128+ popupEl . className = 'hm-map-popup hm-map-popup--static' ;
129+ popupEl . id = `hm-map-popup-${ popupCount } ` ;
130+ popupEl . style . left = pinEl . style . left ;
131+ popupEl . style . top = pinEl . style . top ;
58132
59133 const closeButton = document . createElement ( 'button' ) ;
60134 closeButton . className = 'hm-map-popup__close' ;
61135 closeButton . innerHTML = '×' ;
62136 closeButton . setAttribute ( 'aria-label' , 'Close popup' ) ;
63- closeButton . addEventListener ( 'click' , closePopup ) ;
137+ closeButton . addEventListener ( 'click' , ( ) =>
138+ closePopup ( { restoreFocus : true } )
139+ ) ;
140+
141+ // Escape dismisses the popup and hands focus back to its pin.
142+ popupEl . addEventListener ( 'keydown' , ( event ) => {
143+ if ( event . key === 'Escape' ) {
144+ event . stopPropagation ( ) ;
145+ closePopup ( { restoreFocus : true } ) ;
146+ }
147+ } ) ;
148+
149+ // Moving focus out of the popup dismisses it. Focus has already landed
150+ // on its new target by this point, so leave it there rather than
151+ // restoring: because the popup sits directly after its pin in the DOM,
152+ // Tab lands on the next pin and Shift+Tab on the pin that opened it.
153+ // A null relatedTarget means focus was dropped rather than moved (a
154+ // click on the card's own non-interactive text, say), which should not
155+ // close anything.
156+ popupEl . addEventListener ( 'focusout' , ( event ) => {
157+ if (
158+ ! event . relatedTarget ||
159+ popupEl . contains ( event . relatedTarget )
160+ ) {
161+ return ;
162+ }
163+ closePopup ( ) ;
164+ } ) ;
165+
166+ popupEl . append ( closeButton , content ) ;
167+
168+ // Insert after the pin, not at the end of the canvas, so the tab order
169+ // runs pin -> popup -> next pin. The popup's own z-index keeps it above
170+ // the pins that now follow it.
171+ pinEl . after ( popupEl ) ;
172+
173+ popup = popupEl ;
174+ activePin = pinEl ;
64175
65- popup . append ( closeButton , content ) ;
66- mapDiv . append ( popup ) ;
67176 pinEl . classList . add ( 'active' ) ;
177+ pinEl . setAttribute ( 'aria-expanded' , 'true' ) ;
178+ pinEl . setAttribute ( 'aria-controls' , popupEl . id ) ;
179+
180+ if ( viaKeyboard ) {
181+ popupEl . querySelector ( FOCUSABLE_SELECTOR ) ?. focus ( ) ;
182+ }
68183
69184 wrapper . dispatchEvent (
70185 new CustomEvent ( 'hm-map:pin-activate' , { detail : { index } } )
@@ -90,7 +205,19 @@ export default function initStaticMap( {
90205 pin . style . left = `${ point . x } %` ;
91206 pin . style . top = `${ point . y } %` ;
92207 pin . innerHTML = numberedPins ? `${ index + 1 } ` : '' ;
93- pin . addEventListener ( 'click' , ( ) => openPopup ( marker , pin , index ) ) ;
208+ pin . setAttribute ( 'type' , 'button' ) ;
209+ pin . setAttribute ( 'aria-expanded' , 'false' ) ;
210+
211+ const label = getPinLabel ( marker , index , numberedPins ) ;
212+ if ( label ) {
213+ pin . setAttribute ( 'aria-label' , label ) ;
214+ }
215+
216+ // A keyboard-triggered click carries no click count, which is how the
217+ // popup knows whether to pull focus in or leave it on the pin.
218+ pin . addEventListener ( 'click' , ( event ) =>
219+ openPopup ( marker , pin , index , event . detail === 0 )
220+ ) ;
94221
95222 mapDiv . append ( pin ) ;
96223 } ) ;
0 commit comments