diff --git a/src/modules/modality/index.js b/src/modules/modality/index.js index 247daefb..6cffa833 100644 --- a/src/modules/modality/index.js +++ b/src/modules/modality/index.js @@ -15,110 +15,130 @@ import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment'; * 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); * - * Based on https://github.com/WICG/modality + * Based on https://github.com/WICG/focus-ring */ const modality = () => { if (!canUseDOM) { return; } - /** - * Determine whether the keyboard is required when an element is focused - */ + let styleElement; + let hadKeyboardEvent = false; + let keyboardThrottleTimeoutID = 0; + const proto = window.Element.prototype; - const matcher = proto.matches || + const matches = proto.matches || proto.mozMatchesSelector || proto.msMatchesSelector || proto.webkitMatchesSelector; + + // These elements should always have a focus ring drawn, because they are + // associated with switching to a keyboard modality. const keyboardModalityWhitelist = [ 'input:not([type])', 'input[type=text]', + 'input[type=search]', + 'input[type=url]', + 'input[type=tel]', + 'input[type=email]', + 'input[type=password]', 'input[type=number]', 'input[type=date]', + 'input[type=month]', + 'input[type=week]', 'input[type=time]', 'input[type=datetime]', + 'input[type=datetime-local]', 'textarea', '[role=textbox]', - // indicates that a custom element supports the keyboard - '[supports-modality=keyboard]' ].join(','); + /** + * Disable the focus ring by default + */ + const initialize = () => { + // check if the style sheet needs to be created + const id = 'react-native-modality'; + styleElement = document.getElementById(id); + if (!styleElement) { + // removes focus styles by default + const style = ``; + document.head.insertAdjacentHTML('afterbegin', style); + styleElement = document.getElementById(id); + } + } + + /** + * Computes whether the given element should automatically trigger the + * `focus-ring`. + */ const focusTriggersKeyboardModality = el => { - if (matcher) { - return matcher.call(el, keyboardModalityWhitelist) && matcher.call(el, ':not([readonly])'); + if (matches) { + return matches.call(el, keyboardModalityWhitelist) && matches.call(el, ':not([readonly])'); } else { return false; } }; /** - * Disable the focus ring by default + * Add the focus ring to the focused element */ - const id = 'react-native-modality'; - let styleElement = document.getElementById(id); - if (!styleElement) { - const style = ``; - document.head.insertAdjacentHTML('afterbegin', style); - styleElement = document.getElementById(id); + const addFocusRing = () => { + if (styleElement) { + styleElement.disabled = true; + } } - const disableFocus = () => { + /** + * Remove the focus ring + */ + const removeFocusRing = () => { if (styleElement) { styleElement.disabled = false; } }; - const enableFocus = () => { - if (styleElement) { - styleElement.disabled = true; + /** + * On `keydown`, set `hadKeyboardEvent`, to be removed 100ms later if there + * are no further keyboard events. The 100ms throttle handles cases where + * focus is redirected programmatically after a keyboard event, such as + * opening a menu or dialog. + */ + const handleKeyDown = (e) => { + hadKeyboardEvent = true; + if (keyboardThrottleTimeoutID !== 0) { + clearTimeout(keyboardThrottleTimeoutID); + } + keyboardThrottleTimeoutID = setTimeout(() => { + hadKeyboardEvent = false; + keyboardThrottleTimeoutID = 0; + }, 100); + }; + + /** + * Display the focus-ring when the keyboard was used to focus + */ + const handleFocus = (e) => { + if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) { + addFocusRing(); } }; /** - * Manage the modality focus state + * Remove the focus-ring when the keyboard was used to focus */ - let keyboardTimer; - let hadKeyboardEvent = false; + const handleBlur = () => { + if (!hadKeyboardEvent) { + removeFocusRing(); + } + }; - // track when the keyboard is in use - document.body.addEventListener( - 'keydown', - () => { - hadKeyboardEvent = true; - if (keyboardTimer) { - clearTimeout(keyboardTimer); - } - keyboardTimer = setTimeout( - () => { - hadKeyboardEvent = false; - }, - 100 - ); - }, - true - ); - - // disable focus style reset when the keyboard is in use - document.body.addEventListener( - 'focus', - e => { - if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) { - enableFocus(); - } - }, - true - ); - - // enable focus style reset when keyboard is no longer in use - document.body.addEventListener( - 'blur', - () => { - if (!hadKeyboardEvent) { - disableFocus(); - } - }, - true - ); + if (document.body && document.body.addEventListener) { + initialize(); + document.body.addEventListener('keydown', handleKeyDown, true); + document.body.addEventListener('focus', handleFocus, true); + document.body.addEventListener('blur', handleBlur, true); + } }; export default modality;