Focus management

Фокус карты как часть DOM-модели

Элемент карты в Mapbox GL JS представляет собой canvas, встроенный в DOM-контейнер, который по умолчанию не участвует в системах фокусировки браузера. Для корректной работы клавиатурной навигации и доступности необходимо явно управлять тем, какие элементы могут получать фокус и как он перемещается между слоями интерфейса.

Ключевым моментом является то, что контейнер карты должен быть доступен для фокуса:

mapboxgl.accessToken = 'YOUR_TOKEN';

const map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/mapbox/streets-v12',
  center: [30.3, 59.9],
  zoom: 10
});

map.getCanvas().setAttribute('tabindex', '0');

Добавление tabindex="0" делает canvas фокусируемым, что позволяет пользователю активировать карту с клавиатуры через Tab.

Перехват и обработка клавиатурных событий

После получения фокуса карта начинает обрабатывать клавиатурные события. Mapbox GL JS предоставляет встроенные привязки:

  • стрелки — перемещение карты
  • + / - — масштабирование
  • Shift + стрелки — наклон и поворот (в зависимости от настроек)
  • Ctrl + Drag — альтернативное управление камерой

Дополнительно можно расширять поведение:

map.on('keydown', (e) => {
  if (e.originalEvent.key === 'r') {
    map.resetNorth();
  }
});

Важно учитывать, что любые пользовательские обработчики не должны блокировать стандартные действия без необходимости, иначе нарушается ожидаемая модель навигации.

Потеря и восстановление фокуса при взаимодействии с картой

При кликах вне карты фокус может уходить на другие элементы интерфейса. В сложных интерфейсах требуется контролировать возврат фокуса обратно на карту.

const mapContainer = map.getContainer();

document.addEventListener('click', (e) => {
  if (mapContainer.contains(e.target)) {
    map.getCanvas().focus();
  }
});

Такой подход обеспечивает предсказуемое поведение при повторной активации карты.

Управление фокусом в пользовательских контролах

Пользовательские элементы управления реализуются через интерфейс IControl. Каждый контрол добавляется в DOM и должен самостоятельно обеспечивать доступность.

class ZoomButtonControl {
  onAdd(map) {
    this._map = map;
    this._container = document.createElement('div');
    this._container.className = 'mapboxgl-ctrl';

    this._button = document.createElement('button');
    this._button.textContent = '+';

    this._button.addEventListener('click', () => {
      map.zoomIn();
    });

    this._container.appendChild(this._button);
    return this._container;
  }

  onRemove() {
    this._container.parentNode.removeChild(this._container);
    this._map = undefined;
  }
}

Для корректной работы клавиатуры необходимо явно задавать tabindex и обрабатывать keydown:

this._button.setAttribute('tabindex', '0');

this._button.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    map.zoomIn();
  }
});

Фокус в маркерах и всплывающих окнах

Маркер по умолчанию представляет собой DOM-элемент. Для обеспечения доступности он должен быть фокусируемым:

const el = document.createElement('div');
el.setAttribute('tabindex', '0');

el.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    popup.addTo(map);
  }
});

new mapboxgl.Marker(el)
  .setLngLat([30.3, 59.9])
  .addTo(map);

Popup-элементы требуют отдельного контроля фокуса. После открытия логично переместить фокус внутрь всплывающего окна:

const popup = new mapboxgl.Popup()
  .setHTML('<button id="close">Закрыть</button>')
  .setLngLat([30.3, 59.9])
  .addTo(map);

popup.getElement().querySelector('#close').focus();

Управление фокусом при наложении интерфейсов

При наличии модальных окон поверх карты необходимо предотвращать уход фокуса в подложку. Распространённый подход — блокировка взаимодействия с canvas через CSS:

.map-disabled {
  pointer-events: none;
}

И синхронизация состояния:

map.getContainer().classList.add('map-disabled');

Более строгий вариант включает управление inert:

map.getContainer().setAttribute('inert', '');

Roving tabindex для сложных интерфейсов поверх карты

При создании панелей инструментов поверх карты часто используется паттерн roving tabindex, при котором только один элемент в группе доступен для Tab.

const items = document.querySelectorAll('.toolbar button');

let current = 0;

items.forEach((item, index) => {
  item.setAttribute('tabindex', index === 0 ? '0' : '-1');

  item.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowRight') {
      items[current].setAttribute('tabindex', '-1');
      current = (current + 1) % items.length;
      items[current].setAttribute('tabindex', '0');
      items[current].focus();
    }
  });
});

Этот подход предотвращает перегрузку клавиатурной навигации и упрощает перемещение по контролам.

Синхронизация фокуса и состояния карты

Фокус часто используется как индикатор активного состояния карты. Например, изменение стиля при активном canvas:

map.getCanvas().addEventListener('focus', () => {
  map.getContainer().classList.add('focused');
});

map.getCanvas().addEventListener('blur', () => {
  map.getContainer().classList.remove('focused');
});

Это позволяет визуально отделить активное состояние от неактивного, особенно при работе с несколькими интерактивными областями.

Перехват событий для предотвращения потери контекста

В сложных интерфейсах с панелями и всплывающими окнами важно предотвращать потерю контекста при взаимодействии с картой:

map.on('click', () => {
  map.getCanvas().focus();
});

Однако избыточное принудительное возвращение фокуса может ломать пользовательские сценарии, поэтому подобные решения должны быть ограничены условиями состояния приложения.

Интеграция с внешними UI-фреймворками

При использовании React или Vue фокус управляется через жизненный цикл компонентов. Основная задача — избегать конфликтов между виртуальным DOM и реальным DOM карты.

useEffect(() => {
  if (mapRef.current) {
    mapRef.current.getCanvas().setAttribute('tabindex', '0');
  }
}, []);

Также важно не пересоздавать DOM-узлы контролов без необходимости, иначе фокус будет теряться.

Поведение при смене стиля карты

При вызове setStyle DOM карта может частично пересоздаваться, что приводит к сбросу фокуса:

map.setStyle('mapbox://styles/mapbox/light-v11');

map.once('styledata', () => {
  map.getCanvas().setAttribute('tabindex', '0');
  map.getCanvas().focus();
});

Сохранение фокуса в таких случаях требует повторной инициализации атрибутов доступности.

Обработка конфликтов фокуса между слоями интерфейса

При наличии боковых панелей, модальных окон и карты часто возникает конкуренция за фокус. Стратегия заключается в явном определении активного слоя:

let activeLayer = 'map';

function setActiveLayer(layer) {
  activeLayer = layer;

  if (layer === 'map') {
    map.getCanvas().focus();
  }
}

Такой подход обеспечивает централизованное управление состоянием фокуса и предотвращает его «утечку» между компонентами интерфейса.