docbrown/app/assets/javascripts/base.js.erb
Marcy Sutton f072730f0f
Header navigation dropdown accessibility (#11509)
* Improve keyboard a11y of header menu dropdown

This commit also includes some cleanup of unnecessary functions that seemed to degrade performance.

Closes https://github.com/forem/forem/issues/1154

* Add temporary focus style on navigation-button

* Put menu items in a list

* Adjust menu button based on VoiceOver testing

* Refactor menu logic to be reusable/work on touch

* Preserve admin link visibility

* Focus on first item on menu open

* Clean up some HTML and CSS

* Apply suggestions from code review

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>

* Ensure menu hides on click outside

* Rename toggle function and adjust formatting

* Update nav button focus style

* Clean up padding on header avatar focus style

* Make button show focus state for keyboard only

Using .focus-visible:focus targets keyboard focus and eliminates a flash of the blue border on click before focus is moved to the child item (which also has no focus style on mouse click)

* Update app/views/layouts/_top_bar.html.erb

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>

* Add some defensive programming

* Removed initializeTouchDevice from base.js.erb

* navigation-butt ID is now member-menu-button

* Moved all the logic from initializeTouchDevice.js into a pack file/utilities.

* committing re-ordered schema after setup

* add tests for initializeTouchDevice

* remove some unneeded html setup

* make sure menu doesn't close if user tabs back from sign out

* Revert "committing re-ordered schema after setup"

This reverts commit a41a1c861cca3b97d8a7b8a99268b8afaae9f028.

* optimized code

* small tweaks, only show outline when focused

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>
Co-authored-by: rhymes <rhymes@hey.com>
Co-authored-by: Nick Taylor <nick@dev.to>
Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
2021-01-27 09:39:17 +00:00

726 lines
19 KiB
Text

//= require_tree ./initializers
//= require_tree ./utilities
//= require lib/xss
//= require initializePage
//= require utilities/getImageForLink
//= require honeybadger-js/dist/honeybadger.js
//= require ahoy.js/dist/ahoy.js
//= require serviceworker-companion
var instantClick
, InstantClick = instantClick = function(document, location, $userAgent) {
// Internal variables
var $isChromeForIOS = $userAgent.indexOf(' CriOS/') > -1
, $currentLocationWithoutHash
, $urlToPreload
, $preloadTimer
, $lastTouchTimestamp
// Preloading-related variables
, $history = {}
, $xhr
, $url = false
, $mustRedirect = false
, $fetchedBodies = {}
, $timing = {}
, $isPreloading = false
, $isWaitingForCompletion = false
, $trackedAssets = []
// Variables defined by public functions
, $preloadOnMousedown
, $delayBeforePreload
, $eventsCallbacks = {
fetch: [],
receive: [],
wait: [],
change: [],
restore: []
}
////////// HELPERS //////////
function removeHash(url) {
var index = url.indexOf('#')
if (index < 0) {
return url
}
return url.substr(0, index)
}
function getLinkTarget(target) {
while (target && target.nodeName != 'A') {
target = target.parentNode
}
return target
}
function isNotPreloadable(elem) {
do {
if (!elem.hasAttribute) { // Parent of <html>
break
}
if (elem.hasAttribute('data-instant')) {
return false
}
if (elem.hasAttribute('data-no-instant')) {
return true
}
}
while (elem = elem.parentNode)
return false
}
function isPreloadable(a) {
var domain = location.protocol + '//' + location.host
if (a.target // target="_blank" etc.
|| a.hasAttribute('download')
|| a.href.indexOf(domain + '/') != 0 // Another domain, or no href attribute
|| (a.href.indexOf('#') > -1
&& removeHash(a.href) == $currentLocationWithoutHash) // Anchor
|| isNotPreloadable(a)
) {
return false
}
return true
}
function triggerPageEvent(eventType, arg1, arg2, arg3) {
var returnValue = false
for (var i = 0; i < $eventsCallbacks[eventType].length; i++) {
if (eventType == 'receive') {
var altered = $eventsCallbacks[eventType][i](arg1, arg2, arg3)
if (altered) {
/* Update args for the next iteration of the loop. */
if ('body' in altered) {
arg2 = altered.body
}
if ('title' in altered) {
arg3 = altered.title
}
returnValue = altered
}
}
else {
$eventsCallbacks[eventType][i](arg1, arg2, arg3)
}
}
return returnValue
}
function changePage(title, body, newUrl, scrollY, pop) {
var pageContentDiv = document.getElementById("page-content");
var memberMenuButton = document.getElementById("member-menu-button")
if (!memberMenuButton) {
memberMenuButton.classList.remove('showing')
}
document.getElementsByTagName("BODY")[0].replaceChild(body, pageContentDiv)
var prog = document.getElementById("navigation-progress");
prog.classList.remove("showing");
if (newUrl) {
history.pushState(null, null, newUrl.replace("?samepage=true","").replace("&samepage=true",""))
var hashIndex = newUrl.indexOf('#'),
hashElem = hashIndex > -1 && (
document.getElementById(newUrl.substr(hashIndex + 1)) ||
document.querySelector(`[name=${newUrl.substr(hashIndex + 1)}].anchor`)
),
offset = 0,
samePage = newUrl.indexOf("samepage=true") > -1;
if (hashElem) {
while (hashElem.offsetParent) {
offset += hashElem.offsetTop
hashElem = hashElem.offsetParent
}
}
if (!samePage){
scrollTo(0, offset)
}
$currentLocationWithoutHash = removeHash(newUrl)
}
else {
scrollTo(0, scrollY)
}
if ($isChromeForIOS && document.title == title) {
/* Chrome for iOS:
*
* 1. Removes title on pushState, so the title needs to be set after.
*
* 2. Will not set the title if it's identical when trimmed, so
* appending a space won't do; but a non-breaking space works.
*/
document.title = title + String.fromCharCode(160)
}
else {
document.title = title
}
instantanize()
if (pop) {
triggerPageEvent('restore')
}
else {
triggerPageEvent('change', false)
}
}
function setPreloadingAsHalted() {
$isPreloading = false
$isWaitingForCompletion = false
}
function removeNoscriptTags(html) {
/* Must be done on text, not on a node's innerHTML, otherwise strange
* things happen with implicitly closed elements (see the Noscript test).
*/
return html.replace(/<noscript[\s\S]+?<\/noscript>/gi, '')
}
////////// EVENT LISTENERS //////////
function mousedownListener(e) {
if ($lastTouchTimestamp > (+new Date - 500)) {
return // Otherwise, click doesn't fire
}
var a = getLinkTarget(e.target)
if (!a || !isPreloadable(a)) {
return
}
preload(a.href)
}
function mouseoverListener(e) {
if ($lastTouchTimestamp > (+new Date - 500)) {
return // Otherwise, click doesn't fire
}
var a = getLinkTarget(e.target)
if (!a || !isPreloadable(a)) {
return
}
a.addEventListener('mouseout', mouseoutListener)
if (!$delayBeforePreload) {
preload(a.href)
}
else {
$urlToPreload = a.href
$preloadTimer = setTimeout(preload, $delayBeforePreload)
}
getImageForLink(a);
}
function touchstartListener(e) {
$lastTouchTimestamp = +new Date
var a = getLinkTarget(e.target)
if (!a || !isPreloadable(a)) {
return
}
if ($preloadOnMousedown) {
a.removeEventListener('mousedown', mousedownListener)
}
else {
a.removeEventListener('mouseover', mouseoverListener)
}
preload(a.href);
getImageForLink(a);
}
// If a link is focused, it is preloaded just like on mousover.
// It also covers the issue where a user needs to press <return>
// twice in order to follow a focused link.
function focusListener(e) {
var a = getLinkTarget(e.target)
if (!a || !isPreloadable(a)) {
return
}
if (!$delayBeforePreload) {
preload(a.href)
}
else {
$urlToPreload = a.href
$preloadTimer = setTimeout(preload, $delayBeforePreload)
}
getImageForLink(a);
}
function clickListener(e) {
try{
var a = getLinkTarget(e.target)
if (!a || !isPreloadable(a)) {
return
}
if (e.which > 1 || e.metaKey || e.ctrlKey) { // Opening in new tab
return
}
display(a.href);
e.preventDefault();
}
catch(err){
console.log(err);
}
}
function mouseoutListener() {
if ($preloadTimer) {
clearTimeout($preloadTimer)
$preloadTimer = false
return
}
if (!$isPreloading || $isWaitingForCompletion) {
return
}
$xhr.abort()
setPreloadingAsHalted()
}
function readystatechangeListener() {
processXHR($xhr,$url);
}
function processXHR(xhr,url) {
if (xhr.readyState < 4) {
return
}
if (xhr.status == 0) {
/* Request aborted */
return
}
$timing.ready = +new Date - $timing.start
var pageContentDiv = document.getElementById("page-content");
if (pageContentDiv && xhr.status === 200 && xhr.getResponseHeader('Content-Type').match(/\/(x|ht|xht)ml/)) {
var doc = document.implementation.createHTMLDocument('');
doc.documentElement.innerHTML = removeNoscriptTags(xhr.responseText)
var title = doc.title
var body = doc.getElementById("page-content")
var alteredOnReceive = triggerPageEvent('receive', url, body, title)
if (alteredOnReceive) {
if ('body' in alteredOnReceive) {
body = alteredOnReceive.body
}
if ('title' in alteredOnReceive) {
title = alteredOnReceive.title
}
}
$fetchedBodies[url] = {body:body, title:title};
var urlWithoutHash = removeHash(url)
var elems = doc.head.children
, found = 0
, elem
, data
for (var i = 0; i < elems.length; i++) {
elem = elems[i]
if (elem.hasAttribute('data-instant-track')) {
data = elem.getAttribute('href') || elem.getAttribute('src') || elem.innerHTML
for (var j = 0; j < $trackedAssets.length; j++) {
if ($trackedAssets[j] == data) {
found++
}
}
}
}
if (found != $trackedAssets.length) {
$mustRedirect = true // Assets have changed
}
}
else {
$mustRedirect = true // Not an HTML document
}
if ($isWaitingForCompletion && $url === url) {
$isWaitingForCompletion = false
display($url)
}
}
function popstateListener() {
var loc = removeHash(location.href)
if (loc == $currentLocationWithoutHash) {
return
}
if (!(loc in $history)) {
location.href = location.href
return
}
$history[$currentLocationWithoutHash] = {
body: document.getElementById("page-content"),
title: document.title,
scrollY: pageYOffset
}
$currentLocationWithoutHash = loc
changePage($history[loc].title, $history[loc].body, false, $history[loc].scrollY, true)
}
////////// MAIN FUNCTIONS //////////
function instantanize(isInitializing) {
var docBody = document.body;
if (docBody) {
document.body.addEventListener('touchstart', touchstartListener, true)
document.body.addEventListener('focus', focusListener, true)
if ($preloadOnMousedown) {
document.body.addEventListener('mousedown', mousedownListener, true)
} else {
document.body.addEventListener('mouseover', mouseoverListener, true)
}
document.body.addEventListener('click', clickListener, true)
}
if (!isInitializing) {
var scriptElementsInDOM = document.body.getElementsByTagName('script')
, scriptElementsToCopy = []
, originalElement
, copyElement
, parentNode
, nextSibling
, i
// `scriptElementsInDOM` will change during the copy of scripts if
// a script add or delete script elements, so we need to put script
// elements in an array to loop through them correctly.
for (i = 0; i < scriptElementsInDOM.length; i++) {
scriptElementsToCopy.push(scriptElementsInDOM[i])
}
for (i = 0; i < scriptElementsToCopy.length; i++) {
originalElement = scriptElementsToCopy[i]
if (!originalElement) { // Might have disappeared, see previous comment
continue
}
if (originalElement.hasAttribute('data-no-instant')) {
continue
}
copyElement = document.createElement('script')
for (var j = 0; j < originalElement.attributes.length; j++) {
copyElement.setAttribute(originalElement.attributes[j].name, originalElement.attributes[j].value)
}
copyElement.textContent = originalElement.textContent
parentNode = originalElement.parentNode
nextSibling = originalElement.nextSibling
parentNode.removeChild(originalElement)
parentNode.insertBefore(copyElement, nextSibling)
}
}
}
function preload(url, option) {
if (!$preloadOnMousedown
&& 'display' in $timing
&& +new Date - ($timing.start + $timing.display) < 100) {
return
}
if ($preloadTimer) {
clearTimeout($preloadTimer)
$preloadTimer = false
}
if (!url) {
url = $urlToPreload
}
if ($isPreloading && (url == $url || $isWaitingForCompletion)) {
return
}
$isPreloading = true
$isWaitingForCompletion = false
$mustRedirect = false
$timing = {
start: +new Date
}
if (url.indexOf("?") == -1) {
var internalUrl = url+"?i=i"
}
else {
var internalUrl = url+"&i=i"
}
removeExpiredKeys()
triggerPageEvent('fetch')
if (!$fetchedBodies[url]){
if (option === "force") {
getURLInfo(url, function () {
processXHR(this,url);
})
}
else {
$url = url
$xhr.open('GET', internalUrl)
$xhr.send()
}
}
}
function removeExpiredKeys(option) {
if ( Object.keys($fetchedBodies).length > 13 || option == "force" ) {
$fetchedBodies = {};
}
}
function getURLInfo(url, callback) {
var xhr = new XMLHttpRequest();
if (url.indexOf("?") == -1) {
var internalUrl = url+"?i=i"
}
else {
var internalUrl = url+"&i=i"
}
xhr.open (
"GET",
internalUrl,
true
);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
// defensive check
if (typeof callback == "function") {
// apply() sets the meaning of "this" in the callback
callback.apply(xhr);
}
}
}
// send the request *after* the event handler is defined
xhr.send();
}
function display(url) {
$url = url;
if($fetchedBodies[url]){
var body = $fetchedBodies[url]['body'];
var title = $fetchedBodies[url]['title'];
}
else {
var prog = document.getElementById("navigation-progress");
prog.classList.add("showing");
}
if (!('display' in $timing)) {
$timing.display = +new Date - $timing.start
}
if ($preloadTimer || !$isPreloading) {
if ($preloadTimer && $url && $url != url) {
location.href = url
return
}
preload(url)
triggerPageEvent('wait')
$isWaitingForCompletion = true // Must be set *after* calling `preload`
return
}
if ($isWaitingForCompletion) {
/* The user clicked on a link while a page was preloading. Either on
the same link or on another link. If it's the same link something
might have gone wrong (or he could have double clicked, we don't
handle that case), so we send him to the page without pjax.
If it's another link, it hasn't been preloaded, so we redirect the
user to it.
*/
location.href = url
return
}
if ($mustRedirect) {
location.href = $url
return
}
if (!body) {
triggerPageEvent('wait')
$isWaitingForCompletion = true
return
}
$history[$currentLocationWithoutHash] = {
body: document.getElementById("page-content"),
title: document.title,
scrollY: pageYOffset
}
setPreloadingAsHalted()
changePage(title, body, $url)
}
////////// PUBLIC VARIABLE AND FUNCTIONS //////////
var supported = 'pushState' in history
&& (!$userAgent.match('Android') || $userAgent.match('Chrome/') || $userAgent.match('Firefox/'))
&& location.protocol != "file:"
/* The (sad) state of Android's AOSP browsers:
2.3.7: pushState appears to work correctly, but
`doc.documentElement.innerHTML = body` is buggy.
Update: InstantClick doesn't use that anymore, but it may
fail where 3.0 do, this needs testing again.
3.0: pushState appears to work correctly (though the address bar is
only updated on focus), but
`document.documentElement.replaceChild(doc.body, document.body)`
throws DOMException: WRONG_DOCUMENT_ERR.
4.0.2: Doesn't support pushState.
4.0.4,
4.1.1,
4.2,
4.3: Claims support for pushState, but doesn't update the address bar.
4.4: Works correctly. Claims to be 'Chrome/30.0.0.0'.
All androids tested with Android SDK's Emulator.
Version numbers are from the browser's user agent.
Because of this mess, the only allowed browser on Android is Chrome.
*/
function init(preloadingMode) {
if ($currentLocationWithoutHash) {
/* Already initialized */
return
}
if (!supported) {
triggerPageEvent('change', true)
return
}
if (preloadingMode == 'mousedown') {
$preloadOnMousedown = true
}
else if (typeof preloadingMode == 'number') {
$delayBeforePreload = preloadingMode
}
$currentLocationWithoutHash = removeHash(location.href)
$history[$currentLocationWithoutHash] = {
body: document.getElementById("page-content"),
title: document.title,
scrollY: pageYOffset
}
var elems = document.head.children
, elem
, data
for (var i = 0; i < elems.length; i++) {
elem = elems[i]
if (elem.hasAttribute('data-instant-track')) {
data = elem.getAttribute('href') || elem.getAttribute('src') || elem.innerHTML
/* We can't use just `elem.href` and `elem.src` because we can't
retrieve `href`s and `src`s from the Ajax response.
*/
$trackedAssets.push(data)
}
}
$xhr = new XMLHttpRequest()
$xhr.addEventListener('readystatechange', readystatechangeListener)
instantanize(true)
triggerPageEvent('change', true)
addEventListener('popstate', popstateListener)
addRefreshBehavior();
}
function on(eventType, callback) {
$eventsCallbacks[eventType].push(callback)
}
function addRefreshBehavior(){
if (!("ontouchstart" in document.documentElement)) {
return
}
var script = document.createElement('script');
script.src = "<%= javascript_path 'lib/pulltorefresh.js' %>";
document.head.appendChild(script);
var waitingOnPTR = setInterval(function(){
if (typeof PullToRefresh !== 'undefined') {
var ptr = PullToRefresh.init({
mainElement: 'body',
passive: true,
onRefresh: function(){
window.location.reload();
}
});
clearInterval(waitingOnPTR)
}
}, 1)
}
////////////////////
return {
supported: supported,
init: init,
isPreloadable: isPreloadable,
preload: preload,
removeExpiredKeys: removeExpiredKeys,
display: display,
on: on
}
}(document, location, navigator.userAgent);
// FUNCTIONAL CODE FOR PAGE
function initializeBaseApp() {
InstantClick.on('change', function() {
initializePage();
});
InstantClick.init();
}
// INITIALIZE/ERROR HANDLING
Honeybadger.configure({
apiKey: document.body.dataset.honeybadgerKey,
environment: "<%= Rails.env %>",
revision: document.body.dataset.releaseFootprint,
ignorePatterns: [/ResizeObserver/i, /MetaMask/i],
});
// INITIALIZE AHOY TRACKING
// Setting cookies to false matches what we do in ahoy's initializer.
// Setting trackVisits to false prevents ahoy from creating a visit on the server-side.
ahoy.configure({
cookies: false,
trackVisits: false
});
// Start BaseApp for Page
initializeBaseApp()