Unverified Commit b48ad909 authored by lauriii's avatar lauriii
Browse files

Issue #3179734 by zrpnr, bnjmnm, lauriii: Refactor uses of the :tabbable selector and deprecate it

parent 09c38b44
......@@ -258,6 +258,7 @@ drupal.dialog.ajax:
- core/drupalSettings
- core/drupal.ajax
- core/drupal.dialog
- core/tabbable
drupal.displace:
version: VERSION
......@@ -366,7 +367,6 @@ drupal.tabbingmanager:
- core/jquery
- core/drupal
- core/tabbable
- core/tabbable.jquery.shim
drupal.tabledrag:
version: VERSION
......@@ -791,6 +791,7 @@ tabbable.jquery.shim:
js:
misc/jquery.tabbable.shim.js: {}
dependencies:
- core/drupal
- core/tabbable
- core/jquery
......
......@@ -3,7 +3,7 @@
* Adds default classes to buttons for styling purposes.
*/
(function ($) {
(function ($, { tabbable, isTabbable }) {
$.widget('ui.dialog', $.ui.dialog, {
options: {
buttonClass: 'button',
......@@ -30,5 +30,46 @@
$buttons.eq(index).addClass(opts.buttonPrimaryClass);
}
},
// Override jQuery UI's `_focusTabbable()` so finding tabbable elements uses
// the core/tabbable library instead of jQuery UI's `:tabbable` selector.
_focusTabbable() {
// Set focus to the first match:
// 1. An element that was focused previously.
let hasFocus = this._focusedElement ? this._focusedElement.get(0) : null;
// 2. First element inside the dialog matching [autofocus].
if (!hasFocus) {
hasFocus = this.element.find('[autofocus]').get(0);
}
// 3. Tabbable element inside the content element.
// 4. Tabbable element inside the buttonpane.
if (!hasFocus) {
const $elements = [this.element, this.uiDialogButtonPane];
for (let i = 0; i < $elements.length; i++) {
const element = $elements[i].get(0);
if (element) {
const elementTabbable = tabbable(element);
hasFocus = elementTabbable.length ? elementTabbable[0] : null;
}
if (hasFocus) {
break;
}
}
}
// 5. The close button.
if (!hasFocus) {
const closeBtn = this.uiDialogTitlebarClose.get(0);
hasFocus = closeBtn && isTabbable(closeBtn) ? closeBtn : null;
}
// 6. The dialog itself.
if (!hasFocus) {
hasFocus = this.uiDialog.get(0);
}
$(hasFocus).eq(0).trigger('focus');
},
});
})(jQuery);
})(jQuery, window.tabbable);
......@@ -5,7 +5,9 @@
* @preserve
**/
(function ($) {
(function ($, _ref) {
var tabbable = _ref.tabbable,
isTabbable = _ref.isTabbable;
$.widget('ui.dialog', $.ui.dialog, {
options: {
buttonClass: 'button',
......@@ -32,6 +34,41 @@
if (typeof primaryIndex !== 'undefined') {
$buttons.eq(index).addClass(opts.buttonPrimaryClass);
}
},
_focusTabbable: function _focusTabbable() {
var hasFocus = this._focusedElement ? this._focusedElement.get(0) : null;
if (!hasFocus) {
hasFocus = this.element.find('[autofocus]').get(0);
}
if (!hasFocus) {
var $elements = [this.element, this.uiDialogButtonPane];
for (var i = 0; i < $elements.length; i++) {
var element = $elements[i].get(0);
if (element) {
var elementTabbable = tabbable(element);
hasFocus = elementTabbable.length ? elementTabbable[0] : null;
}
if (hasFocus) {
break;
}
}
}
if (!hasFocus) {
var closeBtn = this.uiDialogTitlebarClose.get(0);
hasFocus = closeBtn && isTabbable(closeBtn) ? closeBtn : null;
}
if (!hasFocus) {
hasFocus = this.uiDialog.get(0);
}
$(hasFocus).eq(0).trigger('focus');
}
});
})(jQuery);
\ No newline at end of file
})(jQuery, window.tabbable);
\ No newline at end of file
......@@ -6,6 +6,11 @@
(($, Drupal, { isTabbable }) => {
$.extend($.expr[':'], {
tabbable(element) {
Drupal.deprecationError({
message:
'The :tabbable selector is deprecated in Drupal 9.2.0 and will be removed in Drupal 10.0.0. Use the core/tabbable library instead. See https://www.drupal.org/node/3183730',
});
// The tabbable library considers the summary element tabbable, and also
// considers a details element without a summary tabbable. The jQuery UI
// :tabbable selector does not. This is due to those element types being
......
......@@ -9,6 +9,10 @@
var isTabbable = _ref.isTabbable;
$.extend($.expr[':'], {
tabbable: function tabbable(element) {
Drupal.deprecationError({
message: 'The :tabbable selector is deprecated in Drupal 9.2.0 and will be removed in Drupal 10.0.0. Use the core/tabbable library instead. See https://www.drupal.org/node/3183730'
});
if (element.tagName === 'SUMMARY' || element.tagName === 'DETAILS') {
var tabIndex = element.getAttribute('tabIndex');
......
......@@ -27,7 +27,7 @@
* @event drupalTabbingContextDeactivated
*/
(function ($, Drupal) {
(function ($, Drupal, { tabbable, isTabbable }) {
/**
* Provides an API for managing page tabbing order modifications.
*
......@@ -116,9 +116,9 @@
* Makes elements outside of the specified set of elements unreachable via
* the tab key.
*
* @param {jQuery} elements
* @param {jQuery|Selector|Element|ElementArray|object|selection} elements
* The set of elements to which tabbing should be constrained. Can also
* be a jQuery-compatible selector string.
* be any jQuery-compatible argument.
*
* @return {Drupal~TabbingContext}
* The TabbingContext instance.
......@@ -136,13 +136,19 @@
// The "active tabbing set" are the elements tabbing should be constrained
// to.
const $elements = $(elements).find(':tabbable').addBack(':tabbable');
let tabbableElements = [];
$(elements).each((index, rootElement) => {
tabbableElements = [...tabbableElements, ...tabbable(rootElement)];
if (isTabbable(rootElement)) {
tabbableElements = [...tabbableElements, rootElement];
}
});
const tabbingContext = new TabbingContext({
// The level is the current height of the stack before this new
// tabbingContext is pushed on top of the stack.
level: this.stack.length,
$tabbableElements: $elements,
$tabbableElements: $(tabbableElements),
});
this.stack.push(tabbingContext);
......@@ -196,7 +202,7 @@
const $set = tabbingContext.$tabbableElements;
const level = tabbingContext.level;
// Determine which elements are reachable via tabbing by default.
const $disabledSet = $(':tabbable')
const $disabledSet = $(tabbable(document.body))
// Exclude elements of the active tabbing set.
.not($set);
// Set the disabled set on the tabbingContext.
......@@ -364,4 +370,4 @@
* @type {Drupal~TabbingManager}
*/
Drupal.tabbingManager = new TabbingManager();
})(jQuery, Drupal);
})(jQuery, Drupal, window.tabbable);
......@@ -5,7 +5,22 @@
* @preserve
**/
(function ($, Drupal) {
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); }
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); }
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); }
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 ($, Drupal, _ref) {
var tabbable = _ref.tabbable,
isTabbable = _ref.isTabbable;
function TabbingManager() {
this.stack = [];
}
......@@ -28,10 +43,17 @@
this.stack[i].deactivate();
}
var $elements = $(elements).find(':tabbable').addBack(':tabbable');
var tabbableElements = [];
$(elements).each(function (index, rootElement) {
tabbableElements = [].concat(_toConsumableArray(tabbableElements), _toConsumableArray(tabbable(rootElement)));
if (isTabbable(rootElement)) {
tabbableElements = [].concat(_toConsumableArray(tabbableElements), [rootElement]);
}
});
var tabbingContext = new TabbingContext({
level: this.stack.length,
$tabbableElements: $elements
$tabbableElements: $(tabbableElements)
});
this.stack.push(tabbingContext);
tabbingContext.activate();
......@@ -54,7 +76,7 @@
activate: function activate(tabbingContext) {
var $set = tabbingContext.$tabbableElements;
var level = tabbingContext.level;
var $disabledSet = $(':tabbable').not($set);
var $disabledSet = $(tabbable(document.body)).not($set);
tabbingContext.$disabledElements = $disabledSet;
var il = $disabledSet.length;
......@@ -149,4 +171,4 @@
}
Drupal.tabbingManager = new TabbingManager();
})(jQuery, Drupal);
\ No newline at end of file
})(jQuery, Drupal, window.tabbable);
\ No newline at end of file
......@@ -131,7 +131,7 @@ protected function assertAnnounceLeaveEditMode() {
*/
protected function getTabbableElementsCount() {
// Mark all tabbable elements.
$this->getSession()->executeScript("jQuery(':tabbable').attr('data-marked', '');");
$this->getSession()->executeScript("jQuery(window.tabbable.tabbable(document.body)).attr('data-marked', '');");
// Count all marked elements.
$count = count($this->getSession()->getPage()->findAll('css', "[data-marked]"));
// Remove set attributes.
......
/**
* @file media_library.ui.es6.js
*/
(($, Drupal, window) => {
(($, Drupal, window, { tabbable }) => {
/**
* Wrapper object for the current state of the media library.
*/
......@@ -103,7 +103,15 @@
// Set focus to the first tabbable element in the media library
// content.
$('#media-library-content :tabbable:first').focus();
const mediaLibraryContent = document.getElementById(
'media-library-content',
);
if (mediaLibraryContent) {
const tabbableContent = tabbable(mediaLibraryContent);
if (tabbableContent.length) {
tabbableContent[0].focus();
}
}
// Remove any response-specific settings so they don't get used on
// the next call by mistake.
......@@ -420,4 +428,4 @@
Drupal.theme.mediaLibrarySelectionCount = function () {
return `<div class="media-library-selected-count js-media-library-selected-count" role="status" aria-live="polite" aria-atomic="true"></div>`;
};
})(jQuery, Drupal, window);
})(jQuery, Drupal, window, window.tabbable);
......@@ -5,7 +5,8 @@
* @preserve
**/
(function ($, Drupal, window) {
(function ($, Drupal, window, _ref) {
var tabbable = _ref.tabbable;
Drupal.MediaLibrary = {
currentSelection: []
};
......@@ -55,7 +56,16 @@
_this.commands[response[i].command](_this, response[i], status);
}
});
$('#media-library-content :tabbable:first').focus();
var mediaLibraryContent = document.getElementById('media-library-content');
if (mediaLibraryContent) {
var tabbableContent = tabbable(mediaLibraryContent);
if (tabbableContent.length) {
tabbableContent[0].focus();
}
}
this.settings = null;
};
......@@ -204,4 +214,4 @@
Drupal.theme.mediaLibrarySelectionCount = function () {
return "<div class=\"media-library-selected-count js-media-library-selected-count\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\"></div>";
};
})(jQuery, Drupal, window);
\ No newline at end of file
})(jQuery, Drupal, window, window.tabbable);
\ No newline at end of file
......@@ -34,3 +34,4 @@ ui:
- core/drupal.announce
- core/jquery.once
- media_library/view
- core/tabbable
......@@ -4,6 +4,7 @@
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseDialogCommand;
use Drupal\Core\Ajax\FocusFirstCommand;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
......@@ -608,7 +609,7 @@ public function updateFormCallback(array &$form, FormStateInterface $form_state)
// source field).
if (empty($added_media)) {
$response->addCommand(new ReplaceCommand('#media-library-add-form-wrapper', $this->buildMediaLibraryUi($form_state)));
$response->addCommand(new InvokeCommand('#media-library-add-form-wrapper :tabbable', 'focus'));
$response->addCommand(new FocusFirstCommand('#media-library-add-form-wrapper'));
}
// When there are still more items, update the form and shift the focus to
// the next media item. If the last list item is removed, shift focus to
......
......@@ -492,7 +492,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
'type' => 'throbber',
'message' => $this->t('Opening media library.'),
],
// The AJAX system automatically moves focus to the first :tabbable
// The AJAX system automatically moves focus to the first tabbable
// element of the modal, so we need to disable refocus on the button.
'disable-refocus' => TRUE,
],
......
......@@ -90,7 +90,7 @@ public function viewsForm(array &$form, FormStateInterface $form_state) {
'query' => $query,
],
'callback' => [static::class, 'updateWidget'],
// The AJAX system automatically moves focus to the first :tabbable
// The AJAX system automatically moves focus to the first tabbable
// element of the modal, so we need to disable refocus on the button.
'disable-refocus' => TRUE,
];
......
......@@ -113,7 +113,7 @@ public function testWidget() {
$this->assertTrue($menu->hasLink('Show Type Three media (selected)'));
// Assert the focus is set to the first tabbable element when a vertical tab
// is clicked.
$this->assertJsCondition('jQuery("#media-library-content :tabbable:first").is(":focus")');
$this->assertJsCondition('jQuery(tabbable.tabbable(document.getElementById("media-library-content"))[0]).is(":focus")');
$assert_session->elementExists('css', '.ui-dialog-titlebar-close')->click();
// Assert that there are no links in the media library view.
......
......@@ -342,7 +342,7 @@ protected function assertMediaAdded($index = 0) {
protected function assertNoMediaAdded() {
// Assert the focus is shifted to the first tabbable element of the add
// form, which should be the source field.
$this->assertJsCondition('jQuery("#media-library-add-form-wrapper :tabbable").is(":focus")');
$this->assertJsCondition('jQuery(tabbable.tabbable(document.getElementById("media-library-add-form-wrapper"))[0]).is(":focus")');
$this->assertSession()
->elementNotExists('css', '[data-drupal-selector="edit-media-0-fields"]');
......
.no-close .ui-dialog-titlebar-close {
display: none;
visibility: hidden;
}
......@@ -13,3 +13,19 @@ dialog_renderer_test.modal_content:
_title: 'Thing 1'
requirements:
_access: 'TRUE'
dialog_renderer_test.modal_content_link:
path: '/dialog_renderer-content-link'
defaults:
_controller: '\Drupal\dialog_renderer_test\Controller\TestController::modalContentLink'
_title: 'Thing 2'
requirements:
_access: 'TRUE'
dialog_renderer_test.modal_content_input:
path: '/dialog_renderer-content-input'
defaults:
_controller: '\Drupal\dialog_renderer_test\Controller\TestController::modalContentInput'
_title: 'Thing 3'
requirements:
_access: 'TRUE'
......@@ -2,6 +2,7 @@
namespace Drupal\dialog_renderer_test\Controller;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
/**
......@@ -22,6 +23,49 @@ public function modalContent() {
];
}
/**
* Return modal content with link.
*
* @return array
* Render array for display in modal.
*/
public function modalContentLink() {
return [
'#type' => 'container',
'text' => [
'#type' => 'markup',
'#markup' => 'Look at me in a modal!<br><a href="#">And a link!</a>',
],
'input' => [
'#type' => 'textfield',
'#size' => 60,
],
];
}
/**
* Return modal content with autofocus input.
*
* @return array
* Render array for display in modal.
*/
public function modalContentInput() {
return [
'#type' => 'container',
'text' => [
'#type' => 'markup',
'#markup' => 'Look at me in a modal!<br><a href="#">And a link!</a>',
],
'input' => [
'#type' => 'textfield',
'#size' => 60,
'#attributes' => [
'autofocus' => TRUE,
],
],
];
}
/**
* Displays test links that will open in the modal dialog.
*
......@@ -74,6 +118,74 @@ public function linksDisplay() {
],
],
],
'no_close_modal' => [
'#title' => 'Hidden close button modal!',
'#type' => 'link',
'#url' => Url::fromRoute('dialog_renderer_test.modal_content'),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode([
'dialogClass' => 'no-close',
]),
],
'#attached' => [
'library' => [
'core/drupal.ajax',
'dialog_renderer_test/dialog_test',
],
],
],
'button_pane_modal' => [
'#title' => 'Button pane modal!',
'#type' => 'link',
'#url' => Url::fromRoute('dialog_renderer_test.modal_content'),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode([
'buttons' => [
[
'text' => 'OK',
'click' => '() => {}',
],
],
]),
],
'#attached' => [
'library' => [
'core/drupal.ajax',
],
],
],
'content_link_modal' => [
'#title' => 'Content link modal!',
'#type' => 'link',
'#url' => Url::fromRoute('dialog_renderer_test.modal_content_link'),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'modal',
],
'#attached' => [
'library' => [
'core/drupal.ajax',
],
],
],
'auto_focus_modal' => [
'#title' => 'Auto focus modal!',
'#type' => 'link',
'#url' => Url::fromRoute('dialog_renderer_test.modal_content_input'),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'modal',
],
'#attached' => [
'library' => [
'core/drupal.ajax',
],
],
],
];
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment