Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 140 additions & 13 deletions src/blocks/map/frontend/static-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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' );
Expand All @@ -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 } } )
Expand All @@ -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 );
} );
Expand Down
Loading