Fixing Focus for Safari

Nick Gard
ITNEXT
Published in
8 min readNov 16, 2021

--

In 2008, Apple’s Safari team had a problem: web developers had noticed that links, buttons, and several input elements were not matching the :focus selector when clicked. Similarly, clicking on those elements did not fire a focus or focusin event. The developers dutifully submitted bugs against Webkit and were waiting for help.

That help never came.

The reason is that macOS didn’t transfer focus to operating system and application buttons when they were clicked and the stakeholders decided to be consistent with the operating system instead of with their competitors’ browsers. The discussions have lasted 13 years and are still ongoing, but the team at Apple is adamant: buttons, links, and non-text inputs won’t get focus.

Helpfully, the team agreed that a workaround would be to implement :focus-visible, the CSS selector that matches elements that are awaiting input; they are focused but not activated. Clicking a button or link both focuses and activates it, but tabbing into them will match this focus-only selector. This partial solution would indeed help, but it stalled in April of 2021, three years after it was started and 11 years after it was suggested.

This failure has started to snowball. Safari introduced :focus-within in early 2017, beating most of their competitors to market with this newly standardized pseudo selector. But it wouldn’t match any container when a button, link, or non-text input was clicked. In fact, if the user were focused on a text input (matching :focus-within selector for its container) and then clicked on one of those troublesome elements, the focus left the container entirely.

Edit (8/29/2022)
Safari has implemented :focus-visible in version 15.4. The bugs described in this article for :focus still exist, though. Clicking any problematic element does not fire focus events, and does not match the :focus or :focus-within selectors.

It’s clear that Apple’s Webkit/Safari team are not going to fix this issue. There is an excellent polyfill available for :focus-visible from the WICG. There is a good polyfill available for :focus-within as well. But I have not found a polyfill for :focus, so I wrote one:

Edit (1/22/2024)
I created a test suite and support matrix for all the different ways that an element can be made click focusable or rendered unfocusable (there are 108 test cases so far!). Safari is by far the least supported but even Firefox and Chrome/Edge had failing tests. Notably, no browser properly handles click focusability on the generated control elements for <audio> and <video> elements. Building out this test suite also showed that the previous polyfill I made was insufficient for all of the use-cases. I wrote a new, more comprehensive polyfill here.

Polyfill Breakdown

The heart of this polyfill is the Element.focus() method call (on line #112 in the gist above) that dispatches the focus and focusin events. Once the browser sees these events associated with an element, this element matches the :focus selector.

There are a few events we could listen to in order to dispatch that focus event when the element is clicked: mousedown, mouseup, and click.

A note on events

If you’re not familiar with how events work in the browser, there are two phases. The capturing phase starts at the topmost element, <html>, and proceeds down through the DOM towards the element that was clicked. The bubbling phase begins after the capture phase ends, and it starts at the lowermost element that was clicked and proceeds upwards (bubbles) toward the HTML element. Any node in this pathway can attach an event listener that is triggered when the event hits that node.

Any of these event listeners can stop the event from continuing and attach listeners for following events that could prevent them from firing. This is important. We don’t want some other event listener to prevent our listener from firing. That means we want to attach to the earliest event, mousedown, and during the capturing phase. This is done on line #81 in the gist above.

However, Element.focus() fires synchronously, meaning that the focus and focusin events would fire before the blur and focusout events. The correct order of events is:

  1. mousedown [targeting the clicked element]
  2. blur [targeting the previously active element]
  3. focusout [targeting the previously active element]
  4. focus [targeting the clicked element]
  5. focusin [targeting the clicked element]
  6. mouseup [targeting the clicked element]
  7. click [targeting the clicked element]

We can use a zero second setTimeout call (line #56 in the gist above) to enqueue the Element.focus() call after all of the currently queued events. This moves our forced focus event after the blur and focusout events, but this also moves it after the mouseup and click events, which is not ideal.

To ensure that the mouseup and click events fire after our forced focus event, we need to capture and resend them. We don’t want to capture every mouseup and click event, so we need to add the listeners only when we are going to force a focus event (line #41–42) and remove them when we’re done (line #58–59). Lastly, we need to re-dispatch those events with all the same data as they originally had, such as mouse position (line #67).

Lastly, we need to only do all of this work in Safari (see line #80) and only when the unsupported elements are clicked. The wonderful folks at allyjs.io have provided a very useful test document for checking what elements get focus. After comparing the results of clicking each of these elements in different browsers, I found the following elements needed this polyfill:

  1. anchor links with href attribute
  2. non-disabled buttons
  3. non-disabled textarea
  4. non-disabled inputs of type button, reset, checkbox, radio, and submit
  5. non-interactive elements (button, a, input, textarea, select) that have a tabindex with a numeric value
  6. audio elements
  7. video elements with controls attribute

The function isPolyfilledFocusEvent (line #22–36) checks for these cases.

I hope this helps.

And I hope Safari supports this stuff natively sometime.

EDIT (15/1/2022): Real-world production use of the polyfill has revealed two bugs. First, the redispatched events should be dispatched from the event targets rather than from the document. Events not fired from the previous target are stripped of their target property and do not trigger default behaviors like navigating on link clicks.

Code diff showing the change from firing the re-dispatched event from the document to firing it from the element’s target.
redispatched events must be fired from the event’s target to trigger native functionality

Second, the capturing of possible out-of-order mouseup and click events needs to be handled differently. By adding the listeners dynamically, they were being added after any listeners that were added after the polyfill runs. This means that the other listeners would trigger first before the event being captured, and then they would trigger again when the event was redispatched. The solution is to add the polyfill’s listeners for mouseup and click immediately, and toggle a capturing flag on and off instead of attaching and removing listeners.

Code diff showing the Capture Event handler having an “if” block inserted that checks if the “capturing” flag is enabled.
Use a flag to begin and end capturing events
Code diff showing the mouse up and click listeners being added immediately after the mouse down listener
Add the listeners immediately, not dynamically

EDIT (21/1/2022): More production use has revealed another bug. Clicking on an already focused element should not fire another focus (or focusin) event. However, the polyfill was blindly firing a focus event on every click, or mousedown, of an affected element. To prevent this from happening, we check if the event target element is the same as the document’s active element before proceeding with the polyfilled focus behavior.

A side effect of not firing focus on every click is that Safari returns focus to the body element if it decides that the target element isn’t focusable. To stop this, we call preventDefault() on the mousedown handler. (How did I know that would work? I didn’t. It took some trial and error to find out when and how Safari was moving focus to the body.)

A code diff showing a new if else block in the mousedown handler that checks if the event target is the same as the document active element and calls event prevent default in that case.
Check if the target is already focused and prevent Safari from focusing the body

EDIT (23/1/2022): Clicks on label elements should redirect focus to the labeled element. In order to detect that a click was occurring on a label, we have two choices: (1) on every mousedown event that we are listening to, we could climb the DOM tree all the way to the body element to see if the event triggered on an element in a label, or (2) detect when the pointer is inside a label before the mousedown event. Option 1 would work but it would be very expensive, especially on large pages. For option 2, we had to find how to detect the user was inside a label before the mousedown event fired.

The event mouseenter fires before the mousedown event (even on touch devices!) and it fires for each element that the mousedown event would bubble through. That means that each element has a mouseenter (and mouseleave) event that it is the target of. (Since we’re using delegated listeners, we cannot rely on currentTarget because that would always be null.)

We added a function to get the redirected focus target when the user is inside a label element, and we use that element to detect if it already has focus and to focus on it if not.

A code diff showing the new “get redirected focus target” function.
For mouse enter and leave events on label elements, find the associated target element

EDIT (17/2/2022): We needed to account for possibly nested children in focusable elements that accept children, such as buttons, anchor tags or arbitrary elements with a tabindex attribute. Since we were traversing the DOM looking for focusable parents anyway, we decided to remove the mouseenter and mouseleave listeners we were using for discovering focusable label elements and fold that use case into the new strategy. We also discovered a bug in our check for the tabindex attribute: the HTMLElement property tabIndex reports a value of -1 if the attribute has not been explicitly set. The solution is to use .getAttribute('tabindex') to determine if the value has been explicitly set.

Code diff showing the new getLabelledElement and getFocusableElement functions
Get the focusable element, even if it’s an ancestor or connected through a label in the ancestry of the mousedown target element

--

--