Issue #1913086 by jessebeach, Wim Leers, mgifford, nod_: Generalize the overlay tabbing management into a utility library.

8.0.x
Dries 2013-04-19 13:20:06 -04:00
parent e16a4241c5
commit 17ad22fd27
7 changed files with 446 additions and 87 deletions

298
core/misc/tabbingmanager.js Normal file
View File

@ -0,0 +1,298 @@
/**
* @file
* Manages page tabbing modifications made by modules.
*/
(function ($, Drupal) {
"use strict";
// Do not process the window of the overlay.
if (parent.Drupal.overlay && parent.Drupal.overlay.iframeWindow === window) {
return;
}
/**
* 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 elements
* The set of elements to which tabbing should be constrained. Can also be
* a jQuery-compatible selector string.
*
* @return TabbingContext
*/
constrain: function (elements) {
// 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 $elements = $(elements).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: $elements
});
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.
*/
release: function () {
// Unwind as far as possible: find the topmost non-released tabbingContext.
var toActivate = this.stack.length - 1;
while (toActivate >= 0 && this.stack[toActivate].released) {
toActivate--;
}
// 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(toActivate + 1);
// Get topmost tabbingContext, if one exists, and activate it.
if (toActivate >= 0) {
this.stack[toActivate].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.
*/
activate: function (tabbingContext) {
var $set = tabbingContext.$tabbableElements;
var level = tabbingContext.level;
// 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.$disabledElements = $disabledSet;
// Record the tabindex for each element, so we can restore it later.
for (var i = 0, il = $disabledSet.length; i < il; i++) {
this.recordTabindex($disabledSet.eq(i), 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.
*/
deactivate: function (tabbingContext) {
var $set = tabbingContext.$disabledElements;
var level = tabbingContext.level;
for (var i = 0, il = $set.length; i < il; i++) {
this.restoreTabindex($set.eq(i), 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 ($el, level) {
var tabInfo = $el.data('drupalOriginalTabIndices') || {};
tabInfo[level] = {
tabindex: $el[0].getAttribute('tabindex'),
autofocus: $el[0].hasAttribute('autofocus')
};
$el.data('drupalOriginalTabIndices', tabInfo);
},
/**
* Restores the tabindex and autofocus values of a reactivated element.
*
* @param jQuery $el
* The element that is being reactivated.
* @param Number level
* The stack level for which the tabindex attribute should be restored.
*/
restoreTabindex: function ($el, level) {
var tabInfo = $el.data('drupalOriginalTabIndices');
if (tabInfo && tabInfo[level]) {
var data = tabInfo[level];
if (data.tabindex) {
$el[0].setAttribute('tabindex', data.tabindex);
}
// If the element did not have a tabindex at this stack level then
// remove it.
else {
$el[0].removeAttribute('tabindex');
}
if (data.autofocus) {
$el[0].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 levelToDelete = level;
while (tabInfo.hasOwnProperty(levelToDelete)) {
delete tabInfo[levelToDelete];
levelToDelete++;
}
$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 TabbingContext (options) {
$.extend(this, {
level: null,
$tabbableElements: $(),
$disabledElements: $(),
released: false,
active: false
}, options);
}
/**
* Add public methods to the TabbingContext class.
*/
$.extend(TabbingContext.prototype, {
/**
* Releases this TabbingContext.
*
* Once a TabbingContext object is released, it can never be activated again.
*/
release: function () {
if (!this.released) {
this.deactivate();
this.released = true;
Drupal.tabbingManager.release(this);
// Allow modules to respond to the tabbingContext release event.
$(document).trigger('drupalTabbingContextReleased', this);
}
},
/**
* Activates this TabbingContext.
*/
activate: function () {
// A released TabbingContext object can never be activated again.
if (!this.active && !this.released) {
this.active = true;
Drupal.tabbingManager.activate(this);
// Allow modules to respond to the constrain event.
$(document).trigger('drupalTabbingContextActivated', this);
}
},
/**
* Deactivates this TabbingContext.
*/
deactivate: function () {
if (this.active) {
this.active = false;
Drupal.tabbingManager.deactivate(this);
// Allow modules to respond to the constrain event.
$(document).trigger('drupalTabbingContextDeactivated', this);
}
}
});
// Mark this behavior as processed on the first pass and return if it is
// already processed.
if (Drupal.tabbingManager) {
return;
}
Drupal.tabbingManager = new TabbingManager();
}(jQuery, Drupal));

View File

@ -109,6 +109,7 @@ function contextual_library_info() {
array('system', 'jquery'),
array('system', 'jquery.once'),
array('system', 'backbone'),
array('system', 'drupal.tabbingmanager'),
),
);

View File

@ -16,15 +16,19 @@
*/
Drupal.behaviors.contextualToolbar = {
attach: function (context) {
var that = this;
$('body').once('contextualToolbar-init', function () {
var options = $.extend({}, that.defaults);
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,
model: model
model: model,
strings: options.strings
});
// Update the model based on overlay events.
@ -53,6 +57,18 @@ Drupal.behaviors.contextualToolbar = {
model.set('isViewing', false);
}
});
},
defaults: {
strings: {
tabbingReleased: Drupal.t('Tabbing is no longer constrained by the Contextual module'),
tabbingConstrained: Drupal.t('Tabbing is constrained to a set of @contextualsCount and the Edit mode toggle'),
pressEsc: Drupal.t('Press the esc key to exit.'),
contextualsCount: {
singular: '@count contextual link',
plural: '@count contextual links'
}
}
}
};
@ -66,7 +82,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: []
}
});
@ -77,12 +98,22 @@ Drupal.contextualToolbar.views.EditToggleView = Backbone.View.extend({
events: { 'click': 'onClick' },
// Tracks whether the tabbing constraint announcement has been read once yet.
announcedOnce: false,
/**
* Implements Backbone Views' initialize().
*/
initialize: function () {
this.strings = this.options.strings;
this.model.on('change', this.render, this);
this.model.on('change:isViewing', this.persist, this);
this.model.on('change:isViewing', this.manageTabbing, this);
$(document)
.on('keyup', $.proxy(this.onKeypress, this));
},
/**
@ -97,11 +128,32 @@ Drupal.contextualToolbar.views.EditToggleView = Backbone.View.extend({
var isViewing = this.model.get('isViewing');
this.$el.find('button')
.toggleClass('active', !isViewing)
.prop('aria-pressed', !isViewing);
.attr('aria-pressed', !isViewing);
return this;
},
/**
* 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 tabbingContext = this.model.get('tabbingContext');
// Always release an existing tabbing context.
if (tabbingContext) {
tabbingContext.release();
}
// 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);
}
},
/**
* Model change handler; persists the isViewing value to localStorage.
*
@ -122,10 +174,55 @@ Drupal.contextualToolbar.views.EditToggleView = Backbone.View.extend({
}
},
/**
* Passes state update messsages to Drupal.announce.
*/
announceTabbingConstraint: function () {
var isViewing = this.model.get('isViewing');
if (!isViewing) {
var contextuals = this.model.get('contextuals');
Drupal.announce(Drupal.t(this.strings.tabbingConstrained, {
'@contextualsCount': Drupal.formatPlural(contextuals.length, this.strings.contextualsCount.singular, this.strings.contextualsCount.plural)
}));
Drupal.announce(this.strings.pressEsc);
}
else {
Drupal.announce(this.strings.tabbingReleased)
}
},
/**
* Responds to the edit mode toggle toolbar button; Toggles edit mode.
*
* @param jQuery.Event event
*/
onClick: function (event) {
this.model.set('isViewing', !this.model.get('isViewing'));
this.announceTabbingConstraint();
this.announcedOnce = true;
event.preventDefault();
event.stopPropagation();
},
/**
* Responds to esc and tab key press events.
*
* @param jQuery.Event event
*/
onKeypress: function (event) {
// Respond to tab key press; Call render so the state announcement is read.
// The first tab key press is tracked so that an annoucement about tabbing
// constraints can be raised if edit mode is enabled when this page loads.
if (!this.announcedOnce && event.keyCode === 9 && !this.model.get('isViewing')) {
this.announceTabbingConstraint();
// Set announce to true so that this conditional block won't be run again.
this.announcedOnce = true;
}
// Respond to the ESC key. Exit out of edit mode.
if (event.keyCode === 27) {
this.model.set('isViewing', true);
}
}
});

View File

@ -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;
}

View File

@ -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.released) {
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);
this.tabset = Drupal.tabbingManager.constrain($tabbables);
var self = this;
$(document).on('drupalOverlayClose.tabbing', function () {
self.tabset.release();
$(document).off('drupalOverlayClose.tabbing');
});
};
/**
* 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);
};
/**
* 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, {

View File

@ -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'),
),

View File

@ -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',