Skip to content

Instantly share code, notes, and snippets.

@NickGard
Last active July 23, 2024 06:42
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 });
}
})();
@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.

@frank-topel-dbi
Copy link

frank-topel-dbi commented Jul 23, 2024

Is there any way to prevent a component that utilizes HTMLButtonElement inside from temporary losing focus (triggering focusout) when a user clicks the button? I'm observing that focusout, with the focus being transferred to the body.

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