Issue #3188938 by bnjmnm, lauriii, alexpott, zrpnr: Create AjaxCommand for focusing that does not require :tabbable selector
parent
2b0ba20b6b
commit
cf214a4938
|
@ -85,6 +85,7 @@ drupal.ajax:
|
|||
- core/drupalSettings
|
||||
- core/drupal.progress
|
||||
- core/jquery.once
|
||||
- core/tabbable
|
||||
|
||||
drupal.announce:
|
||||
version: VERSION
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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);
|
|
@ -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);
|
|
@ -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) {
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue