diff --git a/src/core.js b/src/core.js index 6d4a0c28..13469fbe 100644 --- a/src/core.js +++ b/src/core.js @@ -18,6 +18,9 @@ import View from './components/View'; // modules import createDOMElement from './modules/createDOMElement'; +import modality from './modules/modality'; + +modality(); const ReactNativeCore = { createDOMElement, diff --git a/src/index.js b/src/index.js index bfb5b6ae..018e814e 100644 --- a/src/index.js +++ b/src/index.js @@ -39,6 +39,7 @@ import View from './components/View'; // modules import createDOMElement from './modules/createDOMElement'; +import modality from './modules/modality'; import NativeModules from './modules/NativeModules'; // propTypes @@ -46,6 +47,8 @@ import ColorPropType from './propTypes/ColorPropType'; import EdgeInsetsPropType from './propTypes/EdgeInsetsPropType'; import PointPropType from './propTypes/PointPropType'; +modality(); + const ReactNative = { // top-level API findNodeHandle, diff --git a/src/modules/modality/index.js b/src/modules/modality/index.js new file mode 100644 index 00000000..58bf4601 --- /dev/null +++ b/src/modules/modality/index.js @@ -0,0 +1,97 @@ +/* global document, window */ + +/** + * 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); + * + * Based on https://github.com/WICG/modality + */ +const modality = () => { + /** + * Determine whether the keyboard is required when an element is focused + */ + const proto = window.Element.prototype; + const matcher = proto.matches || proto.mozMatchesSelector || proto.msMatchesSelector || proto.webkitMatchesSelector; + const keyboardModalityWhitelist = [ + 'input:not([type])', + 'input[type=text]', + 'input[type=number]', + 'input[type=date]', + 'input[type=time]', + 'input[type=datetime]', + 'textarea', + '[role=textbox]', + // indicates that a custom element supports the keyboard + '[supports-modality=keyboard]' + ].join(','); + + const focusTriggersKeyboardModality = (el) => { + if (matcher) { + return matcher.call(el, keyboardModalityWhitelist) && matcher.call(el, ':not([readonly])'); + } else { + return false; + } + }; + + /** + * Disable the focus ring by default + */ + const id = 'modality__'; + const style = ``; + document.head.insertAdjacentHTML('afterbegin', style); + const styleElement = document.getElementById(id); + + const disableFocus = () => { + if (styleElement) { + styleElement.disabled = false; + } + }; + + const enableFocus = () => { + if (styleElement) { + styleElement.disabled = true; + } + }; + + /** + * Manage the modality focus state + */ + let keyboardTimer; + let hadKeyboardEvent = false; + + // 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); +}; + +export default modality;