diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 2951aaaa7cc..e0251eb07f8 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 00000000000..f712c1795bb --- /dev/null +++ b/core/lib/Drupal/Core/Ajax/FocusFirstCommand.php @@ -0,0 +1,51 @@ +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 da88f45f9e7..c1d9d6bdf3a 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 b7539af1f7f..45d72115192 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 772a05f734f..9d390854bba 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 40937fa6f9c..01bf512adb9 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 00000000000..1a66c404d16 --- /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 00000000000..9cc0da86613 --- /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 00000000000..6c98c0de8ac --- /dev/null +++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFocusFirstForm.php @@ -0,0 +1,228 @@ + '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 00000000000..3418cd50913 --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/FocusFirstCommandTest.php @@ -0,0 +1,81 @@ +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); + } + +}