From 385a3e642863a456ea56745fe2a01e0ae2b3ccb0 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 20 Jul 2019 18:08:54 +0200 Subject: [PATCH] Hide the focus ring for mouse only interaction --- .../templates/headIncludeJavaScript.tpl | 1 + .../install/files/acp/templates/header.tpl | 1 + wcfsetup/install/files/js/.buildOrder | 1 + .../polyfill/focus-visible.LICENSE.md | 5 + .../js/3rdParty/polyfill/focus-visible.js | 304 ++++++++++++++++++ .../install/files/style/layout/global.scss | 6 + 6 files changed, 318 insertions(+) create mode 100644 wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.LICENSE.md create mode 100644 wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.js diff --git a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl index 00a5acac99..5034ff32e7 100644 --- a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl +++ b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl @@ -161,6 +161,7 @@ requirejs.config({ {js application='wcf' lib='jquery-ui' hasTiny=true} {js application='wcf' lib='jquery-ui' file='touchPunch' bundle='WCF.Combined' hasTiny=true} {js application='wcf' lib='jquery-ui' file='nestedSortable' bundle='WCF.Combined' hasTiny=true} +{js application='wcf' lib='polyfill' file='focus-visible' bundle='WCF.Combined' hasTiny=true} {js application='wcf' file='WCF.Assets' bundle='WCF.Combined' hasTiny=true} {js application='wcf' file='WCF' bundle='WCF.Combined' hasTiny=true} diff --git a/wcfsetup/install/files/acp/templates/header.tpl b/wcfsetup/install/files/acp/templates/header.tpl index 89432be481..78670fdf11 100644 --- a/wcfsetup/install/files/acp/templates/header.tpl +++ b/wcfsetup/install/files/acp/templates/header.tpl @@ -168,6 +168,7 @@ {js application='wcf' lib='jquery-ui'} {js application='wcf' lib='jquery-ui' file='touchPunch' bundle='WCF.Combined'} {js application='wcf' lib='jquery-ui' file='nestedSortable' bundle='WCF.Combined'} + {js application='wcf' lib='polyfill' file='focus-visible' bundle='WCF.Combined' hasTiny=true} {js application='wcf' file='WCF.Assets' bundle='WCF.Combined'} {js application='wcf' file='WCF' bundle='WCF.Combined'} {js application='wcf' acp='true' file='WCF.ACP'} diff --git a/wcfsetup/install/files/js/.buildOrder b/wcfsetup/install/files/js/.buildOrder index 156be11875..c573d15cfe 100644 --- a/wcfsetup/install/files/js/.buildOrder +++ b/wcfsetup/install/files/js/.buildOrder @@ -2,6 +2,7 @@ 3rdParty/jquery-ui 3rdParty/jquery-ui/touchPunch 3rdParty/jquery-ui/nestedSortable +3rdParty/polyfill/focus-visible WCF.Assets WCF WCF.Like diff --git a/wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.LICENSE.md b/wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.LICENSE.md new file mode 100644 index 0000000000..98fad556b6 --- /dev/null +++ b/wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.LICENSE.md @@ -0,0 +1,5 @@ +All Reports in this Repository are licensed by Contributors under the +[W3C Software and Document +License](http://www.w3.org/Consortium/Legal/2015/copyright-software-and-document). Contributions to +Specifications are made under the [W3C CLA](https://www.w3.org/community/about/agreements/cla/). + diff --git a/wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.js b/wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.js new file mode 100644 index 0000000000..f02c3ffe7d --- /dev/null +++ b/wcfsetup/install/files/js/3rdParty/polyfill/focus-visible.js @@ -0,0 +1,304 @@ +/** + * Applies the :focus-visible polyfill at the given scope. + * A scope in this case is either the top-level Document or a Shadow Root. + * + * @param {(Document|ShadowRoot)} scope + * @see https://github.com/WICG/focus-visible + */ +function applyFocusVisiblePolyfill(scope) { + 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. + * @param {Element} el + */ + 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` class being added, i.e. whether it should always match + * `:focus-visible` when focused. + * @param {Element} el + * @return {boolean} + */ + function focusTriggersKeyboardModality(el) { + var type = el.type; + var tagName = el.tagName; + + if (tagName == 'INPUT' && inputTypesWhitelist[type] && !el.readOnly) { + return true; + } + + if (tagName == 'TEXTAREA' && !el.readOnly) { + return true; + } + + if (el.isContentEditable) { + return true; + } + + return false; + } + + /** + * Add the `focus-visible` class to the given element if it was not added by + * the author. + * @param {Element} el + */ + function addFocusVisibleClass(el) { + if (el.classList.contains('focus-visible')) { + return; + } + el.classList.add('focus-visible'); + el.setAttribute('data-focus-visible-added', ''); + } + + /** + * Remove the `focus-visible` class from the given element if it was not + * originally added by the author. + * @param {Element} el + */ + function removeFocusVisibleClass(el) { + if (!el.hasAttribute('data-focus-visible-added')) { + return; + } + el.classList.remove('focus-visible'); + el.removeAttribute('data-focus-visible-added'); + } + + /** + * If the most recent user interaction was via the keyboard; + * and the key press did not include a meta, alt/option, or control key; + * then the modality is keyboard. Otherwise, the modality is not keyboard. + * Apply `focus-visible` to any current active element and keep track + * of our keyboard modality state with `hadKeyboardEvent`. + * @param {KeyboardEvent} e + */ + function onKeyDown(e) { + if (e.metaKey || e.altKey || e.ctrlKey) { + return; + } + + if (isValidFocusTarget(scope.activeElement)) { + addFocusVisibleClass(scope.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. + * @param {Event} e + */ + function onPointerDown(e) { + hadKeyboardEvent = false; + } + + /** + * On `focus`, add the `focus-visible` class 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) + * @param {Event} e + */ + function onFocus(e) { + // Prevent IE from focusing the document or HTML element. + if (!isValidFocusTarget(e.target)) { + return; + } + + if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) { + addFocusVisibleClass(e.target); + } + } + + /** + * On `blur`, remove the `focus-visible` class from the target. + * @param {Event} e + */ + function onBlur(e) { + if (!isValidFocusTarget(e.target)) { + return; + } + + if ( + e.target.classList.contains('focus-visible') || + e.target.hasAttribute('data-focus-visible-added') + ) { + // 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); + removeFocusVisibleClass(e.target); + } + } + + /** + * If the user changes tabs, keep track of whether or not the previously + * focused element had .focus-visible. + * @param {Event} e + */ + 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 class 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. + * @param {Event} e + */ + function onInitialPointerMove(e) { + // Work around a Safari quirk that fires a mousemove on whenever the + // window blurs, even if you're tabbing out of the page. ¯\_(ツ)_/¯ + if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') { + return; + } + + hadKeyboardEvent = false; + removeInitialPointerMoveListeners(); + } + + // For some kinds of state, we are interested in changes at the global scope + // only. For example, global pointer input, global key presses and global + // visibility change should affect the state at every scope: + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('mousedown', onPointerDown, true); + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('touchstart', onPointerDown, true); + document.addEventListener('visibilitychange', onVisibilityChange, true); + + addInitialPointerMoveListeners(); + + // For focus and blur, we specifically care about state changes in the local + // scope. This is because focus / blur events that originate from within a + // shadow root are not re-dispatched from the host element if it was already + // the active element in its own scope: + scope.addEventListener('focus', onFocus, true); + scope.addEventListener('blur', onBlur, true); + + // We detect that a node is a ShadowRoot by ensuring that it is a + // DocumentFragment and also has a host property. This check covers native + // implementation and polyfill implementation transparently. If we only cared + // about the native implementation, we could just check if the scope was + // an instance of a ShadowRoot. + if (scope.nodeType === Node.DOCUMENT_FRAGMENT_NODE && scope.host) { + // Since a ShadowRoot is a special kind of DocumentFragment, it does not + // have a root element to add a class to. So, we add this attribute to the + // host element instead: + scope.host.setAttribute('data-js-focus-visible', ''); + } else if (scope.nodeType === Node.DOCUMENT_NODE) { + document.documentElement.classList.add('js-focus-visible'); + } +} + +// It is important to wrap all references to global window and document in +// these checks to support server-side rendering use cases +// @see https://github.com/WICG/focus-visible/issues/199 +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + // Make the polyfill helper globally available. This can be used as a signal + // to interested libraries that wish to coordinate with the polyfill for e.g., + // applying the polyfill to a shadow root: + window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill; + + // Notify interested libraries of the polyfill's presence, in case the + // polyfill was loaded lazily: + var event; + + try { + event = new CustomEvent('focus-visible-polyfill-ready'); + } catch (error) { + // IE11 does not support using CustomEvent as a constructor directly: + event = document.createEvent('CustomEvent'); + event.initCustomEvent('focus-visible-polyfill-ready', false, false, {}); + } + + window.dispatchEvent(event); +} + +if (typeof document !== 'undefined') { + // Apply the polyfill to the global document, so that no JavaScript + // coordination is required to use the polyfill in the top-level document: + applyFocusVisiblePolyfill(document); +} diff --git a/wcfsetup/install/files/style/layout/global.scss b/wcfsetup/install/files/style/layout/global.scss index 5783c79246..5e00189265 100644 --- a/wcfsetup/install/files/style/layout/global.scss +++ b/wcfsetup/install/files/style/layout/global.scss @@ -189,3 +189,9 @@ a.externalURL::after { position: absolute !important; width: 1px !important; } + +/* Hide the focus ring for mouse interactions, but support them for keyboard navigation. + See https://github.com/WICG/focus-visible and https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible */ +.js-focus-visible :focus:not(.focus-visible) { + outline: none; +} -- 2.20.1