From a09ccb8f13b9581a740f54c5a6d0653ac726006b Mon Sep 17 00:00:00 2001 From: goldenapples Date: Thu, 6 Aug 2026 09:32:19 -0400 Subject: [PATCH] Make static map keyboard-navigable Adds focus management controls for keyboard navigation of the static map: when opening a popup via the keyboard, the keyboard focus is now transferred to the first focusable element inside the tooltip, and when focus leaves the tooltip, it is closed. Additionally, adds some logic attempting to populate a meaningful aria-label for each button based on the text inside the tooltip. --- src/blocks/map/frontend/static-map.js | 153 +++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 13 deletions(-) diff --git a/src/blocks/map/frontend/static-map.js b/src/blocks/map/frontend/static-map.js index 5f07aa9..725df93 100644 --- a/src/blocks/map/frontend/static-map.js +++ b/src/blocks/map/frontend/static-map.js @@ -5,6 +5,50 @@ */ import projectPoint from '../project-point'; +/** + * Elements inside a popup that can take focus. + */ +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join( ',' ); + +/** + * Distinguishes popups across every map block on the page, so a pin's + * aria-controls always points at its own popup. + */ +let popupCount = 0; + +/** + * Build the accessible name for a pin. + * + * The pin's visible text is at most its number, which tells a screen reader + * user nothing about where it points. Prefer the marker's heading, and keep + * the number in front of it so the accessible name still contains the visible + * label (WCAG 2.5.3). + * + * @param {HTMLElement} marker Marker block element. + * @param {number} index Zero-based marker index. + * @param {boolean} numberedPins Whether pins display their marker number. + * @return {string} Accessible name, empty if the marker has no usable text. + */ +function getPinLabel( marker, index, numberedPins ) { + const title = + marker.querySelector( 'h1, h2, h3, h4, h5, h6' )?.textContent?.trim() || + marker.querySelector( 'a' )?.textContent?.trim() || + ''; + + if ( ! numberedPins ) { + return title; + } + + return title ? `${ index + 1 }. ${ title }` : `${ index + 1 }`; +} + /** * Initialize a static map instance. * @@ -29,18 +73,45 @@ export default function initStaticMap( { const projection = mapDiv.dataset.projection || 'equirectangular'; let popup = null; + let activePin = null; + + /** + * Close the open popup, if any. + * + * @param {Object} [options] Options. + * @param {boolean} [options.restoreFocus] Return focus to the pin that opened it. + */ + const closePopup = ( { restoreFocus = false } = {} ) => { + const pin = activePin; - const closePopup = () => { if ( popup ) { popup.remove(); - popup = null; } - Array.from( mapDiv.querySelectorAll( '.marker' ) ).forEach( ( pin ) => - pin.classList.remove( 'active' ) + popup = null; + activePin = null; + + Array.from( mapDiv.querySelectorAll( '.marker' ) ).forEach( + ( pinEl ) => { + pinEl.classList.remove( 'active' ); + pinEl.setAttribute( 'aria-expanded', 'false' ); + pinEl.removeAttribute( 'aria-controls' ); + } ); + + if ( restoreFocus && pin ) { + pin.focus(); + } }; - const openPopup = ( marker, pinEl, index ) => { + /** + * Open the popup for a marker. + * + * @param {HTMLElement} marker Marker block element. + * @param {HTMLElement} pinEl Pin button for that marker. + * @param {number} index Zero-based marker index. + * @param {boolean} viaKeyboard Whether the pin was activated from the keyboard. + */ + const openPopup = ( marker, pinEl, index, viaKeyboard ) => { closePopup(); const content = document.createElement( 'div' ); @@ -51,20 +122,64 @@ export default function initStaticMap( { ) ); - popup = document.createElement( 'div' ); - popup.className = 'hm-map-popup hm-map-popup--static'; - popup.style.left = pinEl.style.left; - popup.style.top = pinEl.style.top; + popupCount += 1; + + const popupEl = document.createElement( 'div' ); + popupEl.className = 'hm-map-popup hm-map-popup--static'; + popupEl.id = `hm-map-popup-${ popupCount }`; + popupEl.style.left = pinEl.style.left; + popupEl.style.top = pinEl.style.top; const closeButton = document.createElement( 'button' ); closeButton.className = 'hm-map-popup__close'; closeButton.innerHTML = '×'; closeButton.setAttribute( 'aria-label', 'Close popup' ); - closeButton.addEventListener( 'click', closePopup ); + closeButton.addEventListener( 'click', () => + closePopup( { restoreFocus: true } ) + ); + + // Escape dismisses the popup and hands focus back to its pin. + popupEl.addEventListener( 'keydown', ( event ) => { + if ( event.key === 'Escape' ) { + event.stopPropagation(); + closePopup( { restoreFocus: true } ); + } + } ); + + // Moving focus out of the popup dismisses it. Focus has already landed + // on its new target by this point, so leave it there rather than + // restoring: because the popup sits directly after its pin in the DOM, + // Tab lands on the next pin and Shift+Tab on the pin that opened it. + // A null relatedTarget means focus was dropped rather than moved (a + // click on the card's own non-interactive text, say), which should not + // close anything. + popupEl.addEventListener( 'focusout', ( event ) => { + if ( + ! event.relatedTarget || + popupEl.contains( event.relatedTarget ) + ) { + return; + } + closePopup(); + } ); + + popupEl.append( closeButton, content ); + + // Insert after the pin, not at the end of the canvas, so the tab order + // runs pin -> popup -> next pin. The popup's own z-index keeps it above + // the pins that now follow it. + pinEl.after( popupEl ); + + popup = popupEl; + activePin = pinEl; - popup.append( closeButton, content ); - mapDiv.append( popup ); pinEl.classList.add( 'active' ); + pinEl.setAttribute( 'aria-expanded', 'true' ); + pinEl.setAttribute( 'aria-controls', popupEl.id ); + + if ( viaKeyboard ) { + popupEl.querySelector( FOCUSABLE_SELECTOR )?.focus(); + } wrapper.dispatchEvent( new CustomEvent( 'hm-map:pin-activate', { detail: { index } } ) @@ -90,7 +205,19 @@ export default function initStaticMap( { pin.style.left = `${ point.x }%`; pin.style.top = `${ point.y }%`; pin.innerHTML = numberedPins ? `${ index + 1 }` : ''; - pin.addEventListener( 'click', () => openPopup( marker, pin, index ) ); + pin.setAttribute( 'type', 'button' ); + pin.setAttribute( 'aria-expanded', 'false' ); + + const label = getPinLabel( marker, index, numberedPins ); + if ( label ) { + pin.setAttribute( 'aria-label', label ); + } + + // A keyboard-triggered click carries no click count, which is how the + // popup knows whether to pull focus in or leave it on the pin. + pin.addEventListener( 'click', ( event ) => + openPopup( marker, pin, index, event.detail === 0 ) + ); mapDiv.append( pin ); } );