Issue #3229828 by bnjmnm, nod_, lauriii: Drupal.tabbingManager does not constrain tab navigation to the browser

merge-requests/1256/head
Lauri Eskola 2021-09-27 11:12:13 +03:00
parent 1052cac609
commit 7a1bb9f5d9
No known key found for this signature in database
GPG Key ID: 382FC0F5B0DF53F8
6 changed files with 348 additions and 3 deletions

View File

@ -70,6 +70,9 @@
* When true, the tabbable elements of this tabbingContext will be reachable
* via the tab key and the disabled elements will not. Only one
* tabbingContext can be active at a time.
* @param {bool} options.trapFocus
* When true, focus is trapped within the tabbable elements, i.e. focus will
* remain within the browser.
*/
function TabbingContext(options) {
$.extend(
@ -99,6 +102,11 @@
* @type {bool}
*/
active: false,
/**
* @type {bool}
*/
trapFocus: false,
},
options,
);
@ -119,13 +127,24 @@
* @param {jQuery|Selector|Element|ElementArray|object|selection} elements
* The set of elements to which tabbing should be constrained. Can also
* be any jQuery-compatible argument.
* @param {object} [options={}]
* Constrain options.
* @param {boolean} [options.trapFocus=false]
* When true, tabbing is trapped within the set of elements and can't
* leave the browser. If the final element in the set is tabbed, the
* first element in the set will receive focus. If the first element in
* the set is shift-tabbed, the last element in the set will receive
* focus.
* When false, it is possible to tab out of the browser window by
* tabbing the final element in the set or shift-tabbing the first
* element in the set.
*
* @return {Drupal~TabbingContext}
* The TabbingContext instance.
*
* @fires event:drupalTabbingConstrained
*/
constrain(elements) {
constrain(elements, { trapFocus = false } = {}) {
// Deactivate all tabbingContexts to prepare for the new constraint. A
// tabbingContext instance will only be reactivated if the stack is
// unwound to it in the _unwindStack() method.
@ -149,6 +168,7 @@
// tabbingContext is pushed on top of the stack.
level: this.stack.length,
$tabbableElements: $(tabbableElements),
trapFocus,
});
this.stack.push(tabbingContext);
@ -226,6 +246,22 @@
$hasFocus = $set.eq(0);
}
$hasFocus.trigger('focus');
// Trap focus within the set.
if ($set.length && tabbingContext.trapFocus) {
$set.last().on('keydown.focus-trap', (event) => {
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault();
$set.first().focus();
}
});
$set.first().on('keydown.focus-trap', (event) => {
if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
$set.last().focus();
}
});
}
},
/**
@ -241,6 +277,9 @@
const $set = tabbingContext.$disabledElements;
const level = tabbingContext.level;
const il = $set.length;
tabbingContext.$tabbableElements.first().off('keydown.focus-trap');
tabbingContext.$tabbableElements.last().off('keydown.focus-trap');
for (let i = 0; i < il; i++) {
this.restoreTabindex($set.eq(i), level);
}

View File

@ -31,12 +31,17 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
$tabbableElements: $(),
$disabledElements: $(),
released: false,
active: false
active: false,
trapFocus: false
}, options);
}
$.extend(TabbingManager.prototype, {
constrain: function constrain(elements) {
var _ref2 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
_ref2$trapFocus = _ref2.trapFocus,
trapFocus = _ref2$trapFocus === void 0 ? false : _ref2$trapFocus;
var il = this.stack.length;
for (var i = 0; i < il; i++) {
@ -53,7 +58,8 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
});
var tabbingContext = new TabbingContext({
level: this.stack.length,
$tabbableElements: $(tabbableElements)
$tabbableElements: $(tabbableElements),
trapFocus: trapFocus
});
this.stack.push(tabbingContext);
tabbingContext.activate();
@ -92,11 +98,28 @@ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len
}
$hasFocus.trigger('focus');
if ($set.length && tabbingContext.trapFocus) {
$set.last().on('keydown.focus-trap', function (event) {
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault();
$set.first().focus();
}
});
$set.first().on('keydown.focus-trap', function (event) {
if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
$set.last().focus();
}
});
}
},
deactivate: function deactivate(tabbingContext) {
var $set = tabbingContext.$disabledElements;
var level = tabbingContext.level;
var il = $set.length;
tabbingContext.$tabbableElements.first().off('keydown.focus-trap');
tabbingContext.$tabbableElements.last().off('keydown.focus-trap');
for (var i = 0; i < il; i++) {
this.restoreTabindex($set.eq(i), level);

View File

@ -0,0 +1,79 @@
<?php
namespace Drupal\tabbingmanager_test\Controller;
use Drupal\Core\Controller\ControllerBase;
/**
* For testing the Tabbing Manager.
*/
class TabbingManagerTestController extends ControllerBase {
/**
* Provides a page with the tabbingManager library for testing tabbing manager.
*
* @return array
* The render array.
*/
public function build() {
return [
'container' => [
'#type' => 'container',
'#attributes' => [
'id' => 'tabbingmanager-test-container',
],
'first' => [
'#type' => 'textfield',
'#title' => $this->t('First'),
'#attributes' => [
'id' => 'first',
],
],
'second' => [
'#type' => 'textfield',
'#title' => $this->t('Second'),
'#attributes' => [
'id' => 'second',
],
],
'third' => [
'#type' => 'textfield',
'#title' => $this->t('Third'),
'#attributes' => [
'id' => 'third',
],
],
],
'another_container' => [
'#type' => 'container',
'#attributes' => [
'id' => 'tabbingmanager-test-another-container',
],
'fourth' => [
'#type' => 'textfield',
'#title' => $this->t('Fourth'),
'#attributes' => [
'id' => 'fourth',
],
],
'fifth' => [
'#type' => 'textfield',
'#title' => $this->t('Fifth'),
'#attributes' => [
'id' => 'fifth',
],
],
'sixth' => [
'#type' => 'textfield',
'#title' => $this->t('Sixth'),
'#attributes' => [
'id' => 'sixth',
],
],
],
'#attached' => ['library' => ['core/drupal.tabbingmanager']],
];
}
}

View File

@ -0,0 +1,5 @@
name: 'Tabbing Manager Test'
type: module
description: 'Module for the testing tabbing manager'
package: Testing
version: VERSION

View File

@ -0,0 +1,7 @@
tabbingmanager_test_page:
path: '/tabbingmanager-test'
defaults:
_controller: '\Drupal\tabbingmanager_test\Controller\TabbingManagerTestController::build'
_title: 'Tabbing Manager testing'
requirements:
_access: 'TRUE'

View File

@ -0,0 +1,192 @@
module.exports = {
'@tags': ['core'],
before(browser) {
browser.drupalInstall().drupalLoginAsAdmin(() => {
browser
.drupalRelativeURL('/admin/modules')
.setValue('input[type="search"]', 'Tabbing Manager Test')
.waitForElementVisible(
'input[name="modules[tabbingmanager_test][enable]"]',
1000,
)
.click('input[name="modules[tabbingmanager_test][enable]"]')
.click('input[type="submit"]');
});
},
after(browser) {
browser.drupalUninstall();
},
'test tabbingmanager': (browser) => {
browser
.drupalRelativeURL('/tabbingmanager-test')
.waitForElementPresent('#tabbingmanager-test-container', 1000);
// Tab through the form without tabbing constrained. Tabbing out of the
// third input should focus the fourth.
browser
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
document.querySelector('#first').focus();
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'first',
'[not constrained] First element focused after calling focus().',
);
},
)
.setValue('#first', [browser.Keys.TAB])
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'second',
'[not constrained] Tabbing first element focuses second element.',
);
},
)
.setValue('#second', [browser.Keys.TAB])
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'third',
'[not constrained] Tabbing second element focuses third element.',
);
},
)
.setValue('#third', [browser.Keys.TAB])
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'fourth',
'[not constrained] Tabbing third element focuses fourth element.',
);
},
);
// Tab through the form with tabbing constrained to the container that has
// the first, second, and third inputs. Tabbing out of the third (final)
// input should move focus back to the first one.
browser
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
Drupal.tabbingManager.constrain(
document.querySelector('#tabbingmanager-test-container'),
{ trapFocus: true },
);
document.querySelector('#first').focus();
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'first',
'[constrained] First element focused after calling focus().',
);
},
)
.setValue('#first', [browser.Keys.TAB])
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'second',
'[constrained] Tabbing first element focuses second element',
);
},
)
.setValue('#second', [browser.Keys.TAB])
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'third',
'[constrained] Tabbing second element focuses the third.',
);
},
)
.setValue('#third', [browser.Keys.TAB])
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'first',
'[constrained] Tabbing final element focuses the first.',
);
},
);
// Confirm shift+tab on the first element focuses the third (final).
browser
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
document.querySelector('#first').focus();
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'first',
'[constrained] First element focused after calling focus().',
);
},
)
.setValue('#first', [browser.Keys.SHIFT, browser.Keys.TAB])
.execute(
// eslint-disable-next-line func-names, prefer-arrow-callback
function () {
return document.activeElement.id;
},
[],
(result) => {
browser.assert.equal(
result.value,
'third',
'[constrained] Shift+tab the first element moves focus to the last element.',
);
},
);
browser.drupalLogAndEnd({ onlyOnError: false });
},
};