Issue #3229828 by bnjmnm, nod_, lauriii: Drupal.tabbingManager does not constrain tab navigation to the browser
parent
1052cac609
commit
7a1bb9f5d9
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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']],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
name: 'Tabbing Manager Test'
|
||||
type: module
|
||||
description: 'Module for the testing tabbing manager'
|
||||
package: Testing
|
||||
version: VERSION
|
|
@ -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'
|
|
@ -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 });
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue