В 2008 году у команды Apple Safari возникла проблема: веб-разработчики заметили, что ссылки, кнопки и некоторые элементы ввода не соответствуют селектору :focus при нажатии. Точно так же нажатие на эти элементы не вызывает событие focus или focusin. Разработчики покорно сообщали о багах Webkit и ждали помощи.

Эта помощь так и не пришла.

Причина в том, что macOS не переводила фокус на операционную систему и кнопки приложений при нажатии на них, и заинтересованные стороны решили следовать операционной системе, а не браузерам своих конкурентов. Дискуссии длились 13 лет и продолжаются до сих пор, но команда Apple непреклонна: кнопки, ссылки и нетекстовые элементы ввода не получат внимания.

К счастью, команда согласилась, что обходной путь будет заключаться в реализации :focus-visible, селектора CSS, который соответствует элементам, ожидающим ввода; они сфокусированы, но не активированы. Щелчок по кнопке или ссылке и фокусирует, и активирует ее, но переход на них будет соответствовать этому селектору только для фокуса. Это частичное решение действительно помогло бы, но оно заглохло в апреле 2021 года, через три года после того, как оно было начато, и через 11 лет после того, как оно было предложено.

Эта неудача начала превращаться в снежный ком. Safari представил :focus-within в начале 2017 года, опередив большинство своих конкурентов на рынке благодаря этому недавно стандартизированному псевдоселектору. Но он не будет соответствовать ни одному контейнеру при нажатии кнопки, ссылки или нетекстового ввода. На самом деле, если пользователь фокусировался на текстовом вводе (соответствующем селектору :focus-within для его контейнера), а затем нажимал на один из этих проблемных элементов, фокус полностью покидал контейнер.

Изменить (29.08.2022)
Safari реализовала :focus-visible в версии 15.4. Однако ошибки, описанные в этой статье для :focus, все еще существуют. Щелчок по любому проблемному элементу не запускает события фокуса и не соответствует селекторам :focus или :focus-within.

Понятно, что команда Apple Webkit/Safari не собирается решать эту проблему. Для :focus-visible есть отличный полифилл от WICG. Для :focus-within тоже есть хороший полифилл. Но я не нашел полифилла для :focus, поэтому написал один:

Разбивка полифилла

Сердцем этого полифилла является вызов метода Element.focus() (в строке № 112 в приведенном выше тексте), который отправляет события focus и focusin. Как только браузер увидит эти события, связанные с элементом, этот элемент соответствует селектору :focus.

Есть несколько событий, которые мы могли бы прослушивать, чтобы отправить это событие focus при нажатии на элемент: mousedown, mouseup и click.

Примечание о событиях

Если вы не знакомы с тем, как события работают в браузере, есть две фазы. Этап захвата начинается с самого верхнего элемента, <html>, и продолжается вниз по DOM к элементу, по которому был сделан щелчок. Фаза пузырьков начинается после завершения фазы захвата и начинается с самого нижнего элемента, по которому был сделан щелчок, и продолжается вверх (пузыри) к элементу HTML. Любой узел на этом пути может подключить прослушиватель событий, который срабатывает, когда событие достигает этого узла.

Любой из этих прослушивателей событий может остановить продолжение события и подключить прослушиватели для следующих событий, которые могут помешать их запуску. Это важно. Мы не хотим, чтобы какой-то другой обработчик событий мешал срабатыванию нашего обработчика событий. Это означает, что мы хотим подключиться к самому раннему событию, mousedown, и на этапе захвата. Это делается в строке № 81 выше.

Однако Element.focus() срабатывает синхронно, а это означает, что события focus и focusin будут срабатывать до событий blur и focusout. Правильный порядок событий таков:

  1. mousedown [нацеливание на выбранный элемент]
  2. размытие [нацеливается на ранее активный элемент]
  3. фокусировка [нацеливание на ранее активный элемент]
  4. фокус [нацеливание на выбранный элемент]
  5. фокусировка [нацеливание на выбранный элемент]
  6. mouseup [нацеливание на выбранный элемент]
  7. щелчок [нацеленный на выбранный элемент]

Мы можем использовать нулевой секундный вызов setTimeout (строка № 56 в приведенном выше описании), чтобы поставить вызов Element.focus() в очередь после всех текущих событий в очереди. Это перемещает наше принудительное событие focus после событий blur и focusout, но это также перемещает его после событий mouseup и click, что не идеально.

Чтобы гарантировать, что события mouseup и click сработают после нашего принудительного события focus, нам нужно их захватить и повторно отправить. Мы не хотим захватывать каждое событие mouseup и click, поэтому нам нужно добавлять прослушиватели только тогда, когда мы собираемся форсировать событие focus (строки 41–42), и удалять их, когда мы закончим (строка 58– 59). Наконец, нам нужно повторно отправить эти события со всеми теми же данными, что и у них изначально, такими как положение мыши (строка № 67).

Наконец, нам нужно проделать всю эту работу только в Safari (см. строку №80) и только тогда, когда будут нажаты неподдерживаемые элементы. Замечательные ребята из allyjs.io предоставили очень полезный тестовый документ для проверки того, какие элементы получают фокус. Сравнив результаты нажатия каждого из этих элементов в разных браузерах, я обнаружил, что этот полифилл нужен следующим элементам:

  1. якорные ссылки с атрибутом href
  2. неотключаемые кнопки
  3. не отключенное текстовое поле
  4. неотключенные входы типа button, reset, checkbox, radio и submit
  5. неинтерактивные элементы (кнопка, а, ввод, текстовое поле, выбор), которые имеют tabindex с числовым значением
  6. аудио элементы
  7. видеоэлементы с атрибутом controls

Функция isPolyfilledFocusEvent (строки 22–36) проверяет эти случаи.

Надеюсь, это поможет.

И я надеюсь, что Safari когда-нибудь будет поддерживать этот материал изначально.

РЕДАКТИРОВАНИЕ (15.01.2022): Реальное использование полифилла в производстве выявило две ошибки. Во-первых, повторно отправленные события должны отправляться из целей событий, а не из документа. События, не инициированные предыдущим target, лишаются своего свойства target и не запускают поведение по умолчанию, например переход по ссылкам.

Во-вторых, захват возможных неупорядоченных событий mouseup и click должен обрабатываться по-разному. При динамическом добавлении прослушивателей они добавлялись после любых прослушивателей, добавленных после выполнения полифилла. Это означает, что другие слушатели будут запускаться первыми до захвата события, а затем они будут запускаться снова, когда событие будет повторно отправлено. Решение состоит в том, чтобы немедленно добавить прослушиватели полифилла для mouseup и click и включать и выключать флаг capturing вместо того, чтобы присоединять и удалять прослушиватели.

РЕДАКТИРОВАНИЕ (21 января 2022 г.): более широкое использование в производственной среде выявило еще одну ошибку. Щелчок по элементу, который уже находится в фокусе, не должен запускать другое событие фокуса (или фокуса). Однако полифилл вслепую запускал событие фокуса при каждом щелчке или нажатии мыши затронутого элемента. Чтобы этого не произошло, мы проверяем, совпадает ли целевой элемент события с активным элементом документа, прежде чем переходить к полифиллированному поведению фокуса.

Побочным эффектом отсутствия фокусировки при каждом щелчке является то, что Safari возвращает фокус элементу body, если решает, что целевой элемент не может быть сфокусирован. Чтобы остановить это, мы вызываем preventDefault() в обработчике mousedown. (Откуда я знал, что это сработает? Я не знал. Потребовалось несколько проб и ошибок, чтобы выяснить, когда и как Safari перемещает фокус на тело.)

ИЗМЕНЕНИЕ (23 января 2022 г.): клики по элементам ярлыка должны перенаправлять фокус на элемент с ярлыком. Чтобы обнаружить, что щелчок произошел по метке, у нас есть два варианта: (1) для каждого события mousedown, которое мы слушаем, мы могли бы подняться по дереву DOM до элемента body, чтобы увидеть, сработало ли событие. на элемент в метке или (2) определить, когда указатель находится внутри метки до события mousedown. Вариант 1 будет работать, но будет очень дорого, особенно на больших страницах. Для варианта 2 нам нужно было найти, как определить, что пользователь находится внутри метки до срабатывания события mousedown.

Событие mouseenter срабатывает перед событием mousedown (даже на сенсорных устройствах!) и срабатывает для каждого элемента, через который будет всплывать событие mousedown. Это означает, что у каждого элемента есть событие mouseentermouseleave), целью которого он является. (Поскольку мы используем делегированных слушателей, мы не можем полагаться на currentTarget, потому что это всегда будет null.)

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

EDIT(17/2/2022): нам нужно было учесть возможные вложенные дочерние элементы в фокусируемых элементах, которые принимают дочерние элементы, такие как кнопки, теги привязки или произвольные элементы с атрибутом tabindex. Так как мы все равно просматривали DOM в поисках фокусируемых родителей, мы решили удалить прослушиватели mouseenter и mouseleave, которые мы использовали для обнаружения фокусируемых элементов label, и включить этот вариант использования в новую стратегию. Мы также обнаружили ошибку в нашей проверке атрибута tabindex: свойство HTMLElement tabIndex сообщает о значении -1, если атрибут не был задан явным образом. Решение состоит в том, чтобы использовать .getAttribute('tabindex'), чтобы определить, имеет ли значение были явно заданы.