core/misc/tabbingmanager.js | 185 ++++++++++++++++++++------------ core/modules/overlay/overlay-parent.js | 10 +- 2 files changed, 123 insertions(+), 72 deletions(-) diff --git a/core/misc/tabbingmanager.js b/core/misc/tabbingmanager.js index 5b171cf..c43467e 100644 --- a/core/misc/tabbingmanager.js +++ b/core/misc/tabbingmanager.js @@ -9,127 +9,176 @@ // By default, browsers make a, area, button, input, object, select, textarea, // and iframe elements reachable via the tab key. -var tabbableElementsSelector = 'a, area, button, input, object, select, textarea, iframe'; +var browserTabbableElementsSelector = 'a, area, button, input, object, select, textarea, iframe'; -// Tabbing sets are stored as a stack. The active set is the first element -// in the sets array. The document is the last set and cannot be removed. -var sets = []; +// 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 () { - - initialize.apply(this, arguments); - return { constrain: constrain }; } /** - * Creates the default set of tabbable elements. - */ -var initialize = function () { - $(function () { - // The document is the default tabbing context. - sets.push($(tabbableElementsSelector, document)); - }); -} - -/** - * Makes elements outside the set of specified elements unreachable via - * the tab key. + * Constrain tabbing to the specified set of elements only. * - * @param {String, jQuery} elements - * The elements that tabbing should be constrained to. + * 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 (elements) { - var $set = ('jquery' in elements) ? elements : $(elements); +var constrain = function (set) { + var stackLevel = stack.length; - var $tabbable; + // The "active tabbing set" are the elements tabbing should be constrained to. + var $activeTabbingSet = ('jquery' in set) ? set : $(set); - // Determine which elements on the page already have a tabindex. - $tabbable = $(tabbableElementsSelector); + // 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. - $tabbable = $tabbable.add('[tabindex]'); - // Exclude elements of the set. - $tabbable = $tabbable.not($set); + .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. - $tabbable.recordTabindex(); + .recordTabindex(stackLevel) // Set tabindex to -1 on everything outside the set. - $tabbable.attr('tabindex', -1); + .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 (function ($set) { - var index = ((sets.push($set)) - 1); - var released = false; return { release: function () { - released = true; - releaseSet(index); + if (!stackFrame.released) { + stackFrame.released = true; + releaseSet(stackLevel); + } }, isReleased: function () { - return released; + return stackFrame.released; } }; - }($set)); + }($tabbable)); }; /** * Restores the original tabindex value to that of the previous set. * - * @param {Number} index - * The part of the DOM that should have its tabindexes changed. Defaults to - * the entire page. + * @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 (index) { - // The default tabbing set cannot be released. - if (index === 0) { +var releaseSet = function (stackLevel) { + // Only allow the top of the stack to be unwound. + if (stackLevel < stack.length - 1) { return; } - // Get the previous set. The zero index is the base set. - var previousSet = ((index - 1) >= 0) ? (index - 1) : 0; + // 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--; + } - // Make the underlying document tabbable again by removing all existing - // tabindex attributes. - // @todo just remove the tabindices in the current set. + // Restore the tabindex attributes that existed before this constraint was + // applied. $('[tabindex]').removeAttr('tabindex'); + stack[stackLevelToRestore].$untabbableElements.restoreTabindex(stackLevelToRestore); - // Restore the tabindex attributes that existed in the previous set. - sets[previousSet].restoreTabindex(); - - // @todo, if the set that is released is not at the top of the sets stack, - // it should be marked for release once all of the preceding stacks are - // popped. For now, we assume a stack of at most 2 sets. - sets.splice(index); + // Delete all stack frames starting at stackLevelToRestore (and always going + // up to stackLevel). + stack.splice(stackLevelToRestore); }; /** - * Record the tabindex for an element, using $.data. + * 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 () { - return this.each(function () { - var $element = $(this); - var tabindex = $(this).attr('tabindex'); - // @todo the original tabbing index needs to be keyed to its set. - $element.data('drupalOriginalTabIndex', tabindex); - }); +$.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 an element's original tabindex. + * 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 () { +$.fn.restoreTabindex = function (stackLevel) { return this.each(function () { var $element = $(this); - var tabindex = $element.data('drupalOriginalTabIndex'); - $element.attr('tabindex', tabindex); + 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 1b58a45..2e7ef4c 100644 --- a/core/modules/overlay/overlay-parent.js +++ b/core/modules/overlay/overlay-parent.js @@ -3,7 +3,7 @@ * Attaches the behaviors for the Overlay parent pages. */ -(function ($) { +(function ($, Drupal) { "use strict"; @@ -875,16 +875,18 @@ 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('*'); - var tabset = Drupal.TabbingManager.constrain($overlay); + tabset = Drupal.TabbingManager.constrain($overlay); $(document).on('drupalOverlayClose.tabbing', function () { tabset.release(); $(document).off('drupalOverlayClose.tabbing'); @@ -925,4 +927,4 @@ $.extend(Drupal.theme, { } }); -})(jQuery); +})(jQuery, Drupal);