Skip to content

Instantly share code, notes, and snippets.

@NickGard
Last active January 29, 2024 10:24
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save NickGard/43328a4e223698e6a63cbff410e35342 to your computer and use it in GitHub Desktop.
Save NickGard/43328a4e223698e6a63cbff410e35342 to your computer and use it in GitHub Desktop.
Safari Focus Polyfill
(function() {
var capturedEvents = [];
var capturing = false;
function getEventTarget(event) {
return event.composedPath()[0] || event.target;
}
function captureEvent(e) {
if (capturing) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
capturedEvents.unshift(e);
}
}
function hiddenOrInert(element) {
var el = element;
while (el) {
if (el.style.display === 'none' || el.hasAttribute('inert')) return true;
el = el.parentElement;
}
return false;
}
/*
* The only mousedown events we care about here are ones emanating from
* (A) anchor links with href attribute,
* (B) non-disabled buttons,
* (C) non-disabled textarea,
* (D) non-disabled inputs of type "button", "reset", "checkbox", "radio", "submit"
* (E) non-interactive elements (button, a, input, textarea, select) that have a tabindex with a numeric value
* (F) audio elements
* (G) video elements with controls attribute
* (H) any element with the contenteditable attribute
*/
function isFocusableElement(el) {
var tag = el.tagName;
return (
!hiddenOrInert(el) && (
(/^a$/i.test(tag) && el.href != null) || // (A)
(/^(button|textarea)$/i.test(tag) && el.disabled !== true) || // (B) (C)
(/^input$/i.test(tag) &&
/^(button|reset|submit|radio|checkbox)$/i.test(el.type) &&
!el.disabled) || // (D)
(!/^(button|input|textarea|select|a)$/i.test(tag) &&
!Number.isNaN(Number.parseFloat(el.getAttribute('tabindex')))) || // (E)
/^audio$/i.test(tag) || // (F)
(/^video$/i.test(tag) && el.controls === true) || // (G)
el.getAttribute('contenteditable') != null // (H)
)
);
}
function getLabelledElement(labelElement) {
var forId = labelElement.getAttribute('for');
return forId
? document.querySelector('#'+forId)
: labelElement.querySelector('button, input, keygen, select, textarea');
}
function getFocusableElement(e) {
var currentElement = getEventTarget(e);
var focusableElement;
while (!focusableElement && currentElement !== null && currentElement.nodeType === 1) {
if (isFocusableElement(currentElement)) {
focusableElement = currentElement;
} else if (/^label$/i.test(currentElement.tagName)) {
var labelledElement = getLabelledElement(currentElement);
if (isFocusableElement(labelledElement)) {
focusableElement = labelledElement;
}
}
currentElement = currentElement.parentElement || currentElement.parentNode
}
return focusableElement;
}
function handler(e) {
var focusableElement = getFocusableElement(e);
if (focusableElement) {
if (focusableElement === document.activeElement) {
// mousedown is happening on the currently focused element
// do not fire the 'focus' event in this case AND
// call preventDefault() to stop the browser from transferring
// focus to the body element
e.preventDefault();
} else {
// start capturing possible out-of-order mouse events
capturing = true;
/*
* enqueue the focus event _after_ the current batch of events, which
* includes any blur events but before the mouseup and click events.
* The correct order of events is:
*
* [this element] MOUSEDOWN <-- this event
* [previously active element] BLUR
* [previously active element] FOCUSOUT
* [this element] FOCUS <-- forced event
* [this element] FOCUSIN <-- triggered by forced event
* [this element] MOUSEUP <-- possibly captured event (it may have fired _before_ the FOCUS event)
* [this element] CLICK <-- possibly captured event (it may have fired _before_ the FOCUS event)
*/
setTimeout(() => {
// stop capturing possible out-of-order mouse events
capturing = false;
// trigger focus event
focusableElement.focus();
// re-dispatch captured mouse events in order
while (capturedEvents.length > 0) {
var event = capturedEvents.pop();
getEventTarget(event).dispatchEvent(new MouseEvent(event.type, event));
}
}, 0);
}
}
}
if (/apple/i.test(navigator.vendor)) {
window.addEventListener('mousedown', handler, {capture: true});
window.addEventListener('mouseup', captureEvent, {capture: true});
window.addEventListener('click', captureEvent, {capture: true});
}
})();
@aercolino
Copy link

To work across shadow roots, replace the two x.target with getEventTarget(x), and add the getEventTarget function in the IIFE

function getEventTarget(event) {
    return event.composedPath()[0];
}

@borm
Copy link

borm commented Aug 10, 2022

TypeError: undefined is not an object (evaluating 'getEventTarget(event).dispatchEvent')

@sewerynkalemba
Copy link

There should be included touch events to fully support mobile users:

window.addEventListener('mousedown', handler, {capture: true});
window.addEventListener('touchstart', handler, {capture: true}); 
window.addEventListener('mouseup', captureEvent, {capture: true});
window.addEventListener('click touchend', captureEvent, {capture: true}); 

@laurent-d
Copy link

thanks @sewerynkalemba for your input...
All actions needed double click on iOs without your fix

@sewerynkalemba
Copy link

sewerynkalemba commented Nov 9, 2022

There's an issue with textarea on iOS Safari using this polyfill. Focusing an textarea won't trigger displaying a keyboard. I couldn't find a proper fix, so I removed textarea from RegEx in isFocusableElement function as a temporary workaround.

@NickGard
Copy link
Author

Thank you all for your comments, bug reports, and improvement suggestions! I just got around to testing and fixing things:

@aercolino, using event.composedPath() is an awesome improvement 👍

@borm, this error is fixed now. It was caused by some events not having a composed path, so I re-added event.target as a fallback. 🔧

@sewerynkalemba, pointerup, pointerdown, touchstart, and touchend do not fire out of order for iOS or iPadOS, so I never included those. By calling preventDefault() on the touch* events, you stop the mouse* and click events from firing, which led to the bug you later reported about the virtual keyboard not opening. Unfortunately, only click events are allowed to be re-dispatched and still engage the default actions. All other events that are dispatched programmatically do not trigger default actions.

@laurent-d I could not reproduce this bug. If you (or anyone) sees something like this again, please leave me a message here and I'll try to reproduce and fix it.

@PitWenkin
Copy link

Hi, selecting text on safari (Desktop) in a textarea does not work propperly (Both firefox an chrome are not affected)
The only way to select any text is clicking in an unfocused textarea and selecting the text before releasing the mouse button.
An already focused textarea will not accept click&select, double-click(singleword), or tripple-click(whole line select).

Might be related to @sewerynkalemba's report

@Maximaximum
Copy link

@NickGard have you ever considered releasing this polyfill as an npm package? It would be extremely useful

@kimskovhusandersen
Copy link

kimskovhusandersen commented Jan 25, 2024

There was a type error in isFocusableElement.

TypeError: null is not an object (evaluating 'e.tagName')

There should be a check to make sure el is not null or undefined.

  function isFocusableElement(el) {
    if (!el) return false;
    var tag = el.tagName;

@NickGard
Copy link
Author

There's a new, more complete version of this polyfill at https://gist.github.com/NickGard/343da4185ed244b9c98aaad267362066. The version here didn't fix all of the test cases on https://nickgard.github.io/click-focus-testing/.

@kimskovhusandersen
Copy link

There's a new, more complete version of this polyfill at https://gist.github.com/NickGard/343da4185ed244b9c98aaad267362066. The version here didn't fix all of the test cases on https://nickgard.github.io/click-focus-testing/.

Thank you @NickGard 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment