Issue #3188938 by bnjmnm, lauriii, alexpott, zrpnr: Create AjaxCommand for focusing that does not require :tabbable selector

merge-requests/550/head
Alex Pott 2021-04-13 17:25:52 +01:00
parent 2b0ba20b6b
commit cf214a4938
No known key found for this signature in database
GPG Key ID: 31905460D4A69276
10 changed files with 491 additions and 7 deletions

View File

@ -85,6 +85,7 @@ drupal.ajax:
- core/drupalSettings
- core/drupal.progress
- core/jquery.once
- core/tabbable
drupal.announce:
version: VERSION

View File

@ -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,
];
}
}

View File

@ -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);

View File

@ -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);
})(jQuery, window, Drupal, drupalSettings, window.tabbable);

View File

@ -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

View File

@ -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'

View File

@ -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);

View File

@ -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);

View File

@ -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) {
}
}

View File

@ -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);
}
}