280 lines
9.1 KiB
JavaScript
280 lines
9.1 KiB
JavaScript
/**
|
|
* Adapts focus styles based on the user's active input modality (i.e., how
|
|
* they are interacting with the UI right now).
|
|
*
|
|
* Focus styles are only relevant when using the keyboard to interact with the
|
|
* page. If we only show the focus ring when relevant, we can avoid user
|
|
* confusion without compromising accessibility.
|
|
*
|
|
* The script uses two heuristics to determine whether the keyboard is being used:
|
|
*
|
|
* 1. a keydown event occurred immediately before a focus event;
|
|
* 2. a focus event happened on an element which requires keyboard interaction (e.g., a text field);
|
|
*
|
|
* This software or document includes material copied from or derived from https://github.com/WICG/focus-visible.
|
|
* Copyright © 2018 W3C® (MIT, ERCIM, Keio, Beihang).
|
|
* W3C Software Notice and License: https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
|
|
*
|
|
*
|
|
*/
|
|
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
|
|
var focusVisibleAttributeName = 'data-focusvisible-polyfill';
|
|
var rule = ":focus:not([" + focusVisibleAttributeName + "]){outline: none;}";
|
|
|
|
var modality = function modality(insertRule) {
|
|
insertRule(rule);
|
|
|
|
if (!canUseDOM) {
|
|
return;
|
|
}
|
|
|
|
var hadKeyboardEvent = true;
|
|
var hadFocusVisibleRecently = false;
|
|
var hadFocusVisibleRecentlyTimeout = null;
|
|
var inputTypesWhitelist = {
|
|
text: true,
|
|
search: true,
|
|
url: true,
|
|
tel: true,
|
|
email: true,
|
|
password: true,
|
|
number: true,
|
|
date: true,
|
|
month: true,
|
|
week: true,
|
|
time: true,
|
|
datetime: true,
|
|
'datetime-local': true
|
|
};
|
|
/**
|
|
* Helper function for legacy browsers and iframes which sometimes focus
|
|
* elements like document, body, and non-interactive SVG.
|
|
*/
|
|
|
|
function isValidFocusTarget(el) {
|
|
if (el && el !== document && el.nodeName !== 'HTML' && el.nodeName !== 'BODY' && 'classList' in el && 'contains' in el.classList) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
/**
|
|
* Computes whether the given element should automatically trigger the
|
|
* `focus-visible` attribute being added, i.e. whether it should always match
|
|
* `:focus-visible` when focused.
|
|
*/
|
|
|
|
|
|
function focusTriggersKeyboardModality(el) {
|
|
var type = el.type;
|
|
var tagName = el.tagName;
|
|
var isReadOnly = el.readOnly;
|
|
|
|
if (tagName === 'INPUT' && inputTypesWhitelist[type] && !isReadOnly) {
|
|
return true;
|
|
}
|
|
|
|
if (tagName === 'TEXTAREA' && !isReadOnly) {
|
|
return true;
|
|
}
|
|
|
|
if (el.isContentEditable) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
/**
|
|
* Add the `focus-visible` attribute to the given element if it was not added by
|
|
* the author.
|
|
*/
|
|
|
|
|
|
function addFocusVisibleAttribute(el) {
|
|
if (el.hasAttribute(focusVisibleAttributeName)) {
|
|
return;
|
|
}
|
|
|
|
el.setAttribute(focusVisibleAttributeName, true);
|
|
}
|
|
/**
|
|
* Remove the `focus-visible` attribute from the given element if it was not
|
|
* originally added by the author.
|
|
*/
|
|
|
|
|
|
function removeFocusVisibleAttribute(el) {
|
|
el.removeAttribute(focusVisibleAttributeName);
|
|
}
|
|
/**
|
|
* Remove the `focus-visible` attribute from all elements in the document.
|
|
*/
|
|
|
|
|
|
function removeAllFocusVisibleAttributes() {
|
|
var list = document.querySelectorAll("[" + focusVisibleAttributeName + "]");
|
|
|
|
for (var i = 0; i < list.length; i += 1) {
|
|
removeFocusVisibleAttribute(list[i]);
|
|
}
|
|
}
|
|
/**
|
|
* Treat `keydown` as a signal that the user is in keyboard modality.
|
|
* Apply `focus-visible` to any current active element and keep track
|
|
* of our keyboard modality state with `hadKeyboardEvent`.
|
|
*/
|
|
|
|
|
|
function onKeyDown(e) {
|
|
if (e.key !== 'Tab' && (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)) {
|
|
return;
|
|
}
|
|
|
|
if (isValidFocusTarget(document.activeElement)) {
|
|
addFocusVisibleAttribute(document.activeElement);
|
|
}
|
|
|
|
hadKeyboardEvent = true;
|
|
}
|
|
/**
|
|
* If at any point a user clicks with a pointing device, ensure that we change
|
|
* the modality away from keyboard.
|
|
* This avoids the situation where a user presses a key on an already focused
|
|
* element, and then clicks on a different element, focusing it with a
|
|
* pointing device, while we still think we're in keyboard modality.
|
|
* It also avoids the situation where a user presses on an element within a
|
|
* previously keyboard-focused element (i.e., `e.target` is not the previously
|
|
* focused element, but one of its descendants) and we need to remove the
|
|
* focus ring because a `blur` event doesn't occur.
|
|
*/
|
|
|
|
|
|
function onPointerDown(e) {
|
|
if (hadKeyboardEvent === true) {
|
|
removeAllFocusVisibleAttributes();
|
|
}
|
|
|
|
hadKeyboardEvent = false;
|
|
}
|
|
/**
|
|
* On `focus`, add the `focus-visible` attribute to the target if:
|
|
* - the target received focus as a result of keyboard navigation, or
|
|
* - the event target is an element that will likely require interaction
|
|
* via the keyboard (e.g. a text box)
|
|
*/
|
|
|
|
|
|
function onFocus(e) {
|
|
// Prevent IE from focusing the document or HTML element.
|
|
if (!isValidFocusTarget(e.target)) {
|
|
return;
|
|
}
|
|
|
|
if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) {
|
|
addFocusVisibleAttribute(e.target);
|
|
}
|
|
}
|
|
/**
|
|
* On `blur`, remove the `focus-visible` attribute from the target.
|
|
*/
|
|
|
|
|
|
function onBlur(e) {
|
|
if (!isValidFocusTarget(e.target)) {
|
|
return;
|
|
}
|
|
|
|
if (e.target.hasAttribute(focusVisibleAttributeName)) {
|
|
// To detect a tab/window switch, we look for a blur event followed
|
|
// rapidly by a visibility change.
|
|
// If we don't see a visibility change within 100ms, it's probably a
|
|
// regular focus change.
|
|
hadFocusVisibleRecently = true;
|
|
window.clearTimeout(hadFocusVisibleRecentlyTimeout);
|
|
hadFocusVisibleRecentlyTimeout = window.setTimeout(function () {
|
|
hadFocusVisibleRecently = false;
|
|
window.clearTimeout(hadFocusVisibleRecentlyTimeout);
|
|
}, 100);
|
|
removeFocusVisibleAttribute(e.target);
|
|
}
|
|
}
|
|
/**
|
|
* If the user changes tabs, keep track of whether or not the previously
|
|
* focused element had the focus-visible attribute.
|
|
*/
|
|
|
|
|
|
function onVisibilityChange(e) {
|
|
if (document.visibilityState === 'hidden') {
|
|
// If the tab becomes active again, the browser will handle calling focus
|
|
// on the element (Safari actually calls it twice).
|
|
// If this tab change caused a blur on an element with focus-visible,
|
|
// re-apply the attribute when the user switches back to the tab.
|
|
if (hadFocusVisibleRecently) {
|
|
hadKeyboardEvent = true;
|
|
}
|
|
|
|
addInitialPointerMoveListeners();
|
|
}
|
|
}
|
|
/**
|
|
* Add a group of listeners to detect usage of any pointing devices.
|
|
* These listeners will be added when the polyfill first loads, and anytime
|
|
* the window is blurred, so that they are active when the window regains
|
|
* focus.
|
|
*/
|
|
|
|
|
|
function addInitialPointerMoveListeners() {
|
|
document.addEventListener('mousemove', onInitialPointerMove);
|
|
document.addEventListener('mousedown', onInitialPointerMove);
|
|
document.addEventListener('mouseup', onInitialPointerMove);
|
|
document.addEventListener('pointermove', onInitialPointerMove);
|
|
document.addEventListener('pointerdown', onInitialPointerMove);
|
|
document.addEventListener('pointerup', onInitialPointerMove);
|
|
document.addEventListener('touchmove', onInitialPointerMove);
|
|
document.addEventListener('touchstart', onInitialPointerMove);
|
|
document.addEventListener('touchend', onInitialPointerMove);
|
|
}
|
|
|
|
function removeInitialPointerMoveListeners() {
|
|
document.removeEventListener('mousemove', onInitialPointerMove);
|
|
document.removeEventListener('mousedown', onInitialPointerMove);
|
|
document.removeEventListener('mouseup', onInitialPointerMove);
|
|
document.removeEventListener('pointermove', onInitialPointerMove);
|
|
document.removeEventListener('pointerdown', onInitialPointerMove);
|
|
document.removeEventListener('pointerup', onInitialPointerMove);
|
|
document.removeEventListener('touchmove', onInitialPointerMove);
|
|
document.removeEventListener('touchstart', onInitialPointerMove);
|
|
document.removeEventListener('touchend', onInitialPointerMove);
|
|
}
|
|
/**
|
|
* When the polfyill first loads, assume the user is in keyboard modality.
|
|
* If any event is received from a pointing device (e.g. mouse, pointer,
|
|
* touch), turn off keyboard modality.
|
|
* This accounts for situations where focus enters the page from the URL bar.
|
|
*/
|
|
|
|
|
|
function onInitialPointerMove(e) {
|
|
// Work around a Safari quirk that fires a mousemove on <html> whenever the
|
|
// window blurs, even if you're tabbing out of the page. ¯\_(ツ)_/¯
|
|
if (e.target.nodeName === 'HTML') {
|
|
return;
|
|
}
|
|
|
|
hadKeyboardEvent = false;
|
|
removeInitialPointerMoveListeners();
|
|
}
|
|
|
|
document.addEventListener('keydown', onKeyDown, true);
|
|
document.addEventListener('mousedown', onPointerDown, true);
|
|
document.addEventListener('pointerdown', onPointerDown, true);
|
|
document.addEventListener('touchstart', onPointerDown, true);
|
|
document.addEventListener('focus', onFocus, true);
|
|
document.addEventListener('blur', onBlur, true);
|
|
document.addEventListener('visibilitychange', onVisibilityChange, true);
|
|
addInitialPointerMoveListeners();
|
|
};
|
|
|
|
export default modality; |