Skip to content

Instantly share code, notes, and snippets.

@NickGard
Last active April 16, 2024 15:50
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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.

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