open atlas
↑ К треку
React с нуля до senior RCT · 10 · 02

Управление фокусом: фокус — состояние приложения, и владеете им вы

SPA отвязывает фокус от навигации: смена роута молчит, пока вы не сфокусируете заголовок (tabIndex -1); диалоги ловят фокус, делают фон inert и возвращают фокус триггеру; flushSync лечит гонку фокуса после рендера; удаление сфокусированного узла выбрасывает пользователя на body.

RCT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Консультант по доступности на стороне enterprise-клиента завёл баг из одной строки, заморозивший продление контракта на шестизначную сумму: «После Continue NVDA замолкает». Онбординг был SPA-визардом (одностраничное приложение) из пяти шагов. Для зрячего пользователя клик по Continue менял роут, и въезжала следующая форма. Для пользователя скринридера тот же клик демонтировал кнопку у него под фокусом, браузер тихо ронял фокус на body, и ничего не объявлялось — приложение звучало как мёртвое. Кто-то нажимал Enter ещё раз и отправлял форму дважды; один тикет в поддержку описывал повторную табуляцию с верха страницы через сорок одну остановку, чтобы обнаружить, что шаг два, оказывается, отрендерился. Вторая находка того же аудита была злее: удаление получателя из таблицы. Кнопка Delete жила внутри строки, которую уничтожала, так что нажатие удаляло сам сфокусированный элемент — фокус падал на body, виртуальный курсор выбрасывало на верх документа, и клавиатурный пользователь, вычищавший получателя 38 из 40, телепортировался в шапку после каждого удаления. Ни один из багов не экзотика; оба — физика SPA по умолчанию. Браузеры управляют фокусом между полными загрузками страниц: новый документ, сброс фокуса, объявление заголовка. В день, когда вы взяли клиентский роутинг, вы молча взяли на себя эту работу, и «мы ничего не делали» компилируется в «фокус оказывается там, где его бросил DOM-дифф».

Смена роута никуда не переносит фокус

При полной загрузке страницы браузер делает три работы по доступности бесплатно: сбрасывает фокус на верх нового документа, объявляет заголовок документа и начинает порядок чтения с известного места. Клиентская смена роута не делает ни одной. React меняет поддерево; фокус либо остаётся на том, что пережило дифф (ссылка в навигации, далёкая от нового контента), либо — если ранее сфокусированный элемент демонтирован — падает на body, скринридер молчит, и следующий Tab пользователя стартует оттуда, где похоронили покойника. Стандартное лечение — считать навигацию событием фокуса: после коммита нового вью сфокусировать его h1, которому задан tabIndex={-1}, и обновить document.title. Это -1 существенно: оно делает заголовок фокусируемым программно, не вставляя его в Tab-порядок, — иначе клавиатурные пользователи навсегда получили бы лишнюю остановку на неинтерактивном элементе. Фокус на заголовке отрабатывает дважды: скринридер его объявляет (контекст: «Детали платежа, заголовок первого уровня»), и последовательный фокус теперь продолжается с верха нового контента, а не со старой позиции в DOM.

function RouteHeading({ children }) {
  const ref = useRef(null);
  useEffect(() => {
    // при монтировании нового роута: объявить + переставить фокус
    ref.current?.focus();
  }, []);
  return <h1 tabIndex={-1} ref={ref}>{children}</h1>;
}

Альтернатива — live-region-объявлятель маршрутов (элемент с aria-live="polite", которому вы записываете имя новой страницы): он объявляет, не трогая фокус, что подходит для случаев, где перенос фокуса был бы разрушительным, но он не чинит вторую половину проблемы — «следующий Tab стартует из ниоткуда». Продакшен-роутеры и фреймворки поставляют варианты обоих приёмов; ваша зона ответственности — выбор целевого фокуса по типу навигации: шагу визарда нужен заголовок, обновлению фильтра внутри страницы — обычно нетронутый фокус плюс вежливое объявление количества результатов.

Викторина

После клиентской навигации в вашем SPA пользователь скринридера ничего не слышит, а следующий Tab попадает на баннер cookie в верху документа. Что произошло механически?

Фокусный контракт диалога

Модальный диалог — это фокусный контракт из четырёх пунктов, и APG их проговаривает. При открытии: перенести фокус внутрь — на первый осмысленный фокусируемый элемент или на сам контейнер диалога для подтверждений, где фокус на кнопке «Удалить навсегда» был бы заряженным ружьём. Пока открыт: фокус заперт — Tab с последнего элемента заворачивает на первый, а страница позади инертна; современный механизм — атрибут inert на фоновом контейнере (или нативный элемент dialog с showModal(), который даёт top-layer, Escape и инертность фона одним ходом). inert — та важная половина, которую самодельные ловушки забывают: ловушка клавиши Tab останавливает клавиатуру, но без инертности виртуальный курсор скринридера спокойно выходит из диалога в фоновую страницу. По Escape: закрыть. При закрытии: вернуть фокус элементу, открывшему диалог — сохраните ссылку на document.activeElement до переноса фокуса и восстановите после демонтирования.

Восстановление — пункт, который команды нарушают чаще всего, и он плохо компонуется с остальными: ловушка без восстановления означает, что каждое взаимодействие с диалогом заканчивается выбросом пользователя на body — ловушка сделала диалог пригодным, а отсутствие восстановления сделало его закрытие обрывом. Сеньорский крайний случай: триггера может уже не существовать к моменту закрытия (работа диалога состояла в удалении строки, содержащей триггер). Поэтому восстановлению нужна цепочка запасных вариантов — триггер, если жив, иначе ближайший выживший контейнер или заголовок списка, — и это ровно тезис «фокус — состояние приложения», доведённый до конкретики: вы вычисляете следующую позицию фокуса из своей модели данных, а не из DOM.

Гонки, удаления и два псевдокласса фокуса

Фокус может встать только на узел, который существует. Классическая гонка React: обработчик вызывает setShowPanel(true), а следующей строкой — panelRef.current.focus(), но обновления состояния батчатся и асинхронны, панель ещё не отрендерена, panelRef.current равен null, и фокус молча уходит в никуда. flushSync из react-dom существует ровно для этого: оберните обновление состояния в flushSync(() => setShowPanel(true)) — React синхронно закоммитит DOM до следующей строки, и вызов focus() найдёт узел. Это осознанный аварийный люк с реальной ценой — синхронный рендер затронутого поддерева в обход батчинга, — поэтому правило такое: flushSync для позиционирования фокуса (а также прокрутки и выделения) после монтирования, управляемого состоянием, и ни для чего больше.

Удаление — та же теорема с другой стороны: когда сфокусированный элемент демонтируется, браузер переносит фокус на body — эвристики «ближайший сосед» в платформе нет. Поэтому вычисляйте преемника до мутации: фокус на аналогичный контрол следующей строки, иначе предыдущей, иначе на заголовок списка с tabIndex={-1}. Ваша модель данных знает соседа; DOM после удаления — нет.

Две смежные дистинкции завершают набор. :focus срабатывает всегда, когда у элемента фокус; :focus-visible — когда эвристики браузера говорят, что индикатор стоит показать: при клавиатурном взаимодействии да, при клике мышью обычно нет. Современный паттерн — выразительное кольцо на :focus-visible вместо удаления обводок (древнее преступление outline: none мотивировалось кольцами от кликов мыши; :focus-visible устраняет мотивацию). И scrollIntoView — это не фокус: он двигает пиксели, а не позицию последовательного фокуса — после прокрутки элемента в видимую область следующий Tab пользователя всё равно продолжится со старого места. Если пользователь должен взаимодействовать с нового места — фокусируйте; если должен лишь увидеть — прокручивайте; делать одно, имея в виду другое, — категориальная ошибка, которую переживают только клавиатурные пользователи.

Почему это работает

Почему платформа отфутболивает фокус на body, вместо того чтобы сделать что-то умнее, когда сфокусированный узел исчезает? Потому что каждый «умный» вариант кодирует политику, которой браузер знать не может. Следующий сосед? Предыдущий? Контейнер? Элемент, вставший на это место? Каждый вариант корректен в одном UI и абсурден в другом — после удаления последнего пункта списка «следующий сосед» может оказаться футером страницы. У DOM нет понятия «этот узел заменил тот»; семантического преемника знает только состояние вашего приложения. Поэтому спецификация выбрала единственный нейтральный фолбэк — body — и сделала выбор преемника заботой приложения. React наследует это полностью: ререндер, демонтирующий сфокусированный компонент, для браузера — просто удаление.

Викторина

Обработчик выполняет setOpen(true), а следующей строкой — inputRef.current.focus(), рассчитывая сфокусировать поле внутри только что открытой панели. Поле фокус не получает. Почему, и каково санкционированное лечение?

Вспомните перед уходом
  1. 01
    Перечислите четыре пункта фокусного контракта диалога и два способа, которыми команды его ломают.
  2. 02
    Объясните гонку фокуса-после-рендера и выброс при удалении строки — и почему у них общая первопричина.
Итог

Полная загрузка страницы даёт assistive technology три бесплатных сигнала — сброс фокуса, объявление заголовка, известный порядок чтения, — а клиентская смена роута не даёт ни одного, так что SPA, которое «ничего не делает», оставляет фокус на устаревшем DOM или роняет его на body в полной тишине. Рабочий набор паттернов: считать навигацию событием фокуса — фокусировать заголовок нового вью с tabIndex минус один (фокусируем программно, вне Tab-порядка) и обновлять title, либо объявлять через вежливый live region там, где перенос фокуса разрушителен; соблюдать четырёхпунктный контракт диалога — фокус внутрь при открытии, ловушка Tab плюс инертность фона пока открыт (inert или нативный showModal, потому что голая ловушка клавиш не останавливает виртуальный курсор), Escape для закрытия и восстановление на сохранённый триггер с цепочкой запасных целей для триггеров, уничтоженных самим диалогом. Механика в основании: фокус встаёт только на закоммиченные узлы, поэтому фокусу после смены состояния нужен flushSync, форсирующий коммит до вызова фокуса, — осознанно дорогой люк, ограниченный фокусом, прокруткой и выделением; а демонтирование сфокусированного элемента всегда отправляет фокус на body, потому что платформа отказывается угадывать преемников, — значит, потоки удаления вычисляют следующую цель фокуса из модели данных до мутации. Индикатор стилизуйте на :focus-visible, а не удаляйте обводки, и держите scrollIntoView и фокус раздельно: один двигает вьюпорт, другой — точку, где живёт клавиатура. Всё это — один и тот же тезис: в SPA фокус — состояние приложения, а неуправляемое состояние гниёт. Теперь, когда увидишь молчание после смены роута или выброс курсора на верх страницы после удаления, у тебя есть конкретный чек-лист — фокус на заголовок, контракт диалога, flushSync, заранее вычисленный преемник — чтобы найти и устранить причину за минуты.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.