From b0877275aa18b03af4b2a6c74c15d885364a9a12 Mon Sep 17 00:00:00 2001
From: Robert Lange <robert.lange@uni-leipzig.de>
Date: Thu, 24 Sep 2020 16:29:52 +0200
Subject: [PATCH] refs #17576 [master] adapt BARF keyboard and focus
 controllers from Micromodal

* backported for Vufind 5 to keep focus in modal when tabbing with keyboard
* also see https://github.com/vufind-org/vufind/pull/1667
---
 themes/finc/js/lightbox.js | 105 +++++++++++++++++++++++++++++++++----
 1 file changed, 95 insertions(+), 10 deletions(-)

diff --git a/themes/finc/js/lightbox.js b/themes/finc/js/lightbox.js
index 17e429708ba..1593b4d96b5 100644
--- a/themes/finc/js/lightbox.js
+++ b/themes/finc/js/lightbox.js
@@ -1,4 +1,4 @@
-/*global recaptchaOnLoad, resetCaptcha, VuFind, TEMPORARY BARF CK*/
+/*global grecaptcha, recaptchaOnLoad, resetCaptcha, VuFind, TEMPORARY BARF CK */
 VuFind.register('lightbox', function Lightbox() {
   // State
   var _originalUrl = false;
@@ -7,7 +7,7 @@ VuFind.register('lightbox', function Lightbox() {
   var refreshOnClose = false;
   var _modalParams = {};
   // Elements
-  var _modal, _modalBody, _modalTitle, _clickedButton = null;
+  var _modal, _modalBody, _clickedButton = null;
   // Utilities
   function _storeClickedStatus() {
     _clickedButton = this;
@@ -15,10 +15,11 @@ VuFind.register('lightbox', function Lightbox() {
   function _html(content) {
     _modalBody.html(content);
     // Set or update title if we have one
-    if (_lightboxTitle) {
-      _modalTitle.text(_lightboxTitle);
-      _lightboxTitle = false;
+    var $h2 = _modalBody.find("h2:first-of-type");
+    if (_lightboxTitle && $h2) {
+      $h2.text(_lightboxTitle);
     }
+    _lightboxTitle = false;
     _modal.modal('handleUpdate');
   }
   function _emit(msg, _details) {
@@ -329,7 +330,7 @@ VuFind.register('lightbox', function Lightbox() {
       submit.attr('disabled', 'disabled');
     }
     // Store custom title
-    _lightboxTitle = submit.data('lightboxTitle') || $(form).data('lightboxTitle') || '';
+    _lightboxTitle = submit.data('lightbox-title') || $(form).data('lightbox-title') || false;
     // Get Lightbox content
     ajax({
       url: $(form).attr('action') || _currentUrl,
@@ -343,6 +344,86 @@ VuFind.register('lightbox', function Lightbox() {
     return false;
   };
 
+  /**
+   * Keyboard and focus controllers
+   * Adapted from Micromodal
+   * - https://github.com/ghosh/Micromodal/blob/master/lib/src/index.js
+   * FIXME: backported for VuFind 5, remove with Vufind 7
+   */
+  var FOCUSABLE_ELEMENTS = ['a[href]', 'area[href]', 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', 'select:not([disabled]):not([aria-hidden])', 'textarea:not([disabled]):not([aria-hidden])', 'button:not([disabled]):not([aria-hidden])', 'iframe', 'object', 'embed', '[contenteditable]', '[tabindex]:not([tabindex^="-"])'];
+  function getFocusableNodes () {
+    var nodes = _modal[0].querySelectorAll(FOCUSABLE_ELEMENTS);
+    return Array.apply(null, nodes);
+  }
+  /**
+   * Tries to set focus on a node which is not a close trigger
+   * if no other nodes exist then focuses on first close trigger
+   */
+  function setFocusToFirstNode() {
+    var focusableNodes = getFocusableNodes();
+
+    // no focusable nodes
+    if (focusableNodes.length === 0) return;
+
+    // remove nodes on whose click, the modal closes
+    var nodesWhichAreNotCloseTargets = focusableNodes.filter(function(node)  {
+      return !node.hasAttribute("data-lightbox-close") && (
+          !node.hasAttribute("data-dismiss") ||
+          node.getAttribute("data-dismiss") != "modal"
+      );
+    });
+
+    if (nodesWhichAreNotCloseTargets.length > 0) nodesWhichAreNotCloseTargets[0].focus();
+    if (nodesWhichAreNotCloseTargets.length === 0) focusableNodes[0].focus();
+  }
+
+  function retainFocus(event) {
+    var focusableNodes = getFocusableNodes();
+
+    // no focusable nodes
+    if (focusableNodes.length === 0) return;
+
+    /**
+     * Filters nodes which are hidden to prevent
+     * focus leak outside modal
+     */
+    focusableNodes = focusableNodes.filter(function(node)  {
+      return (node.offsetParent !== null);
+    });
+
+    // if disableFocus is true
+    if (!_modal[0].contains(document.activeElement)) {
+      focusableNodes[0].focus();
+    } else {
+      var focusedItemIndex = focusableNodes.indexOf(document.activeElement);
+
+      if (event.shiftKey && focusedItemIndex === 0) {
+        focusableNodes[focusableNodes.length - 1].focus();
+        event.preventDefault();
+      }
+
+      if (!event.shiftKey && focusableNodes.length > 0 && focusedItemIndex === focusableNodes.length - 1) {
+        focusableNodes[0].focus();
+        event.preventDefault();
+      }
+    }
+  }
+  function onKeydown(e) {
+    if (event.keyCode === 27) { // esc
+      close();
+    }
+    if (event.keyCode === 9) { // tab
+      retainFocus(event);
+    }
+  }
+  function bindFocus() {
+    document.addEventListener('keydown', onKeydown);
+    setFocusToFirstNode();
+  }
+  function unbindFocus() {
+    document.removeEventListener('keydown', onKeydown);
+  }
+
   // Public: Attach listeners to the page
   function bind(el) {
     var target = el || document;
@@ -389,24 +470,28 @@ VuFind.register('lightbox', function Lightbox() {
     _html(VuFind.translate('loading') + '...');
     _originalUrl = false;
     _currentUrl = false;
-    _lightboxTitle = '';
+    _lightboxTitle = false;
     _modalParams = {};
   }
   function init() {
     _modal = $('#modal');
     _modalBody = _modal.find('.modal-body');
-    _modalTitle = _modal.find("#modal-title");
     _modal.on('hide.bs.modal', function lightboxHide() {
       if (VuFind.lightbox.refreshOnClose) {
         VuFind.refreshPage();
+      } else {
+        unbindFocus();
+        this.setAttribute('aria-hidden', true);
+        _emit('VuFind.lightbox.closing');
       }
-      this.setAttribute('aria-hidden', true);
-      _emit('VuFind.lightbox.closing');
     });
     _modal.on('hidden.bs.modal', function lightboxHidden() {
       VuFind.lightbox.reset();
       _emit('VuFind.lightbox.closed');
     });
+    _modal.on("shown.bs.modal", function lightboxShown() {
+      bindFocus();
+    });
 
     VuFind.modal = function modalShortcut(cmd) {
       if (cmd === 'show') {
-- 
GitLab