PageSourceSearch

https://bikeatthebeach.com/js/map-view/drawing/marker-a11y.js

js bikeatthebeach.com collected 2026-10-02 00:30:24 UTC 5,978 bytes, 150 lines download raw bytes

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.