core/misc/tabbingmanager.js | 184 ++++++++++++++++++++++++++++++++ core/modules/overlay/overlay-parent.js | 111 +++++-------------- core/modules/overlay/overlay.module | 1 + core/modules/system/system.module | 13 +++ 4 files changed, 226 insertions(+), 83 deletions(-) diff --git a/core/misc/tabbingmanager.js b/core/misc/tabbingmanager.js new file mode 100644 index 0000000..c43467e --- /dev/null +++ b/core/misc/tabbingmanager.js @@ -0,0 +1,184 @@ +/** + * @file + * Manages page tabbing modifications made by modules. + */ + +(function ($, Drupal) { + +"use strict"; + +// By default, browsers make a, area, button, input, object, select, textarea, +// and iframe elements reachable via the tab key. +var browserTabbableElementsSelector = 'a, area, button, input, object, select, textarea, iframe'; + +// Tabbing sets are stored as a stack. The active set is at the top of the +// stack. The document is the at the bottom of the stack and cannot be removed. +// We use a JavaScript array as if it were a stack; we consider the first +// element to be the bottom and the last element to be the top. This allows us +// to use JavaScript's built-in Array.push() and Array.pop() methods. +var stack = []; + +/** + * Provides an API for managing page tabbing order modifications. + */ +function TabbingManager () { + return { + constrain: constrain + }; +} + +/** + * Constrain tabbing to the specified set of elements only. + * + * Makes elements outside of the specified set of elements unreachable via the + * tab key. + * + * @param {String, jQuery} set + * The set of elements to which tabbing should be constrained. + * + * @return Object + * An object with isReleased() and release() methods, respectively to check + * whether the constrained tabbing has been released and to release it. + */ +var constrain = function (set) { + var stackLevel = stack.length; + + // The "active tabbing set" are the elements tabbing should be constrained to. + var $activeTabbingSet = ('jquery' in set) ? set : $(set); + + // Determine which elements are "tabbable" (reachable via tabbing) by default. + var $tabbable = $(browserTabbableElementsSelector) + // If another element (like a div) has a tabindex, it's also tabbable. + .add('[tabindex]') + // Exclude elements of the active tabbing set. + .not($activeTabbingSet); + + // Make all tabbable elements outside of the active tabbing set unreachable. + $tabbable + // Record the tabindex for each element, so we can restore it later. + .recordTabindex(stackLevel) + // Set tabindex to -1 on everything outside the set. + .attr('tabindex', -1); + + return (function ($tabbable) { + // Build a stack frame with necessary metadata, and push it on the stack. + var stackFrame = { + $untabbableElements: $tabbable, + released: false + }; + stack.push(stackFrame); + + return { + release: function () { + if (!stackFrame.released) { + stackFrame.released = true; + releaseSet(stackLevel); + } + }, + isReleased: function () { + return stackFrame.released; + } + }; + }($tabbable)); +}; + +/** + * Restores the original tabindex value to that of the previous set. + * + * @todo incorporate relevant parts of the big comment for stackLevelToRestore + * here, or move it entirely here? + * + * @param {Number} stackLevel + * The stack level whose tabbing constraints should be released. + */ +var releaseSet = function (stackLevel) { + // Only allow the top of the stack to be unwound. + if (stackLevel < stack.length - 1) { + return; + } + + // Unwind as far as possible. + // If there are e.g. 3 stacked tabbing constraints, such as: 1) document/page, + // 2) contextual links' edit mode, 3) in-place editing, and the second stack + // level is released, then the if-test above (which only allows the top of the + // stack to be unwound) will cause the second stack level to be marked as + // released, but to not actually be released. This is what ensures that the + // top-level tabbing constraint remains active. Once that is marked as + // unactive, however, we must not only unwind the top-level tabbing constraint + // (number 3 in the example), but also the level below that (number 2). + var stackLevelToRestore = stackLevel; + while (stackLevelToRestore > 0 && stack[stackLevelToRestore - 1].released) { + stackLevelToRestore--; + } + + // Restore the tabindex attributes that existed before this constraint was + // applied. + $('[tabindex]').removeAttr('tabindex'); + stack[stackLevelToRestore].$untabbableElements.restoreTabindex(stackLevelToRestore); + + // Delete all stack frames starting at stackLevelToRestore (and always going + // up to stackLevel). + stack.splice(stackLevelToRestore); +}; + +/** + * Records the stack level-specific tabindex for an element, using $.data. + * + * Only elements that actually have a tabindex attribute will be handled. + * + * @param {Number} stackLevel + * The stack level for which the tabindex attribute should be recorded. + */ +$.fn.recordTabindex = function (stackLevel) { + return this + .filter('[tabindex]') + .each(function () { + var $element = $(this); + // Retrieve the existing drupalOriginalTabIndices, if any, and store the + // tabindex data for this stack level. + var tabIndices = $element.data('drupalOriginalTabIndices') || {}; + tabIndices[stackLevel] = $element.attr('tabindex'); + $element.data('drupalOriginalTabIndices', tabIndices); + }) + .end(); +}; + +/** + * Restore the element's original (but stack-level-specific) tabindex. + * + * @param {Number} stackLevel + * The stack level for which the tabindex attribute should be restored. + */ +$.fn.restoreTabindex = function (stackLevel) { + return this.each(function () { + var $element = $(this); + var tabIndices = $element.data('drupalOriginalTabIndices'); + + if (tabIndices && tabIndices[stackLevel]) { + $element.attr('tabindex', tabIndices[stackLevel]); + + // Clean up $.data. + if (stackLevel === 0) { + // Remove all data. + $element.removeData('drupalOriginalTabIndices'); + } + else { + // Remove the data for this stack level and higher. + var stackLevelToDelete = stackLevel; + while (tabIndices.hasOwnProperty(stackLevelToDelete)) { + delete tabIndices[stackLevelToDelete]; + stackLevelToDelete++; + } + $element.data('drupalOriginalTabIndices', tabIndices); + } + } + }); +}; + +// Create a TabbingManager instance and assign it to the Drupal namespace. +var manager = new TabbingManager(); +Drupal.TabbingManager = manager; +// @todo remove this, but this is useful to see what's going on :) +Drupal.TabbingManager.stack = stack; + +}(jQuery, Drupal)); diff --git a/core/modules/overlay/overlay-parent.js b/core/modules/overlay/overlay-parent.js index caf9336..2e7ef4c 100644 --- a/core/modules/overlay/overlay-parent.js +++ b/core/modules/overlay/overlay-parent.js @@ -3,17 +3,21 @@ * Attaches the behaviors for the Overlay parent pages. */ -(function ($) { +(function ($, Drupal) { "use strict"; +// The set of elements a user may tab between when the overlay is open. +var tabset; + /** * Open the overlay, or load content into it, when an admin link is clicked. */ Drupal.behaviors.overlayParent = { attach: function (context, settings) { if (Drupal.overlay.isOpen) { - Drupal.overlay.makeDocumentUntabbable(context); + // Constrain the tabbing order. + Drupal.overlay.constrainTabbing(); } if (this.processed) { @@ -94,7 +98,8 @@ Drupal.overlay.open = function (url) { this.isOpening = false; this.isOpen = true; $(document.documentElement).addClass('overlay-open'); - this.makeDocumentUntabbable(); + // Constrain the tabbing order. + Drupal.overlay.constrainTabbing(); // Allow other scripts to respond to this event. $(document).trigger('drupalOverlayOpen'); @@ -201,7 +206,6 @@ Drupal.overlay.close = function () { $(document.documentElement).removeClass('overlay-open'); // Restore the original document title. document.title = this.originalTitle; - this.makeDocumentTabbable(); // Allow other scripts to respond to this event. $(document).trigger('drupalOverlayClose'); @@ -871,6 +875,25 @@ Drupal.overlay.getPath = function (link) { }; /** + * Makes elements outside the overlay unreachable via the tab key. + */ +Drupal.overlay.constrainTabbing = function () { + var tabset; + + // If a tabset is already active, return without creating a new one. + if (tabset && !tabset.isReleased()) { + return; + } + // Leave links inside the overlay and toolbars alone. + var $overlay = $('.toolbar, .overlay-element, #overlay-container, .overlay-displace-top, .overlay-displace-bottom').find('*'); + tabset = Drupal.TabbingManager.constrain($overlay); + $(document).on('drupalOverlayClose.tabbing', function () { + tabset.release(); + $(document).off('drupalOverlayClose.tabbing'); + }); +}; + +/** * Get the total displacement of given region. * * @param region @@ -888,84 +911,6 @@ Drupal.overlay.getDisplacement = function (region) { return displacement; }; -/** - * Makes elements outside the overlay unreachable via the tab key. - * - * @param context - * The part of the DOM that should have its tabindexes changed. Defaults to - * the entire page. - */ -Drupal.overlay.makeDocumentUntabbable = function (context) { - context = context || document.body; - var $overlay, $tabbable, $hasTabindex; - - // Determine which elements on the page already have a tabindex. - $hasTabindex = $('[tabindex] :not(.overlay-element)', context); - // Record the tabindex for each element, so we can restore it later. - $hasTabindex.each(Drupal.overlay._recordTabindex); - // Add the tabbable elements from the current context to any that we might - // have previously recorded. - Drupal.overlay._hasTabindex = $hasTabindex.add(Drupal.overlay._hasTabindex); - - // Set tabindex to -1 on everything outside the overlay and toolbars, so that - // the underlying page is unreachable. - - // By default, browsers make a, area, button, input, object, select, textarea, - // and iframe elements reachable via the tab key. - $tabbable = $('a, area, button, input, object, select, textarea, iframe'); - // If another element (like a div) has a tabindex, it's also tabbable. - $tabbable = $tabbable.add($hasTabindex); - // Leave links inside the overlay and toolbars alone. - $overlay = $('.overlay-element, #overlay-container, .overlay-displace-top, .overlay-displace-bottom').find('*'); - $tabbable = $tabbable.not($overlay); - // We now have a list of everything in the underlying document that could - // possibly be reachable via the tab key. Make it all unreachable. - $tabbable.attr('tabindex', -1); -}; - -/** - * Restores the original tabindex value of a group of elements. - * - * @param context - * The part of the DOM that should have its tabindexes restored. Defaults to - * the entire page. - */ -Drupal.overlay.makeDocumentTabbable = function (context) { - var $needsTabindex; - context = context || document.body; - - // Make the underlying document tabbable again by removing all existing - // tabindex attributes. - $('[tabindex]', context).removeAttr('tabindex'); - - // Restore the tabindex attributes that existed before the overlay was opened. - $needsTabindex = $(Drupal.overlay._hasTabindex, context); - $needsTabindex.each(Drupal.overlay._restoreTabindex); - Drupal.overlay._hasTabindex = Drupal.overlay._hasTabindex.not($needsTabindex); -}; - -/** - * Record the tabindex for an element, using $.data. - * - * Meant to be used as a jQuery.fn.each callback. - */ -Drupal.overlay._recordTabindex = function () { - var $element = $(this); - var tabindex = $(this).attr('tabindex'); - $element.data('drupalOverlayOriginalTabIndex', tabindex); -}; - -/** - * Restore an element's original tabindex. - * - * Meant to be used as a jQuery.fn.each callback. - */ -Drupal.overlay._restoreTabindex = function () { - var $element = $(this); - var tabindex = $element.data('drupalOverlayOriginalTabIndex'); - $element.attr('tabindex', tabindex); -}; - $.extend(Drupal.theme, { /** * Theme function to create the overlay iframe element. @@ -982,4 +927,4 @@ $.extend(Drupal.theme, { } }); -})(jQuery); +})(jQuery, Drupal); diff --git a/core/modules/overlay/overlay.module b/core/modules/overlay/overlay.module index 1893a15..e2dd460 100644 --- a/core/modules/overlay/overlay.module +++ b/core/modules/overlay/overlay.module @@ -213,6 +213,7 @@ function overlay_library_info() { array('system', 'jquery'), array('system', 'drupal'), array('system', 'drupalSettings'), + array('system', 'drupal.tabbingmanager'), array('system', 'jquery.ui.core'), array('system', 'jquery.bbq'), ), diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 72ceea5..7f962ea 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -1383,6 +1383,19 @@ function system_library_info() { ), ); + // Manages tab orders in the document. + $libraries['drupal.tabbingmanager'] = array( + 'title' => 'Drupal tabbing manager', + 'version' => VERSION, + 'js' => array( + 'core/misc/tabbingmanager.js' => array('group', JS_LIBRARY), + ), + 'dependencies' => array( + array('system', 'jquery'), + array('system', 'drupal'), + ), + ); + // A utility function to limit calls to a function with a given time. $libraries['drupal.debounce'] = array( 'title' => 'Drupal debounce',