1/* ===================================================================== 2 js/map-view/drawing/marker-a11y.js 3 Keyboard accessibility and ARIA attributes for map pin markers. 4 Extracted from layers.js to keep that file under the 500-line guidance. 5 ===================================================================== */ 6 7import { accessibleFeatureName } from '../a11y/feature-name.js'; 8import { featureClickEnabled } from '../feature-click-policy.js'; 9 10/* Whether markers sit in the sequential tab order. 11 * 12 * They used to, and on a map with 300 points that put 300 stops between 13 * the skip link and the zoom control â reaching an ordinary map control 14 * meant tabbing past every feature on the map. The features drawer 15 * (a11y/feature-list.js) is the better route: it is searchable, it 16 * covers lines and areas too, and it is one stop away. 17 * 18 * So the drawer takes them out when it mounts. This stays true until it 19 * does, which means a page where the drawer fails to load keeps the old 20 * behavior rather than losing keyboard access altogether. Markers keep 21 * role="button", their name, and Enter/Space either way: tabindex="-1" 22 * is still reachable by a screen reader's virtual cursor and still 23 * focusable programmatically, which is how "Show on map" hands focus to 24 * one. 25 */ 26let markersTabbable = true; 27 28/** 29 * Set whether markers participate in sequential focus, and apply the 30 * change to markers that already exist. 31 * 32 * @param {boolean} tabbable 33 */ 34export function setMarkerTabOrder(tabbable) { 35 markersTabbable = !!tabbable; 36 document.querySelectorAll('.mapboxgl-marker[role="button"]').forEach(el => { 37 if (el.dataset.wmTouchMarker === 'true') return; // never had a tabIndex 38 el.tabIndex = markersTabbable ? 0 : -1; 39 }); 40} 41 42/** Current setting â exported for tests and for the drawer's own checks. */ 43export function markersAreTabbable() { 44 return markersTabbable; 45} 46 47/** 48 * Apply keyboard accessibility and ARIA attributes to a pin marker element.
49 * Makes markers focusable, gives them a descriptive label, and wires 50 * Enter/Space to trigger the same action as a mouse click. 51 * 52 * @param {HTMLElement} el - The marker DOM element (from marker.getElement()) 53 * @param {object} pin - Pin data: { feature_id, category_id } 54 * @param {Function} onActivate - Callback when marker is activated via keyboard 55 */ 56export function applyMarkerA11y(el, pin, onActivate) { 57 if (!el) return; 58 59 // ARIA attributes â safe on all platforms, no touch side-effects. 60 // 61 // A marker only claims to be a button when activating it does 62 // something. With settings.disable_feature_click the pin is a symbol 63 // on a map and nothing more, and announcing "button" for a control 64 // that never responds is worse than announcing nothing: it invites a 65 // reader to press it and reports no result. It keeps its name either 66 // way, so the feature is still identifiable. 67 const interactive = featureClickEnabled(); 68 el.setAttribute('role', interactive ? 'button' : 'img'); 69 el.setAttribute('aria-label', buildMarkerLabel(pin)); 70 if (!interactive) { 71 el.style.cursor = 'default'; 72 el.tabIndex = -1; 73 return; // no keyboard, touch, or focus wiring below 74 } 75 76 // tabIndex makes the element focusable for keyboard navigation. 77 // On iOS Safari, tabIndex=0 on non-native elements triggers a 78 // "focus-first, activate-second" double-tap pattern that breaks 79 // single-tap activation. We only set tabIndex when a keyboard 80 // (not just touch) is likely available. 81 const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; 82 if (isTouch) { 83 el.dataset.wmTouchMarker = 'true'; 84 } else { 85 el.tabIndex = markersTabbable ? 0 : -1; 86 } 87 88 // Keyboard activation: Enter and Space trigger the same action as click 89 el.addEventListener('keydown', (e) => { 90 if (e.key === 'Enter' || e.key === ' ') { 91 e.preventDefault(); 92 e.stopPropagation(); 93 onActivate(); 94 } 95 }); 96 97 // Touch-tap handler: activate on first tap without waiting for 98 // the iOS focusâclick cycle. Uses the same short-tap detection 99 // pattern as map-click-handler.js (touchstartâtouchend, max 300ms 100 // and 10px movement). 101 if (isTouch) { 102 let touchStart = { time: 0, x: 0, y: 0 }; 103 104 el.addEventListener('touchstart', (e) => { 105 if (e.touches.length !== 1) return; 106 touchStart.time = Date.now(); 107 touchStart.x = e.touches[0].clientX; 108 touchStart.y = e.touches[0].clientY; 109 }, { passive: true }); 110 111 el.addEventListener('touchend', (e) => { 112 if (e.changedTouches.length !== 1) return; 113 const dt = Date.now() - touchStart.time; 114 if (dt > 300) return; // not a tap 115 const dx = e.changedTouches[0].clientX - touchStart.x; 116 const dy = e.changedTouches[0].clientY - touchStart.y; 117 if (Math.sqrt(dx * dx + dy * dy) > 10) return; // was a drag 118 e.preventDefault(); // prevent delayed click / focus 119 onActivate(); 120 }); 121 } 122 123 // Focus ring via inline style â Mapbox GL's cross-origin CSS overrides 124 // stylesheet :focus rules, so we apply the outline directly on focus/blur. 125 el.addEventListener('focus', () => { 126 el.style.outline = '2px solid #ffc107'; 127 el.style.outlineOffset = '2px'; 128 }); 129 el.addEventListener('blur', () => { 130 el.style.outline = ''; 131 el.style.outlineOffset = ''; 132 }); 133} 134 135/** 136 * The marker's accessible name. 137 * 138 * This used to build its own label and produced "Map feature â 139 * Bike Infrastructure" for every feature in a category: a name that 140 * identifies the layer rather than the thing. It now reads the shared 141 * index (a11y/feature-name.js), which resolves title, then description, 142 * then address, then category, and disambiguates duplicates â the same 143 * string the features drawer shows for the same feature. 144 * 145 * @param {object} pin - { feature_id, category_id } 146 * @returns {string} 147 */ 148function buildMarkerLabel(pin) { 149 return accessibleFeatureName(pin.feature_id); 150}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.