-
-
Save NickGard/343da4185ed244b9c98aaad267362066 to your computer and use it in GitHub Desktop.
(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 }); | |
} | |
})(); |
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?
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 :-)
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.
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
.
In IOS i need to click twice in order for it to activate.