From cf214a4938a9c3edc0450502e3a03518cd7c2b25 Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Tue, 13 Apr 2021 17:25:52 +0100
Subject: [PATCH] Issue #3188938 by bnjmnm, lauriii, alexpott, zrpnr: Create
 AjaxCommand for focusing that does not require :tabbable selector

---
 core/core.libraries.yml                       |   1 +
 .../Drupal/Core/Ajax/FocusFirstCommand.php    |  51 ++++
 core/misc/ajax.es6.js                         |  50 +++-
 core/misc/ajax.js                             |  28 ++-
 .../modules/ajax_test/ajax_test.libraries.yml |   7 +
 .../modules/ajax_test/ajax_test.routing.yml   |   8 +
 .../modules/ajax_test/js/focus-ajax.es6.js    |  23 ++
 .../tests/modules/ajax_test/js/focus-ajax.js  |  21 ++
 .../src/Form/AjaxTestFocusFirstForm.php       | 228 ++++++++++++++++++
 .../Ajax/FocusFirstCommandTest.php            |  81 +++++++
 10 files changed, 491 insertions(+), 7 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Ajax/FocusFirstCommand.php
 create mode 100644 core/modules/system/tests/modules/ajax_test/js/focus-ajax.es6.js
 create mode 100644 core/modules/system/tests/modules/ajax_test/js/focus-ajax.js
 create mode 100644 core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFocusFirstForm.php
 create mode 100644 core/tests/Drupal/FunctionalJavascriptTests/Ajax/FocusFirstCommandTest.php

diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 2951aaaa7cc1..e0251eb07f8e 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -85,6 +85,7 @@ drupal.ajax:
     - core/drupalSettings
     - core/drupal.progress
     - core/jquery.once
+    - core/tabbable
 
 drupal.announce:
   version: VERSION
diff --git a/core/lib/Drupal/Core/Ajax/FocusFirstCommand.php b/core/lib/Drupal/Core/Ajax/FocusFirstCommand.php
new file mode 100644
index 000000000000..f712c1795bbb
--- /dev/null
+++ b/core/lib/Drupal/Core/Ajax/FocusFirstCommand.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\Core\Ajax;
+
+/**
+ * AJAX command for focusing an element.
+ *
+ * This command is provided a selector then does the following:
+ * - The first element matching the provided selector will become the container
+ *   where the search for tabbable elements is conducted.
+ * - If one or more tabbable elements are found within the container, the first
+ *   of those will receive focus.
+ * - If no tabbable elements are found within the container, but the container
+ *   itself is focusable, then the container will receive focus.
+ * - If the container is not focusable and contains no tabbable elements, the
+ *   triggering element will remain focused.
+ *
+ * @see Drupal.AjaxCommands.focusFirst
+ *
+ * @ingroup ajax
+ */
+class FocusFirstCommand implements CommandInterface {
+
+  /**
+   * The selector of the container with tabbable elements.
+   *
+   * @var string
+   */
+  protected $selector;
+
+  /**
+   * Constructs an FocusFirstCommand object.
+   *
+   * @param string $selector
+   *   The selector of the container with tabbable elements.
+   */
+  public function __construct($selector) {
+    $this->selector = $selector;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    return [
+      'command' => 'focusFirst',
+      'selector' => $this->selector,
+    ];
+  }
+
+}
diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js
index da88f45f9e74..c1d9d6bdf3a1 100644
--- a/core/misc/ajax.es6.js
+++ b/core/misc/ajax.es6.js
@@ -11,7 +11,7 @@
  * included to provide Ajax capabilities.
  */
 
-(function ($, window, Drupal, drupalSettings) {
+(function ($, window, Drupal, drupalSettings, { isFocusable, tabbable }) {
   /**
    * Attaches the Ajax behavior to each Ajax form element.
    *
@@ -999,8 +999,9 @@
       if (response[i].command && this.commands[response[i].command]) {
         this.commands[response[i].command](this, response[i], status);
         if (
-          response[i].command === 'invoke' &&
-          response[i].method === 'focus'
+          (response[i].command === 'invoke' &&
+            response[i].method === 'focus') ||
+          response[i].command === 'focusFirst'
         ) {
           focusChanged = true;
         }
@@ -1471,6 +1472,47 @@
       $(response.selector).data(response.name, response.value);
     },
 
+    /**
+     * Command to focus the first tabbable element within a container.
+     *
+     * If no tabbable elements are found and the container is focusable, then
+     * focus will move to that container.
+     *
+     * @param {Drupal.Ajax} [ajax]
+     *   {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+     * @param {object} response
+     *   The response from the Ajax request.
+     * @param {string} response.selector
+     *   A query selector string of the container to focus within.
+     * @param {number} [status]
+     *   The XMLHttpRequest status.
+     */
+    focusFirst(ajax, response, status) {
+      let focusChanged = false;
+      const container = document.querySelector(response.selector);
+      if (container) {
+        // Find all tabbable elements within the container.
+        const tabbableElements = tabbable(container);
+
+        // Move focus to the first tabbable item found.
+        if (tabbableElements.length) {
+          tabbableElements[0].focus();
+          focusChanged = true;
+        } else if (isFocusable(container)) {
+          // If no tabbable elements are found, but the container is focusable,
+          // move focus to the container.
+          container.focus();
+          focusChanged = true;
+        }
+      }
+
+      // If no items were available to receive focus, return focus to the
+      // triggering element.
+      if (ajax.hasOwnProperty('element') && !focusChanged) {
+        ajax.element.focus();
+      }
+    },
+
     /**
      * Command to apply a jQuery method.
      *
@@ -1580,4 +1622,4 @@
       messages.add(response.message, response.messageOptions);
     },
   };
-})(jQuery, window, Drupal, drupalSettings);
+})(jQuery, window, Drupal, drupalSettings, window.tabbable);
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
index b7539af1f7f6..45d721151924 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -17,7 +17,9 @@ function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToAr
 
 function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
 
-(function ($, window, Drupal, drupalSettings) {
+(function ($, window, Drupal, drupalSettings, _ref) {
+  var isFocusable = _ref.isFocusable,
+      tabbable = _ref.tabbable;
   Drupal.behaviors.AJAX = {
     attach: function attach(context, settings) {
       function loadAjaxBehavior(base) {
@@ -443,7 +445,7 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
       if (response[i].command && _this.commands[response[i].command]) {
         _this.commands[response[i].command](_this, response[i], status);
 
-        if (response[i].command === 'invoke' && response[i].method === 'focus') {
+        if (response[i].command === 'invoke' && response[i].method === 'focus' || response[i].command === 'focusFirst') {
           focusChanged = true;
         }
       }
@@ -626,6 +628,26 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
     data: function data(ajax, response, status) {
       $(response.selector).data(response.name, response.value);
     },
+    focusFirst: function focusFirst(ajax, response, status) {
+      var focusChanged = false;
+      var container = document.querySelector(response.selector);
+
+      if (container) {
+        var tabbableElements = tabbable(container);
+
+        if (tabbableElements.length) {
+          tabbableElements[0].focus();
+          focusChanged = true;
+        } else if (isFocusable(container)) {
+          container.focus();
+          focusChanged = true;
+        }
+      }
+
+      if (ajax.hasOwnProperty('element') && !focusChanged) {
+        ajax.element.focus();
+      }
+    },
     invoke: function invoke(ajax, response, status) {
       var $element = $(response.selector);
       $element[response.method].apply($element, _toConsumableArray(response.args));
@@ -649,4 +671,4 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
       messages.add(response.message, response.messageOptions);
     }
   };
-})(jQuery, window, Drupal, drupalSettings);
\ No newline at end of file
+})(jQuery, window, Drupal, drupalSettings, window.tabbable);
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml
index 772a05f734ff..9d390854bba2 100644
--- a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml
+++ b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml
@@ -26,3 +26,10 @@ order-header-js-command:
   header: true
   js:
     header.js: {}
+
+focus.first:
+  js:
+    js/focus-ajax.js: {}
+  dependencies:
+    - core/drupal
+    - core/once
diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
index 40937fa6f9c1..01bf512adb96 100644
--- a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
+++ b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
@@ -85,3 +85,11 @@ ajax_test.message_form:
     _form: '\Drupal\ajax_test\Form\AjaxTestMessageCommandForm'
   requirements:
     _access: 'TRUE'
+
+ajax_test.focus_first_form:
+  path: '/ajax-test/focus-first'
+  defaults:
+    _title: 'Ajax Focus First Form'
+    _form: '\Drupal\ajax_test\Form\AjaxTestFocusFirstForm'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/ajax_test/js/focus-ajax.es6.js b/core/modules/system/tests/modules/ajax_test/js/focus-ajax.es6.js
new file mode 100644
index 000000000000..1a66c404d16f
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/js/focus-ajax.es6.js
@@ -0,0 +1,23 @@
+/**
+ * @file
+ * For testing FocusFirstCommand.
+ */
+
+((Drupal) => {
+  Drupal.behaviors.focusFirstTest = {
+    attach() {
+      // Add data-has-focus attribute to focused elements so tests have a
+      // selector to wait for before moving to the next test step.
+      once('focusin', document.body).forEach((element) => {
+        element.addEventListener('focusin', (e) => {
+          document
+            .querySelectorAll('[data-has-focus]')
+            .forEach((wasFocused) => {
+              wasFocused.removeAttribute('data-has-focus');
+            });
+          e.target.setAttribute('data-has-focus', true);
+        });
+      });
+    },
+  };
+})(Drupal, once);
diff --git a/core/modules/system/tests/modules/ajax_test/js/focus-ajax.js b/core/modules/system/tests/modules/ajax_test/js/focus-ajax.js
new file mode 100644
index 000000000000..9cc0da866137
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/js/focus-ajax.js
@@ -0,0 +1,21 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function (Drupal) {
+  Drupal.behaviors.focusFirstTest = {
+    attach: function attach() {
+      once('focusin', document.body).forEach(function (element) {
+        element.addEventListener('focusin', function (e) {
+          document.querySelectorAll('[data-has-focus]').forEach(function (wasFocused) {
+            wasFocused.removeAttribute('data-has-focus');
+          });
+          e.target.setAttribute('data-has-focus', true);
+        });
+      });
+    }
+  };
+})(Drupal, once);
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFocusFirstForm.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFocusFirstForm.php
new file mode 100644
index 000000000000..6c98c0de8acb
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFocusFirstForm.php
@@ -0,0 +1,228 @@
+<?php
+
+namespace Drupal\ajax_test\Form;
+
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\FocusFirstCommand;
+use Drupal\Core\Form\FormInterface;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Form for testing AJAX FocusFirstCommand.
+ *
+ * @internal
+ */
+class AjaxTestFocusFirstForm implements FormInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ajax_test_focus_first_command_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['first_input'] = [
+      '#type' => 'textfield',
+    ];
+    $form['second_input'] = [
+      '#type' => 'textfield',
+    ];
+    $form['a_container'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'id' => 'a-container',
+      ],
+    ];
+    $form['a_container']['first_container_input'] = [
+      '#type' => 'textfield',
+    ];
+    $form['a_container']['second_container_input'] = [
+      '#type' => 'textfield',
+    ];
+    $form['focusable_container_without_tabbable_children'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'tabindex' => '-1',
+        'id' => 'focusable-container-without-tabbable-children',
+      ],
+      '#markup' => 'No tabbable children here',
+    ];
+
+    $form['multiple_of_same_selector_1'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'id' => 'multiple-of-same-selector-1',
+        'class' => ['multiple-of-same-selector'],
+      ],
+    ];
+
+    $form['multiple_of_same_selector_1']['inside_same_selector_container_1'] = [
+      '#type' => 'textfield',
+    ];
+
+    $form['multiple_of_same_selector_2'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'id' => 'multiple-of-same-selector-2',
+        'class' => ['multiple-of-same-selector'],
+      ],
+    ];
+
+    $form['multiple_of_same_selector_2']['inside_same_selector_container_2'] = [
+      '#type' => 'textfield',
+    ];
+
+    $form['nothing_tabbable'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'id' => 'nothing-tabbable',
+      ],
+      '#markup' => 'nothing tabbable',
+    ];
+
+    $form['nothing_tabbable']['nested'] = [
+      '#type' => 'container',
+      '#markup' => 'There are divs in here, but nothing tabbable',
+    ];
+
+    $form['focus_first_in_container'] = [
+      '#type' => 'submit',
+      '#value' => 'Focus the first item in a container',
+      '#name' => 'focusFirstContainer',
+      '#ajax' => [
+        'callback' => '::focusFirstInContainer',
+      ],
+    ];
+    $form['focus_first_in_form'] = [
+      '#type' => 'submit',
+      '#value' => 'Focus the first item in the form',
+      '#name' => 'focusFirstForm',
+      '#ajax' => [
+        'callback' => '::focusFirstInForm',
+      ],
+    ];
+    $form['uses_selector_with_multiple_matches'] = [
+      '#type' => 'submit',
+      '#value' => 'Uses selector with multiple matches',
+      '#name' => 'SelectorMultipleMatches',
+      '#ajax' => [
+        'callback' => '::focusFirstSelectorMultipleMatch',
+      ],
+    ];
+    $form['focusable_container_no_tabbable_children'] = [
+      '#type' => 'submit',
+      '#value' => 'Focusable container, no tabbable children',
+      '#name' => 'focusableContainerNotTabbableChildren',
+      '#ajax' => [
+        'callback' => '::focusableContainerNotTabbableChildren',
+      ],
+    ];
+
+    $form['selector_has_nothing_tabbable'] = [
+      '#type' => 'submit',
+      '#value' => 'Try to focus container with nothing tabbable',
+      '#name' => 'SelectorNothingTabbable',
+      '#ajax' => [
+        'callback' => '::selectorHasNothingTabbable',
+      ],
+    ];
+
+    $form['selector_does_not_exist'] = [
+      '#type' => 'submit',
+      '#value' => 'Call FocusFirst on selector that does not exist.',
+      '#name' => 'SelectorNotExist',
+      '#ajax' => [
+        'callback' => '::selectorDoesNotExist',
+      ],
+    ];
+
+    $form['#attached']['library'][] = 'ajax_test/focus.first';
+
+    return $form;
+  }
+
+  /**
+   * Callback for testing FocusFirstCommand on a container.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function selectorDoesNotExist() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new FocusFirstCommand('#selector-does-not-exist'));
+  }
+
+  /**
+   * Callback for testing FocusFirstCommand on a container.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function selectorHasNothingTabbable() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new FocusFirstCommand('#nothing-tabbable'));
+  }
+
+  /**
+   * Callback for testing FocusFirstCommand on a container.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function focusableContainerNotTabbableChildren() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new FocusFirstCommand('#focusable-container-without-tabbable-children'));
+  }
+
+  /**
+   * Callback for testing FocusFirstCommand on a container.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function focusFirstSelectorMultipleMatch() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new FocusFirstCommand('.multiple-of-same-selector'));
+  }
+
+  /**
+   * Callback for testing FocusFirstCommand on a container.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function focusFirstInContainer() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new FocusFirstCommand('#a-container'));
+  }
+
+  /**
+   * Callback for testing FocusFirstCommand on a form.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function focusFirstInForm() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new FocusFirstCommand('#ajax-test-focus-first-command-form'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Ajax/FocusFirstCommandTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/FocusFirstCommandTest.php
new file mode 100644
index 000000000000..3418cd509139
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/FocusFirstCommandTest.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Ajax;
+
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+
+/**
+ * Tests setting focus via AJAX command.
+ *
+ * @group Ajax
+ */
+class FocusFirstCommandTest extends WebDriverTestBase {
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['ajax_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * Tests AjaxFocusFirstCommand on a page.
+   */
+  public function testFocusFirst() {
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+
+    $this->drupalGet('ajax-test/focus-first');
+    $has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
+    $this->assertNotContains($has_focus_id, ['edit-first-input', 'edit-first-container-input']);
+
+    // Confirm that focus does not change if the selector targets a
+    // non-focusable container containing no tabbable elements.
+    $page->pressButton('SelectorNothingTabbable');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-selector-has-nothing-tabbable[data-has-focus]'));
+    $has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
+    $this->assertEquals('edit-selector-has-nothing-tabbable', $has_focus_id);
+
+    // Confirm that focus does not change if the page has no match for the
+    // provided selector.
+    $page->pressButton('SelectorNotExist');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-selector-does-not-exist[data-has-focus]'));
+    $has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
+    $this->assertEquals('edit-selector-does-not-exist', $has_focus_id);
+
+    // Confirm focus is moved to first tabbable element in a container.
+    $page->pressButton('focusFirstContainer');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-first-container-input[data-has-focus]'));
+    $has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
+    $this->assertEquals('edit-first-container-input', $has_focus_id);
+
+    // Confirm focus is moved to first tabbable element in a form.
+    $page->pressButton('focusFirstForm');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '#ajax-test-focus-first-command-form #edit-first-input[data-has-focus]'));
+
+    // Confirm the form has more than one input to confirm that focus is moved
+    // to the first tabbable element in the container.
+    $this->assertNotNull($page->find('css', '#ajax-test-focus-first-command-form #edit-second-input'));
+    $has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
+    $this->assertEquals('edit-first-input', $has_focus_id);
+
+    // Confirm that the selector provided will use the first match in the DOM as
+    // the container.
+    $page->pressButton('SelectorMultipleMatches');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-inside-same-selector-container-1[data-has-focus]'));
+    $this->assertNotNull($page->findById('edit-inside-same-selector-container-2'));
+    $this->assertNull($assert_session->waitForElementVisible('css', '#edit-inside-same-selector-container-2[data-has-focus]'));
+    $has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
+    $this->assertEquals('edit-inside-same-selector-container-1', $has_focus_id);
+
+    // Confirm that if a container has no tabbable children, but is itself
+    // focusable, then that container receives focus.
+    $page->pressButton('focusableContainerNotTabbableChildren');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '#focusable-container-without-tabbable-children[data-has-focus]'));
+    $has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
+    $this->assertEquals('focusable-container-without-tabbable-children', $has_focus_id);
+  }
+
+}
-- 
GitLab