Перейти к содержанию

Карта и список: общий выбор и клавиатура

Оглавление серии «Карты для веба» · Урок 5 из 12.

Маркеры показывают расположение мест, но карта — не единственный способ прочитать каталог. В этом уроке добавим обычный список и свяжем его с картой через общий выбор. Пользователь сможет найти название в тексте, нажать кнопку и открыть соответствующую карточку. Нажатие на маркер, в свою очередь, отметит ту же запись в списке.

Продолжаем «Рядом» с теми же четырьмя вымышленными местами. Никакие координаты не становятся ключом выбора. Связь строится на feature.id: у списка и библиотечного маркера разные DOM-элементы, но одна идентичность в данных. Результат урока — два представления принятого набора, которые согласованно выбирают объект.

Что должно принадлежать приложению

Можно было бы спросить карту, какой popup открыт, и по его тексту искать строку списка. Такой путь привязывает смысл выбора к временному состоянию библиотеки. Popup может закрыться после движения камеры, название — поменяться, а два места — иметь одинаковую подпись. Поэтому выбранный идентификатор хранится отдельно в state.selected.

Состояние отвечает на вопрос «какое место выбрал человек», а не «какой элемент сейчас виден». Карта получает команду открыть соответствующий маркер. Список получает команду отметить соответствующую кнопку. Если позднее добавится панель подробностей, она тоже прочитает этот идентификатор, не пытаясь восстановить его из оформления маркера.

Такой подход помогает и при удалении данных. Когда выбранный объект исчезнет из принятой коллекции, нужно сбросить выбор. Сохранение ссылки на старый DOM-узел не обеспечит этого: узел может быть удалён, а состояние приложения — продолжать считать его текущим. При обновлениях работаем с идентификатором и проверяем его наличие в наборе.

Добавим семантический список

В пятой папке index.html содержит секцию с заголовком и ol#places. Это настоящий список, а не несколько визуально похожих блоков. Над рабочей областью находится ссылка «Перейти к списку мест». Она позволяет миновать карту при последовательном чтении страницы. Если вы меняете структуру, ссылка должна продолжать вести к существующему элементу.

Для выбора внутри каждой записи создаём button с типом button. Кнопка уже умеет участвовать в порядке фокуса и активироваться привычными клавишами. Не нужно изображать её через div и вручную добавлять десяток обработчиков клавиатуры. Если запись должна открывать самостоятельную страницу места, это была бы ссылка; здесь действие меняет выбор в текущем интерфейсе, поэтому используем кнопку.

Полный файл src/ui.js пятого состояния:

export function renderList(features, select, limit = 500, origin = null) {
  const fragment = document.createDocumentFragment();
  for (const feature of features.slice(0, limit)) {
    const item = document.createElement('li');
    const button = document.createElement('button');
    button.type = 'button';
    button.dataset.id = feature.id;
    button.setAttribute('aria-pressed', 'false');
    button.textContent = feature.properties.name;
    button.addEventListener('click', () => select(feature.id, true));
    item.append(button);
    fragment.append(item);
  }
  document.querySelector('#places').replaceChildren(fragment);
}
export function markSelection(id) {
  document.querySelectorAll('#places button').forEach(button => {
    button.setAttribute('aria-pressed', String(button.dataset.id === id));
  });
}

Данные снова попадают в интерфейс через textContent. В dataset.id кладём уже проверенный строковый идентификатор. Для назначения обработчика используем замыкание, которое помнит feature.id, а не порядковый номер. После сортировки или фильтрации новая строка с тем же местом будет вызывать ту же операцию выбора.

Fragment позволяет подготовить все элементы до единственной замены списка. Это не гарантия идеальной производительности любого объёма, но понятная граница обновления: пользователь не видит постепенно собранную половину результата. На данном шаге набор небольшой; ограничение списка и последствия его пересоздания рассмотрим отдельно, когда появятся фильтры и множество точек.

Одна операция для двух источников действия

В контроллере src/main.js определяем общий выбор:

function select(id, moveCamera) {
  state.selected = id;
  markSelection(id);
  if (map) selectMarker(id, moveCamera);
}

Кнопка списка передаёт true: нужно показать место на карте. Обработчик маркера передаёт false: человек уже нажал видимую точку, поэтому дополнительное движение не требуется. Признак отделяет источник пользовательского действия от идентичности места. Позднее некоторые действия могут только обновлять панель, не перемещая камеру вообще.

Функция markSelection выставляет aria-pressed для всех кнопок по сравнению с выбранным id. Она также даёт CSS состояние для видимой рамки. Стиль не должен быть единственным способом понять выбор: семантический признак сообщает его вспомогательной технологии. В данном простом интерфейсе кнопка обозначает выбор одного места; если вы преобразуете список в сложный составной виджет, понадобится заново определить клавиатурную модель и подходящие роли.

Не вызываем обработчик кнопки из обработчика маркера искусственным click(). Обе стороны обращаются к общей операции напрямую. Это уменьшает риск циклов вида «список выбрал карту, карта опять нажала список». Публичное состояние приложения обновляется один раз; отображения получают уже сформулированное решение.

Сохраним привычный фокус

Нажатие кнопки списка открывает popup, но фокус не обязан автоматически прыгать в карту. Пользователь может продолжить последовательно читать соседние названия. Сама карточка доступна как дополнительное представление; её сложная интерактивность не нужна для выбора названия. Если позднее внутри появится форма или обязательное действие, потребуется отдельная модель перехода в неё и возврата.

Для маркеров оставлены keyboard: true и подписи из названия. Это второй путь доступа, однако карта не становится полноценной заменой списка. Пользователь не должен вычислять пространственное положение каждого маленького значка, чтобы узнать, что есть в каталоге. В текстовом списке содержание можно просматривать обычными средствами браузера и вспомогательной технологии.

CSS сохраняет заметный :focus-visible и не удаляет outline. Цвет выбора отличается от цвета фокуса: выбранное место может оставаться отмеченным, пока человек уже перешёл к следующей кнопке. Эти состояния отвечают на разные вопросы и должны быть различимы. На узком экране секции становятся одной колонкой, но порядок документа остаётся понятным: список, затем карта.

Как обновление набора связано с выбором

Операция render берёт текущие записи, строит список и отображает маркеры. Перед этим она проверяет, остался ли state.selected среди результатов. Если место исчезло, выбор сбрасывается. Это правило пригодится в следующем уроке: фильтр может убрать объект из текущего набора, хотя он продолжает существовать в полном каталоге.

В этой серии выбор относится именно к видимому результату. Другой продукт может сохранять выбранную карточку вне фильтра, показывая пояснение «место не входит в результаты». Такое поведение тоже возможно, но его нужно определить явно. Мы выбрали более простой договор: скрытый результат не считается текущим выбором, а повторное появление не возвращает выбор автоматически.

Перестроение списка создаёт новые кнопки. Сейчас оно происходит при начальном показе, а выбор только меняет их признаки, поэтому фокус остаётся на прежнем элементе. В дальнейшем частые обновления смогут уничтожить сфокусированный узел; тогда понадобится сохранять фокус или обновлять элементы по идентификатору. Это отдельная задача интерфейса, которую нельзя решить одним наличием ARIA-атрибута.

Ожидаемый сценарий

После самостоятельного выполнения пятой папки в списке должны появиться четыре названия. Нажмите «Учебная библиотека „Строка“»: ожидается открытие её карточки и отметка соответствующей кнопки. Затем выберите другой маркер. Отметка должна перейти к другому месту, а в состоянии останется ровно один идентификатор. Переключение подложки не меняет выбранную запись.

Пройдите список клавишей Tab и активируйте одну из кнопок. Проверьте, что видимый фокус сохраняется и карта не делает взаимодействие обязательным для чтения названий. Мы ещё не выполняли эту проверку в учебном приложении; описанный сценарий задаёт ожидаемый результат для вашего ручного запуска. Следующим шагом общий набор результатов получит категории и текстовый фильтр.

Полный снимок этого шага находится в папке lesson-05 архива серии. При подготовке материала пример не запускался; описанные эффекты ожидаются по разобранному коду и требуют самостоятельной проверки в браузере.