From 0fdac7ab0c1fd0737e2934acb500623e342cacbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?"J.=20Rene=CC=81e=20Beach"?= Date: Mon, 8 Apr 2013 16:23:06 -0400 Subject: [PATCH] patch 29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: J. ReneĢe Beach --- core/misc/tabbingmanager.js | 389 +++++++++++++++++++++++++ core/modules/contextual/contextual.module | 1 + core/modules/contextual/contextual.toolbar.js | 41 ++- core/modules/overlay/overlay-child.css | 6 +- core/modules/overlay/overlay-parent.js | 107 ++----- core/modules/overlay/overlay.module | 1 + core/modules/system/system.module | 15 + 7 files changed, 475 insertions(+), 85 deletions(-) create mode 100644 core/misc/tabbingmanager.js diff --git a/core/misc/tabbingmanager.js b/core/misc/tabbingmanager.js new file mode 100644 index 0000000..3848c7c --- /dev/null +++ b/core/misc/tabbingmanager.js @@ -0,0 +1,389 @@ +/** + * @file + * Manages page tabbing modifications made by modules. + */ + +(function ($, Drupal) { + +"use strict"; + +Drupal.behaviors.drupalTabbingManager = { + attach: function (context, settings) { + // Do not process the window of the overlay. + if (parent.Drupal.overlay && parent.Drupal.overlay.iframeWindow === window) { + return; + } + // Mark this behavior as processed on the first pass and return if it is + // already processed. + if (this.tabbingManagerProcessed) { + return; + } + this.tabbingManagerProcessed = true; + } +}; + +/** + * Provides an API for managing page tabbing order modifications. + */ +function TabbingManager () { + // Tabbing sets are stored as a stack. The active set is at the top of the + // stack. 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. + this.stack = []; +} + +/** + * Add public methods to the TabbingManager class. + */ +$.extend(TabbingManager.prototype, { + /** + * Constrain tabbing to the specified set of elements only. + * + * Makes elements outside of the specified set of elements unreachable via the + * tab key. + * + * @param jQuery set + * The set of elements to which tabbing should be constrained. Can also be + * a jQuery-compatible selector string. + * + * @return TabbingContext + */ + constrain: function (set) { + // 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. + for (var i = 0, il = this.stack.length; i < il; i++) { + this.stack[i].deactivate(); + } + + // The "active tabbing set" are the elements tabbing should be constrained + // to. + var $set = $(set).find(':tabbable').addBack(':tabbable'); + + var 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: $set, + onRelease: $.proxy(this._unwindStack, this), + onActivate: $.proxy(this._activateTabbingContext, this), + onDeactivate: $.proxy(this._deactivateTabbingContext, this) + }); + + this.stack.push(tabbingContext); + + // Activates the tabbingContext; this will manipulate the DOM to constrain + // tabbing. + tabbingContext.activate(); + + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingConstrained', tabbingContext); + + return tabbingContext; + }, + + /** + * Restores a former tabbingContext when an active tabbingContext is released. + * + * The TabbingManager stack of tabbingContext instances will be unwound from + * the top-most released tabbingContext down to the first non-released + * tabbingContext instance. This non-released instance is then activated. + */ + _unwindStack: function () { + // Unwind as far as possible: find the topmost non-released tabbingContext. + var tabbingContextToActivate = this.stack.length - 1; + while (tabbingContextToActivate >= 0 && this.stack[tabbingContextToActivate].isReleased()) { + tabbingContextToActivate--; + } + + // Delete all tabbingContexts after the to be activated one. They have + // already been deactivated, so their effect on the DOM has been reversed. + this.stack.splice(tabbingContextToActivate + 1); + + // Get topmost tabbingContext, if one exists, and activate it. + if (tabbingContextToActivate >= 0) { + this.stack[tabbingContextToActivate].activate(); + } + }, + + /** + * Makes all elements outside the of the tabbingContext's set untabbable. + * + * Elements made untabble have their original tabindex and autfocus values + * stored so that they might be restored later when this tabbingContext + * is deactivated. + * + * @param TabbingContext tabbingContext + * The TabbingContext instance that has been activated. + */ + _activateTabbingContext: function (tabbingContext) { + var $set = tabbingContext.getTabbableElements(); + var level = tabbingContext.getLevel(); + // Determine which elements are reachable via tabbing by default. + var $disabledSet = $(':tabbable') + // Exclude elements of the active tabbing set. + .not($set); + // Set the disabled set on the tabbingContext. + tabbingContext.setDisabledElements($disabledSet); + // Record the tabindex for each element, so we can restore it later. + this._recordTabindex($disabledSet, level); + // Make all tabbable elements outside of the active tabbing set unreachable. + $disabledSet + .prop('tabindex', -1) + .prop('autofocus', false); + + // Set focus on an element in the tabbingContext's set of tabbable elements. + // First, check if there is an element with an autofocus attribute. Select + // the last one from the DOM order. + var $hasFocus = $set.filter('[autofocus]').eq(-1); + // If no element in the tabbable set has an autofocus attribute, select the + // first element in the set. + if ($hasFocus.length === 0) { + $hasFocus = $set.eq(0); + } + $hasFocus.focus(); + }, + + /** + * Restores that tabbable state of a tabbingContext's disabled elements. + * + * Elements that were made untabble have their original tabindex and autfocus + * values restored. + * + * @param TabbingContext tabbingContext + * The TabbingContext instance that has been deactivated. + */ + _deactivateTabbingContext: function (tabbingContext) { + var $set = tabbingContext.getDisabledElements(); + var level = tabbingContext.getLevel(); + this._restoreTabindex($set, level); + }, + + /** + * Records the tabindex and autofocus values of an untabbable element. + * + * @param jQuery $set + * The set of elements that have been disabled. + * @param Number level + * The stack level for which the tabindex attribute should be recorded. + */ + _recordTabindex: function ($set, level) { + for (var i = 0, il = $set.length; i < il; i++) { + var $el = $set.eq(i); + var el = $set[i]; + var tabInfo = $el.data('drupalOriginalTabIndices') || {}; + tabInfo[level] = { + tabindex: el.getAttribute('tabindex'), + autofocus: el.hasAttribute('autofocus') + }; + $el.data('drupalOriginalTabIndices', tabInfo); + } + }, + + /** + * Restores the tabindex and autofocus values of a reactivated element. + * + * @param jQuery $set + * The set of elements that have been reactivated. + * @param Number level + * The stack level for which the tabindex attribute should be restored. + */ + _restoreTabindex: function ($set, level) { + for (var i = 0, il = $set.length; i < il; i++) { + var $el = $set.eq(i); + var el = $set[i]; + var tabInfo = $el.data('drupalOriginalTabIndices') || {}; + if (tabInfo && tabInfo[level]) { + var data = tabInfo[level]; + if (data.tabindex) { + el.setAttribute('tabindex', data.tabindex); + } + // If the element did not have a tabindex at this stack level then + // remove it. + else { + el.removeAttribute('tabindex'); + } + if (data.autofocus) { + el.setAttribute('autofocus', 'autofocus'); + } + + // Clean up $.data. + if (level === 0) { + // Remove all data. + $el.removeData('drupalOriginalTabIndices'); + } + else { + // Remove the data for this stack level and higher. + var stackLevelToDelete = level; + while (tabInfo.hasOwnProperty(stackLevelToDelete)) { + delete tabInfo[stackLevelToDelete]; + stackLevelToDelete++; + } + $el.data('drupalOriginalTabIndices', tabInfo); + } + } + } + } +}); + +/** + * Stores a set of tabbable elements. + * + * This constraint can be removed with the release() method. + * + * @param Object options + * A set of initiating values that include: + * - Number level: The level in the TabbingManager's stack of this + * tabbingContext. + * - jQuery $tabbableElements: The DOM elements that should be reachable via + * the tab key when this tabbingContext is active. + * - jQuery $disabledElements: The DOM elements that should not be reachable + * via the tab key when this tabbingContext is active. + * - Boolean released: A released tabbingContext can never be activated again. + * It will be cleaned up when the TabbingManager unwinds its stack. + * - Boolean active: 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. + * - Function onRelease: A callback invoked when the tabbingContext is + * released. + * - Function onActivate: A callback invoked when the tabbingContext is + * activated. + * - Function onDeactivate: A callback invoked when the tabbingContext is + * deactivated. + */ +function TabbingContext (options) { + $.extend(this, { + level: null, + $tabbableElements: $(), + $disabledElements: $(), + released: false, + active: false, + onRelease: function () {}, + onActivate: function () {}, + onDeactivate: function () {} + }, options); +} + +/** + * Add public methods to the TabbingContext class. + */ +$.extend(TabbingContext.prototype, { + + /** + * Returns the level of this tabbingContext in the TabbingManager stack. + * + * @return Number + */ + getLevel: function () { + return this.level; + }, + + /** + * Returns the elements that would have their tabbing disabled when this + * tabbingContext instance is active. + * + * @return jQuery + */ + getDisabledElements: function () { + return this.$disabledElements; + }, + + /** + * Stores the jQuery set of elements that will have their tabbing disabled. + * + * @param jQuery $set + * The jQuery set of elements that will have their tabbing disabled. + */ + setDisabledElements: function ($set) { + this.$disabledElements = $set; + }, + + /** + * Returns the elements that would be tabbable when this tabbingContext + * instance is active. + * + * @return jQuery + */ + getTabbableElements: function () { + return this.$tabbableElements; + }, + + /** + * Sets the released flag to true and calls the onRelease callback. + * + * Once a TabbingContext object is released, it can never be activated again. + */ + release: function () { + if (!this.released) { + this.released = true; + // Releasing a tabbingContext means it is permanently deactivated. + this.deactivate(); + var args = Array.prototype.slice.call(arguments); + args.unshift(this); + this.onRelease.apply(null, args); + + // Allow modules to respond to the tabbingContext release event. + $(document).trigger('drupalTabbingContextReleased', this); + } + }, + + /** + * Returns the state of the released flag. + * + * @return Boolean + */ + isReleased: function () { + return this.released; + }, + + /** + * Sets the active flag to true and calls the onActivate callback. + */ + activate: function () { + // A released TabbingContext object can never be activated again. + if (!this.active && !this.released) { + this.active = true; + var args = Array.prototype.slice.call(arguments); + args.unshift(this); + this.onActivate.apply(null, args); + + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextActivated', this); + } + }, + + /** + * Sets the active flag to false and calls the onDeactivate callback. + */ + deactivate: function () { + if (this.active) { + this.active = false; + var args = Array.prototype.slice.call(arguments); + args.unshift(this); + this.onDeactivate.apply(null, args); + + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextDeactivated', this); + } + }, + + /** + * Returns the state of the active flag. + * + * Only one TabbingContext can be active at a time. The disabled elements of + * an active TabbingContext will be unreachable via the tab key. + * + * @return Boolean + */ + isActive: function () { + return this.active; + } +}); + +// Create a TabbingManager instance and assign it to the Drupal namespace. +var manager = new TabbingManager(); +Drupal.TabbingManager = manager; + +}(jQuery, Drupal)); diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module index 76e39ba..f859409 100644 --- a/core/modules/contextual/contextual.module +++ b/core/modules/contextual/contextual.module @@ -109,6 +109,7 @@ function contextual_library_info() { array('system', 'jquery'), array('system', 'jquery.once'), array('system', 'backbone'), + array('system', 'drupal.tabbingmanager'), ), ); diff --git a/core/modules/contextual/contextual.toolbar.js b/core/modules/contextual/contextual.toolbar.js index d2d6e18..e44b60b 100644 --- a/core/modules/contextual/contextual.toolbar.js +++ b/core/modules/contextual/contextual.toolbar.js @@ -20,7 +20,8 @@ Drupal.behaviors.contextualToolbar = { var $contextuals = $(context).find('.contextual-links'); var $tab = $('.js .toolbar .bar .contextual-toolbar-tab'); var model = new Drupal.contextualToolbar.models.EditToggleModel({ - isViewing: true + isViewing: true, + contextuals: $contextuals.get() }); var view = new Drupal.contextualToolbar.views.EditToggleView({ el: $tab, @@ -66,7 +67,12 @@ Drupal.contextualToolbar.models.EditToggleModel = Backbone.Model.extend({ // Indicates whether the toggle is currently in "view" or "edit" mode. isViewing: true, // Indicates whether the toggle should be visible or hidden. - isVisible: false + isVisible: false, + // The set of elements that can be reached via the tab key when edit mode + // is enabled. + tabbingContext: null, + // The set of contextual links stored as an Array. + contextuals: [] } }); @@ -83,6 +89,7 @@ Drupal.contextualToolbar.views.EditToggleView = Backbone.View.extend({ initialize: function () { this.model.on('change', this.render, this); this.model.on('change:isViewing', this.persist, this); + this.model.on('change:isViewing', this.manageTabbing, this); }, /** @@ -122,6 +129,36 @@ Drupal.contextualToolbar.views.EditToggleView = Backbone.View.extend({ } }, + /** + * Limits tabbing to the contextual links and edit mode toolbar tab. + * + * @param Drupal.contextualToolbar.models.EditToggleModel model + * An EditToggleModel Backbone model. + * @param bool isViewing + * The value of the isViewing attribute in the model. + */ + manageTabbing: function (model, isViewing) { + var contextuals = this.model.get('contextuals'); + // Always release an existing tabbing context. + var tabbingContext = this.model.get('tabbingContext'); + if (tabbingContext) { + tabbingContext.release(); + if (isViewing) { + Drupal.announce(Drupal.t('Tabbing is no longer constrained by the Contextual module.')) + } + } + // Create a new tabbing context when edit mode is enabled. + if (!isViewing) { + tabbingContext = Drupal.TabbingManager.constrain($('.contextual-toolbar-tab, .contextual')); + this.model.set('tabbingContext', tabbingContext); + Drupal.announce( + Drupal.t('Tabbing is constrained to set of @contextualsCount and the Edit mode toggle.', { + '@contextualsCount': Drupal.formatPlural(contextuals.length, '@count contextual link', '@count contextual links') + }) + ); + } + }, + onClick: function (event) { this.model.set('isViewing', !this.model.get('isViewing')); event.preventDefault(); diff --git a/core/modules/overlay/overlay-child.css b/core/modules/overlay/overlay-child.css index 3bbf906..48ced7f 100644 --- a/core/modules/overlay/overlay-child.css +++ b/core/modules/overlay/overlay-child.css @@ -53,10 +53,6 @@ #overlay-title:focus { outline: 0; } - -.overlay #skip-link { - margin-top: -20px; -} .overlay #skip-link a { color: #fff; /* This is white to contrast with the dark background behind it. */ } @@ -143,7 +139,7 @@ */ #overlay-disable-message { background-color: #fff; - margin: -20px auto 20px; + margin: 0 auto 20px; width: 80%; border-radius: 0 0 8px 8px; } diff --git a/core/modules/overlay/overlay-parent.js b/core/modules/overlay/overlay-parent.js index 804f556..f94b6e4 100644 --- a/core/modules/overlay/overlay-parent.js +++ b/core/modules/overlay/overlay-parent.js @@ -12,10 +12,6 @@ */ Drupal.behaviors.overlayParent = { attach: function (context, settings) { - if (Drupal.overlay.isOpen) { - Drupal.overlay.makeDocumentUntabbable(context); - } - if (this.processed) { return; } @@ -94,7 +90,6 @@ Drupal.overlay.open = function (url) { this.isOpening = false; this.isOpen = true; $(document.documentElement).addClass('overlay-open'); - this.makeDocumentUntabbable(); // Allow other scripts to respond to this event. $(document).trigger('drupalOverlayOpen'); @@ -204,11 +199,12 @@ 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'); + Drupal.announce(Drupal.t('Tabbing is no longer constrained by the Overlay module.')); + // When the iframe is still loading don't destroy it immediately but after // the content is loaded (see Drupal.overlay.loadChild). if (!this.isLoading) { @@ -301,15 +297,21 @@ Drupal.overlay.loadChild = function (event) { .attr('title', Drupal.t('@title dialog', { '@title': iframeWindow.jQuery('#overlay-title').text() })).prop('tabindex', false); this.inactiveFrame = event.data.sibling; + Drupal.announce(Drupal.t('The overlay has been opened to @title', {'@title': iframeWindow.jQuery('#overlay-title').text()})); + // Load an empty document into the inactive iframe. (this.inactiveFrame[0].contentDocument || this.inactiveFrame[0].contentWindow.document).location.replace('about:blank'); - // Move the focus to just before the "skip to main content" link inside - // the overlay. - this.activeFrame.focus(); - var skipLink = iframeWindow.jQuery('a:first'); + // Create a fake link before the skip link in order to give it focus. + var skipLink = iframeWindow.jQuery('[href="#main-content"]'); Drupal.overlay.setFocusBefore(skipLink, iframeWindow.document); + // Report these tabbables to the TabbingManger in the parent window. + Drupal.overlay.releaseTabbing(); + Drupal.overlay.constrainTabbing($(iframeWindow.document).add('#toolbar-administration')); + + Drupal.announce(Drupal.t('Tabbing is constrained to items in the administrative toolbar and the overlay.')); + // Allow other scripts to respond to this event. $(document).trigger('drupalOverlayLoad'); } @@ -343,7 +345,7 @@ Drupal.overlay.setFocusBefore = function ($element, document) { var $placeholder = $(placeholder).addClass('element-invisible').attr('href', '#'); // Put the placeholder where it belongs, and set the document focus to it. $placeholder.insertBefore($element); - $placeholder.focus(); + $placeholder.attr('autofocus', true); // Make the placeholder disappear as soon as it loses focus, so that it // doesn't appear in the tab order again. $placeholder.one('blur', function () { @@ -833,80 +835,29 @@ Drupal.overlay.getPath = function (link) { /** * 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); +Drupal.overlay.constrainTabbing = function ($tabbables) { + // If a tabset is already active, return without creating a new one. + if (this.tabset && !this.tabset.isReleased()) { + return; + } // Leave links inside the overlay and toolbars alone. - $overlay = $('.overlay-element, #overlay-container, #toolbar-administration').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.prop('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. - $(context).find('[tabindex]').prop('tabindex', false); - - // 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).prop('tabindex'); - $element.data('drupalOverlayOriginalTabIndex', tabindex); + this.tabset = Drupal.TabbingManager.constrain($tabbables); + var self = this; + $(document).on('drupalOverlayClose.tabbing', function () { + self.tabset.release(); + $(document).off('drupalOverlayClose.tabbing'); + }); }; /** - * 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.prop('tabindex', tabindex); +Drupal.overlay.releaseTabbing = function () { + if (this.tabset) { + this.tabset.release(); + delete this.tabset; + } }; $.extend(Drupal.theme, { diff --git a/core/modules/overlay/overlay.module b/core/modules/overlay/overlay.module index 446d56c..83ba145 100644 --- a/core/modules/overlay/overlay.module +++ b/core/modules/overlay/overlay.module @@ -226,6 +226,7 @@ function overlay_library_info() { array('system', 'drupal'), array('system', 'drupalSettings'), array('system', 'drupal.displace'), + 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 b3f9bc1..5ab9ade 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -1417,6 +1417,21 @@ 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'), + // Depends on jQuery UI Core to use the ":tabbable" pseudo selector. + array('system', 'jquery.ui.core'), + array('system', 'drupal'), + ), + ); + // A utility function to limit calls to a function with a given time. $libraries['drupal.debounce'] = array( 'title' => 'Drupal debounce', -- 1.7.10.4