Фокус и табуляция

В интерфейсных компонентах на основе нативного <select> поведение фокуса определяется браузером, но при замене элемента на кастомный компонент управление фокусом полностью переходит к JavaScript. В Slim Select оригинальный <select> скрывается, а вместо него создаётся DOM-структура, имитирующая поведение выпадающего списка. Это приводит к необходимости отдельно учитывать:

  • перенос фокуса между оригинальным элементом и кастомным UI;
  • управление tabindex у контейнеров;
  • синхронизацию клавиатурных событий с состоянием dropdown;
  • корректное поведение при открытии и закрытии списка.

Фокусная модель строится вокруг двух ключевых элементов: скрытого <select> и визуального контейнера Slim Select.


Инициализация и первичное поведение фокуса

При инициализации экземпляра создаётся обёртка:

const select = document.querySelector('#mySelect');

const slim = new SlimSelect({
  select: '#mySelect'
});

После инициализации:

  • исходный <select> получает display: none или аналогичное скрытие;
  • создаётся контейнер .ss-main;
  • кликабельный элемент получает возможность принимать фокус.

Фокус по умолчанию остаётся на скрытом <select>, но пользователь взаимодействует уже с .ss-main.


Tabindex и участие в табуляции

Ключевая проблема кастомных селектов — участие в последовательности Tab.

В Slim Select используется стратегия:

  • контейнер .ss-main получает tabindex="0";
  • скрытый <select> исключается из таб-цепочки (tabindex="-1" или display:none);
  • фокус полностью перехватывается кастомным компонентом.

Пример итоговой структуры:

<div class="ss-main" tabindex="0">
  ...
</div>
<select id="mySelect" tabindex="-1"></select>

Поведение Tab

  • Tab → перенос фокуса на .ss-main
  • Shift + Tab → возврат к предыдущему элементу формы
  • повторный Tab → переход дальше по форме

Важно, что внутренние элементы dropdown не участвуют в Tab-последовательности.


Открытие dropdown через фокус

Фокус на .ss-main сам по себе не обязан открывать список. Поведение зависит от конфигурации и реализации событий:

  • focus → только активация компонента;
  • keydown (Enter/Space/ArrowDown) → открытие списка;
  • click → открытие списка и перенос фокуса внутрь UI.

Типовой сценарий:

document.querySelector('.ss-main').addEventListener('focus', () => {
  // визуальная активация
});

Инициация открытия чаще привязана к клавиатуре:

document.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowDown') {
    slim.open();
  }
});

Перемещение фокуса внутри dropdown

После открытия списка фокус не переносится на каждый элемент списка как на отдельный tab-stop. Вместо этого используется:

  • логический фокус (active item);
  • навигация стрелками;
  • один активный элемент с aria-selected.

Структура поведения:

  • ArrowDown → следующий элемент;
  • ArrowUp → предыдущий;
  • Enter → выбор текущего;
  • Escape → закрытие и возврат фокуса на .ss-main.

Пример обработчика:

document.addEventListener('keydown', (e) => {
  switch (e.key) {
    case 'ArrowDown':
      slim.moveDown();
      break;
    case 'ArrowUp':
      slim.moveUp();
      break;
    case 'Enter':
      slim.selectHighlighted();
      break;
    case 'Escape':
      slim.close();
      document.querySelector('.ss-main').focus();
      break;
  }
});

Возврат фокуса после выбора

После выбора значения поведение фокуса критично для форм:

  • dropdown закрывается;
  • фокус возвращается на .ss-main;
  • происходит обновление отображаемого значения.

Это предотвращает «потерю фокуса» при последовательной навигации по форме.

Типовой сценарий:

slim.setSelected('value');

document.querySelector('.ss-main').focus();

Управление blur-событиями

Blur в кастомных селектах часто вызывает проблемы, если не учитывать структуру DOM.

В Slim Select blur может происходить:

  • при клике вне компонента;
  • при переходе на следующий элемент формы;
  • при программном изменении состояния.

Рекомендуемая логика:

  • blur на .ss-main не всегда означает закрытие dropdown;
  • закрытие должно зависеть от клика вне контейнера.

Пример:

document.addEventListener('click', (e) => {
  if (!e.target.closest('.ss-main')) {
    slim.close();
  }
});

Tab внутри формы с несколькими Slim Select

При наличии нескольких селектов важно сохранять линейную табуляцию:

<input type="text">
<select id="a"></select>
<select id="b"></select>
<input type="text">

Поведение:

  1. Input → Tab
  2. .ss-main (a) → Tab
  3. .ss-main (b) → Tab
  4. Input

Каждый .ss-main становится единым таб-узлом.

Особенность: внутренние элементы dropdown никогда не становятся частью tab order, что предотвращает «застревание» внутри списка.


Программное управление фокусом

В ряде сценариев требуется ручное управление фокусом:

Установка фокуса на компонент

document.querySelector('#mySelect + .ss-main').focus();

Открытие с фокусом

slim.open();
document.querySelector('.ss-main').focus();

Перевод фокуса при валидации

if (!value) {
  const el = document.querySelector('.ss-main');
  el.classList.add('error');
  el.focus();
}

Клавиатурные конфликты и их обработка

В контексте фокуса возникают типовые конфликты:

Scroll страницы вместо навигации

При фокусе на .ss-main клавиши стрелок могут прокручивать страницу. Решение:

document.addEventListener('keydown', (e) => {
  if (document.activeElement.classList.contains('ss-main')) {
    if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
      e.preventDefault();
    }
  }
});

Enter в форме

Enter может вызывать submit формы вместо выбора элемента:

document.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && slim.isOpen()) {
    e.preventDefault();
    slim.selectHighlighted();
  }
});

Поведение при disabled состоянии

Когда селект отключён:

  • .ss-main не должен получать фокус;
  • tabindex удаляется или устанавливается в -1;
  • события клавиатуры игнорируются.

Пример:

slim.disable();

const el = document.querySelector('.ss-main');
el.setAttribute('tabindex', '-1');

Особенности accessibility-фокуса

Фокус в Slim Select тесно связан с ARIA-состояниями:

  • aria-expanded → состояние dropdown;
  • aria-activedescendant → активный элемент;
  • aria-selected → выбранные значения.

Фокус всегда должен коррелировать с aria-expanded:

  • focus + open → aria-expanded="true"
  • blur + close → aria-expanded="false"

Типичные проблемы фокуса

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

При обновлении данных:

  • DOM пересоздаётся;
  • .ss-main теряет tabindex;
  • фокус сбрасывается.

Решение:

slim.setData(newData);
document.querySelector('.ss-main').focus();

Двойное открытие dropdown

Возникает при одновременной обработке click и focus:

el.addEventListener('focus', slim.open);
el.addEventListener('click', slim.open);

Решение — централизовать открытие:

el.addEventListener('click', () => {
  if (!slim.isOpen()) slim.open();
});

Невозможность выхода Tab

Появляется, если внутренние обработчики блокируют blur. Решение:

  • не перехватывать Tab;
  • не вызывать preventDefault() без условия.