diff --git a/core/misc/tabbingmanager.js b/core/misc/tabbingmanager.js index c43467e..7b4a9e2 100644 --- a/core/misc/tabbingmanager.js +++ b/core/misc/tabbingmanager.js @@ -7,178 +7,421 @@ "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'; +var browserTabbableElementsSelector = 'a,area,button,iframe,input,object,select,summary,textarea'; -// 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 = []; +/** + * + */ +Drupal.behaviors.drupalTabbingManager = { + attach: function (context, settings) { + // once() returns a jQuery set. It will be empty if no unprocessed + // elements are found. window and window.parent are equivalent unless the + // Drupal page is itself wrapped in an iframe. + $(window.parent.document.body).once('drupalTabbingManager', function () { + // If this window is itself in an iframe it must be marked as processed. + // Its parent window will have been processed above. + // When attach() is called again for the preview iframe, it will check + // its parent window and find it has been processed. In most cases, the + // following code will have no effect. + $(window.document.body).once('drupalTabbingManager'); + // Listen for key events on the document. + $(document).on('keydown.drupalTabbingManager keyup.drupalTabbingManager', keyHandler); + }); + } +}; + +/** + * + */ +var keyHandler = function (event) { + switch (event.keyCode) { + // Esc key. + case 27: + break; + // Enter or space keys. + case 13: + case 32: + break; + // Tab key. + case 9: + manager.tab(event); + break; + default: + break; + } +} /** * Provides an API for managing page tabbing order modifications. */ function TabbingManager () { - return { - constrain: constrain - }; + // By default, browsers make a, area, button, input, object, select, textarea, + // and iframe elements reachable via the tab key. + this.browserTabbableElementsSelector = browserTabbableElementsSelector; + + // 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. + this.stack = []; } /** - * 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. + * Add public methods to the TabbingManager class. */ -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) { +$.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 {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. + */ + constrain: function (id, set) { + // If the id parameter is an object, then no id is provided for this set. + if (typeof id === 'object') { + set = id; + id = null; + } + + // The "active tabbing set" are the elements tabbing should be constrained to. + var $tabbableElements = ('jquery' in set) ? set : $(set); + + $tabbableElements.attr('tabindex', 1); + + // Determine which elements are "tabbable" (reachable via tabbing) by default. + var $disabledTabbingSet = $(':drupalTabbable') + // Exclude elements of the active tabbing set. + .not($tabbableElements); + + // Make all tabbable elements outside of the active tabbing set unreachable. + $disabledTabbingSet + // Record the tabindex for each element, so we can restore it later. + .recordTabindex(this.stack.length) + // Set tabindex to -1 on everything outside the tabbable set. + .attr('tabindex', -1); + // 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; + var stackFrame = new TabbingContext({ + id: id, + // Get a fresh set of the tabbable elements so that the array will be + // enumerated in DOM order. + $tabbableElements: $tabbableElements, + $disabledTabbingSet: $disabledTabbingSet, + onRelease: $.proxy(this.releaseSet, this) + }); + this.stack.push(stackFrame); + + // Trigger an event + $(document).trigger('drupalTabbingConstrained', stackFrame); + + return stackFrame; + }, + /** + * + */ + getContext: function (id) { + for (var i = 0; i < this.stack.length; i++) { + if (this.stack[i].getId() === id) { + return this.stack[i]; } - }; - }($tabbable)); -}; + } + return null; + }, + /** + * + */ + getActiveContext: function () { + return this.stack.slice(-1).shift(); + }, + /** + * 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. + */ + releaseSet: function (tabbingContext) { + // Only allow the top of the stack to be unwound and only unnwind if the top + // of the stack is released. + var top = this.stack.slice().pop(); + if (tabbingContext !== top || !top.isReleased()) { + 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 = this.stack.length; + for (var i = 0, n = this.stack.length; i < n && this.stack[i].isReleased(); i++) { + stackLevelToRestore--; + } + + // Restore the tabindex attributes that existed before this constraint was + // applied. + $('[tabindex]').removeAttr('tabindex'); + // If the level being restored is the document, just return after removing + // the tabindexes. + // @todo The tabindexes should only be removed from elements that we added + // it to, an then only if it didn't have tabindex before. + if (stackLevelToRestore <= 0) { + return; + } + this.stack[stackLevelToRestore].getDisabledElements().restoreTabindex(stackLevelToRestore); + + // Delete all stack frames starting at stackLevelToRestore (and always going + // up to stackLevel). + this.stack.splice(stackLevelToRestore); + }, + /** + * + */ + tab: function (event) { + var context = this.getActiveContext(); + // If the context has already been released, do nothing. + if (!context || context.isReleased()) { + return; + } + switch (event.type) { + // Record the current active element. + case 'keydown': + $(event.target).css('background-color', 'red'); + context.setActiveElement(event.target); + break; + // Respond to the new current active element. + case 'keyup': + $(event.target).css('background-color', 'blue'); + (event.shiftKey) ? context.prev() : context.next(); + break; + default: + break; + } + }, + /** + * + */ + getElementSelector: function () { + return this.browserTabbableElementsSelector; + } +}); /** - * 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; - } +function TabbingContext (options) { + $.extend(this, { + id: null, + $disabledElements: $(), + $tabbableElements: $(), + released: false, + activeItemIndex: undefined, + onRelease: function () {} + }, options); +} - // 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--; - } +/** + * Add public methods to the TabbingContext class. + */ +$.extend(TabbingContext.prototype, { + getId: function () { + return this.id; + }, + getDisabledElements: function () { + return this.$disabledElements; + }, + getTabbableElements: function () { + return this.$tabbableElements; + }, + getActiveElement: function () { + return this.$tabbableElements.eq(this.activeItemIndex); + }, + setActiveElement: function (element) { + if (typeof element === 'number' && element >= 0 && element < this.$tabbableElements.length) { + this.activeItemIndex = element; + return true; + } + if (typeof element === 'object') { + var index = this.$tabbableElements.index(element); + if (index >= 0) { + this.activeItemIndex = index; + } + return true; + } + this.activeItemIndex = undefined; + return false; + }, + add: function ($elements) { + this.$tabbableElements = this.$tabbableElements.add($elements); + var elements = this.$tabbableElements.get(); + this.$tabbableElements = $($.unique(elements)).sort(); + }, + release: function () { + if (!this.released) { + this.released = true; + this.onRelease(this); + } + }, + isReleased: function () { + return this.released; + }, + next: function () { + var count = this.$tabbableElements.length; + var index = this.activeItemIndex; + var loop = false; + // This is the first tab or the user tabbed outside the defined tabbable + // set of elements. + if (index === undefined) { + index = -1; + loop = true; + } - // Restore the tabindex attributes that existed before this constraint was - // applied. - $('[tabindex]').removeAttr('tabindex'); - stack[stackLevelToRestore].$untabbableElements.restoreTabindex(stackLevelToRestore); + index++; - // Delete all stack frames starting at stackLevelToRestore (and always going - // up to stackLevel). - stack.splice(stackLevelToRestore); -}; + // Loop around to the beginning of the set. + if (index >= count) { + index = 0; + loop = true; + } + + this.setActiveElement(index); + + if (loop) { + this.getActiveElement().focus(); + } + +// this.getActiveElement().css('background-color', 'yellow'); + + Drupal.announce(Drupal.t('item @index of @count', { + '@index': index + 1, + '@count': count + })); + }, + prev: function () { + var count = this.$tabbableElements.length; + var index = this.activeItemIndex; + var loop = false; + // This is the first tab or the user tabbed outside the defined tabbable + // set of elements. + if (index === undefined) { + index = count; + loop = true; + } + + index--; + + // Loop around to the end of the set. + if (index < 0) { + index = count - 1; + loop = true; + } + + this.setActiveElement(index); + + if (loop) { + this.getActiveElement().focus(); + } + +// this.getActiveElement().css('background-color', 'green'); + + Drupal.announce(Drupal.t('item @index of @count', { + '@index': index + 1, + '@count': count + })); + } +}); /** - * 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. + * Custom jQuery Collection methods. */ -$.fn.recordTabindex = function (stackLevel) { - return this - .filter('[tabindex]') - .each(function () { +$.extend($.fn, { + /** + * 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. + */ + 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. + */ + restoreTabindex: function (stackLevel) { + return this.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(); -}; + var tabIndices = $element.data('drupalOriginalTabIndices'); -/** - * 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++; + 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); } - $element.data('drupalOriginalTabIndices', tabIndices); } - } - }); -}; + }); + } +}); + +/** + * Custom jQuery pseudo selectors. + */ +$.extend($.expr[':'], { + drupalTabbable: function (element, index, selector, collection) { + var $element = $(element); + var tabindex = $element.attr('tabindex'); + return ($element.is(browserTabbableElementsSelector) && (tabindex === undefined || parseInt(tabindex) > -1)) || (tabindex !== undefined && parseInt(tabindex) > -1); + }, + drupalUntabbable: function (element, index, selector, collection) { + var $element = $(element); + var tabindex = $element.attr('tabindex'); + return (tabindex !== undefined && parseInt(tabindex) < 0) || !$element.is(browserTabbableElementsSelector); + } +}); // 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-child.js b/core/modules/overlay/overlay-child.js index 80a8a2f..f27d90e 100644 --- a/core/modules/overlay/overlay-child.js +++ b/core/modules/overlay/overlay-child.js @@ -179,11 +179,35 @@ Drupal.overlayChild.behaviors.shortcutAddLink = function (context, settings) { }); }; -// Workaround because of the way jQuery events works. -// jQuery from the parent frame needs to be used to catch this event. -parent.jQuery(document).bind('offsettopchange', function () { - // Fires an event that the child iframe can listen to. - $(document).trigger('offsettopchange'); -}); +/** + * + */ +Drupal.overlayChild.behaviors.handleOffsetTopChange = function (context, settings) { + // Workaround because of the way jQuery events works. + // jQuery from the parent frame needs to be used to catch this event. + parent.jQuery(document).bind('offsettopchange', function (event) { + // Fires an event that the child iframe can listen to. + $(document).trigger('offsettopchange'); + }); +} + +/** + * Find tabbable elements and report them to Drupal.TabbingManager(). + */ +Drupal.overlayChild.behaviors.findTabbables = function (context, settings) { + // Workaround because of the way jQuery events works. + // jQuery from the parent frame needs to be used to catch this event. + parent.jQuery(document).on('keydown.overlayChild keyup.overlayChild', function (event) { + // Fires an event that the child iframe can listen to. + switch (event.keyCode) { + // Tab key. + case 9: + parent.Drupal.TabbingManager.tab(event); + break; + default: + break; + } + }); +}; })(jQuery); diff --git a/core/modules/overlay/overlay-parent.js b/core/modules/overlay/overlay-parent.js index 2e7ef4c..c7411ab 100644 --- a/core/modules/overlay/overlay-parent.js +++ b/core/modules/overlay/overlay-parent.js @@ -15,11 +15,6 @@ var tabset; */ Drupal.behaviors.overlayParent = { attach: function (context, settings) { - if (Drupal.overlay.isOpen) { - // Constrain the tabbing order. - Drupal.overlay.constrainTabbing(); - } - if (this.processed) { return; } @@ -98,8 +93,6 @@ Drupal.overlay.open = function (url) { this.isOpening = false; this.isOpen = true; $(document.documentElement).addClass('overlay-open'); - // Constrain the tabbing order. - Drupal.overlay.constrainTabbing(); // Allow other scripts to respond to this event. $(document).trigger('drupalOverlayOpen'); @@ -305,6 +298,14 @@ Drupal.overlay.loadChild = function (event) { // Load an empty document into the inactive iframe. (this.inactiveFrame[0].contentDocument || this.inactiveFrame[0].contentWindow.document).location.replace('about:blank'); + + // Find the tabble elements in the iframeWindow document. + var $iframeTabbables = $(':drupalTabbable', iframeWindow.document); + + // Report these tabbables to the TabbingManger in the parent window. + Drupal.overlay.releaseTabbing(); + Drupal.overlay.constrainTabbing($iframeTabbables); + // Move the focus to just before the "skip to main content" link inside // the overlay. this.activeFrame.focus(); @@ -445,9 +446,11 @@ Drupal.overlay.eventhandlerAlterDisplacedElements = function (event) { }).attr('data-offset-top', Drupal.overlay.getDisplacement('top')); $(document).bind('offsettopchange', function () { - var iframeDocument = Drupal.overlay.iframeWindow.document; - $(iframeDocument.body).attr('data-offset-top', Drupal.overlay.getDisplacement('top')); - $(iframeDocument).trigger('offsettopchange'); + if (Drupal.overlay.iframeWindow) { + var iframeDocument = Drupal.overlay.iframeWindow.document; + $(iframeDocument.body).attr('data-offset-top', Drupal.overlay.getDisplacement('top')); + $(iframeDocument).trigger('offsettopchange'); + } }); var documentHeight = this.iframeWindow.document.body.clientHeight; @@ -877,23 +880,32 @@ Drupal.overlay.getPath = function (link) { /** * Makes elements outside the overlay unreachable via the tab key. */ -Drupal.overlay.constrainTabbing = function () { - var tabset; - +Drupal.overlay.constrainTabbing = function ($tabbables) { // If a tabset is already active, return without creating a new one. - if (tabset && !tabset.isReleased()) { + if (this.tabset && !this.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); + this.tabset = Drupal.TabbingManager.constrain('overlay', $tabbables); + var self = this; $(document).on('drupalOverlayClose.tabbing', function () { - tabset.release(); + self.tabset.release(); $(document).off('drupalOverlayClose.tabbing'); }); }; /** + * + */ +Drupal.overlay.releaseTabbing = function () { + var tabset = Drupal.TabbingManager.getContext('overlay'); + if (tabset) { + tabset.release(); + delete this.tabset; + } +}; + +/** * Get the total displacement of given region. * * @param region