Skip to content

Instantly share code, notes, and snippets.

@NickGard
Last active June 18, 2024 12:38
Show Gist options
  • Save NickGard/343da4185ed244b9c98aaad267362066 to your computer and use it in GitHub Desktop.
Save NickGard/343da4185ed244b9c98aaad267362066 to your computer and use it in GitHub Desktop.
Updated polyfill for fixing click focusability in Safari. Handles shadow DOM clicks now.
(function () {
const capturedEvents = [];
let capturing = false;
let captureTarget = null;
let deferredDispatch;
const faultyElementSelector = [
"a[href]",
"area[href]",
"audio[controls]",
"button",
'input[type="button"]',
'input[type="checkbox"]',
'input[type="file"]',
'input[type="image"]',
'input[type="radio"]',
'input[type="range"]',
'input[type="reset"]',
'input[type="submit"]',
"video[controls]",
].join(", ");
// interactive content is a term of art defined by the whatwg spec:
// https://html.spec.whatwg.org/multipage/dom.html#interactive-content
// (note: focusable elements are not necessarily interactive elements and vice versa)
const interactiveElementSelector = [
"a[href]",
"audio[controls]",
"button",
"details",
"embed",
"iframe",
"img[usemap]",
'input:not([type="hidden"])',
"label",
"select",
"textarea",
"video[controls]",
].join(", ");
function captureEvent(e) {
if (capturing && getEventTarget(e) === captureTarget) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
capturedEvents.unshift(e);
}
}
/**
* Gets the target element, even if it is in a shadow DOM
*/
function getEventTarget(event) {
return event.composedPath()[0] || event.target;
}
function isLabelableElement(element) {
return (
element.matches(
"button, input, select, textarea, progress, meter, output"
) || element.constructor.formAssociated
); // custom elements that are form associated can be labeled
}
function canBeDisabled(element) {
return (
element.matches(
"button, fieldset, optgroup, option, select, textarea, input"
) || element.constructor.formAssociated
); // custom elements that are form associated can be disabled
}
/**
* "being rendered" is a term of art in [WHATWG](https://html.spec.whatwg.org/multipage/rendering.html#being-rendered).
* An element is "being rendered" _unless_ it or an ancestor has:
* 1. display: none
* 2. content-visibility: hidden
* 3. visibility: hidden
*
* Note:
* There was some debate whether an element with "display: contents" meant that it was being rendered,
* but the issue was resolved in this (CSSWG thread](https://github.com/w3c/csswg-drafts/issues/2632)
*/
function isBeingRendered(element) {
let el = element;
let isVisibilityOverridden = false;
while (el) {
if (el.style.visibility === "visible") {
isVisibilityOverridden = true;
}
if (
el.style.display === "none" ||
el.style.contentVisibility === "hidden" ||
(!isVisibilityOverridden && el.style.visibility === "hidden")
) {
return false;
}
el = el.parentElement;
}
return true;
}
function isFocusable(element) {
// is natively focusable element
const isNativelyFocusableElement =
element.matches(
'button, input:not([type="hidden"]), select, textarea, a[href], area[href], audio[controls], video[controls]'
) || element.constructor.formAssociated;
/* elements can be made focusable in two ways:
* 1. by adding a tabindex attribute that can be parsed as an integer
* 2. by adding a contenteditable attribute with a value of either "true" or "plaintext-only" (adding an empty attribute is the same as "true")
*/
const isArtificiallyFocusable =
!Number.isNaN(parseInt(element.getAttribute("tabindex"), 10)) || // 1
["plaintext-only", "true"].includes(element.contentEditable); // 2
/*
* An element's focusability may be overridden due to:
* 1. Being disabled via the "disabled" attribute, but only if the element is a form associated element
* 2. Being contained by a fieldset that is disabled, but only if the element is a form associated element
* 3. The element or an ancestor is inert
* 4. The element or an ancestor is not "being rendered"
*/
const isDisabled =
canBeDisabled(element) &&
(element.disabled || element.closest("fieldset:disabled")); // 1, 2
const isInert = element.closest("[inert]"); // 3
const isHidden = !isBeingRendered(element); // 4
const isFocusabilityOverridden = isDisabled || isInert || isHidden;
return (
(isNativelyFocusableElement || isArtificiallyFocusable) &&
!isFocusabilityOverridden
);
}
function focusAndRedispatchMouseEvents(element) {
/*
* 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 been dispatched _before_ the FOCUS event)
* [this element] CLICK <-- possibly captured event (it may have been dispatched _before_ the FOCUS event)
*/
setTimeout(() => {
// stop capturing possible out-of-order mouse events
capturing = false;
captureTarget = null;
// trigger focus event
element.focus();
// re-dispatch captured mouse events in order
while (capturedEvents.length > 0) {
const capturedEvent = capturedEvents.pop();
capturedEvent.target.dispatchEvent(
new MouseEvent(capturedEvent.type, capturedEvent)
);
}
}, 0);
}
function detectFaultyElementAndScheduleFix(event) {
const target = getEventTarget(event);
const labelElement = target.closest("label");
const interactiveAncestor = target.closest(interactiveElementSelector);
const faultyAncestor = target.closest(faultyElementSelector);
let focusTarget = null; // element expected to recieve focus
let faultyFocusTarget = null; // focusTarget that matches the faulty parameters
let waitForRedirectedFocus = false; // should defer focusing and re-dispatching captured events until the target's click event is dispatched
if (labelElement) {
const labelTargetId = labelElement.getAttribute("for");
if (
labelElement !== interactiveAncestor &&
labelElement.contains(interactiveAncestor) &&
interactiveAncestor.contains(faultyAncestor)
) {
// labels must not redirect focus or re-dispatch events if the click occurs within an interactive descendant
// https://html.spec.whatwg.org/multipage/forms.html#the-label-element:activation-behaviour-2
faultyFocusTarget = faultyAncestor;
} else if (labelTargetId != null) {
// if the label has a "for" attribute, ignore any wrapped labelable elements even if the target referenced is not labelable or doesn't exist
focusTarget =
labelTargetId !== ""
? document.querySelector(`#${CSS.escape(labelTargetId)}`)
: null;
if (focusTarget && isLabelableElement(focusTarget)) {
waitForRedirectedFocus = true;
// all labelable elements getting redirected focus from a label click are faulty because they dispatch events out of order
faultyFocusTarget = focusTarget;
captureTarget = focusTarget;
}
} else {
// check for wrapped labelable elements
focusTarget = Array.from(labelElement.querySelectorAll("*")).find(
isLabelableElement
);
if (focusTarget) {
waitForRedirectedFocus = true;
// all labelable elements getting redirected focus from a label click are faulty because they dispatch events out of order
faultyFocusTarget = focusTarget;
captureTarget = focusTarget;
} else if (faultyAncestor) {
// ignore the label since it doesn't have a "for" attribute __and__ doesn't wrap a labelable element
faultyFocusTarget = faultyAncestor;
captureTarget = target;
}
}
} else if (faultyAncestor) {
faultyFocusTarget = faultyAncestor;
captureTarget = target;
}
if (faultyFocusTarget && isFocusable(faultyFocusTarget)) {
if (faultyFocusTarget === document.activeElement) {
// mousedown is happening on the currently focused element;
// __do not__ dispatch the 'focus' event in this case AND
// call preventDefault() to stop the browser from transferring
// focus to the body element
event.preventDefault();
} else {
// start capturing possible out-of-order mouse events
capturing = true;
if (waitForRedirectedFocus) {
deferredDispatch = focusAndRedispatchMouseEvents.bind(
null,
faultyFocusTarget
);
} else {
focusAndRedispatchMouseEvents(faultyFocusTarget);
}
}
}
}
function fulfillDeferments() {
if (typeof deferredDispatch === "function") {
deferredDispatch();
deferredDispatch = null;
}
}
if (/apple/i.test(navigator.vendor)) {
window.addEventListener("mousedown", detectFaultyElementAndScheduleFix, {
capture: true,
});
window.addEventListener("click", fulfillDeferments, {
capture: true,
});
window.addEventListener("mouseup", captureEvent, { capture: true });
window.addEventListener("click", captureEvent, { capture: true });
}
})();
@furuskog
Copy link

furuskog commented Feb 5, 2024

In IOS i need to click twice in order for it to activate.

@sebalis
Copy link

sebalis commented May 17, 2024

Thank you for working on this. I am trying to use your polyfill and it does make focusing possible. However, ‘unfocusing’ does not seem to work. My test document contains:

<a href="#" onclick="return false;">Link</a>

and some CSS making focus visible:

a:focus {
	background-color: red !important;
}

and I am opening this on Safari or Firefox on an iPhone (iOS 16.7.8, Firefox 16.7.8). Tapping the link does turn its background red. I would expect that tapping elsewhere on the document would take the focus away from the link, making the red background disappear. This does not happen, but that is the way it works elsewhere (Firefox/Chromium on Linux, Firefox on Android). Am I missing something?

@sebalis
Copy link

sebalis commented May 18, 2024

After some further research I realise that in my use case, all I need to make focus work is to give my A element a tabindex attribute. Then, even without this polyfill, Safari will make it focusable and the :focus pseudoclass works, my red background will appear. Also, the behaviour I was expecting – clicking/tapping somewhere else will make the focus go away – still does not occur in Safari in this setup. So it seems that here is another detail in which Safari deviates from other browsers and I would need an entirely different kind of polyfill. I have now solved my actual use case in a different way. Still, thanks for helping me understand :-)

@rallets
Copy link

rallets commented Jun 18, 2024

Hi @NickGard , thank you for your awesome work! I have been just bitten by this "feature" 😒 in Safari, and I'm wondering if you also publish a npm package with this polyfill. It could make easier to contribute and also get acknowledgment.

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