Skip to content

Commit 1c00b62

Browse files
authored
Merge pull request #1 from humanmade/popup-focus-management
Make static map keyboard-navigable
2 parents abe034d + a09ccb8 commit 1c00b62

1 file changed

Lines changed: 140 additions & 13 deletions

File tree

‎src/blocks/map/frontend/static-map.js‎

Lines changed: 140 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,50 @@
55
*/
66
import 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

Comments
 (0)