diff --git a/core/modules/edit/css/edit.css b/core/modules/edit/css/edit.css index b270165..7cc1732 100644 --- a/core/modules/edit/css/edit.css +++ b/core/modules/edit/css/edit.css @@ -362,8 +362,9 @@ display: block; float: left; } -.edit-toolbar a span.close { +.edit-toolbar span.close { background: url('../images/close.png') no-repeat 3px 2px; + text-indent: -999em; } .edit-toolbar a.blank-button { diff --git a/core/modules/edit/edit.module b/core/modules/edit/edit.module index dd4feaf..94e7b24 100644 --- a/core/modules/edit/edit.module +++ b/core/modules/edit/edit.module @@ -89,6 +89,8 @@ function edit_toolbar() { 'href' => request_path(), 'fragment' => 'view', 'attributes' => array( + 'title' => t('Exit quick edit mode.'), + 'role' => 'button', 'class' => array('edit_view-edit-toggle', 'edit-view'), ), ), @@ -97,6 +99,8 @@ function edit_toolbar() { 'href' => request_path(), 'fragment' => 'quick-edit', 'attributes' => array( + 'title' => t('Enter quick edit mode.'), + 'role' => 'button', 'class' => array('edit_view-edit-toggle', 'edit-edit'), ), ), @@ -218,6 +222,7 @@ function edit_preprocess_field(&$variables) { // Mark this field as editable and provide metadata through data- attributes. $variables['attributes']['data-edit-field-label'] = $instance->definition['label']; $variables['attributes']['data-edit-id'] = $entity->entityType() . ':' . $entity->id() . ':' . $field_name . ':' . $langcode . ':' . $view_mode; + $variables['attributes']['aria-label'] = t('Edit entity @type @id, field @field', array('@type' => $entity->entityType(), '@id' => $entity->id(), '@field' => $instance->definition['label'])); $variables['attributes']['class'][] = 'edit-field'; $variables['attributes']['class'][] = 'edit-allowed'; $variables['attributes']['class'][] = 'edit-type-' . $editor; diff --git a/core/modules/edit/js/app.js b/core/modules/edit/js/app.js index c5d5be5..4daa65a 100644 --- a/core/modules/edit/js/app.js +++ b/core/modules/edit/js/app.js @@ -91,6 +91,15 @@ this.$entityElements.each(function() { $(this).createEditable('setState', newState); }); + // Manage the page's tab indexes. + if (newState === 'candidate') { + this._manageDocumentFocus(); + Drupal.edit.setMessage(Drupal.t('In place edit mode is active'), Drupal.t('Page navigation is limited to editable items.'), Drupal.t('Press escape to exit')); + } + else { + this._releaseDocumentFocusManagement(); + Drupal.edit.setMessage(Drupal.t('Edit mode is inactive.'), Drupal.t('Resume normal page navigation')); + } }, /** @@ -269,6 +278,7 @@ // Keep track of the active editor in the global state. if (_.indexOf(this.activeEditorStates, to) !== -1 && this.model.get('activeEditor') !== editor) { this.model.set('activeEditor', editor); + Drupal.edit.setMessage(Drupal.t('An editor is active')); } else if (this.model.get('activeEditor') === editor && to === 'candidate') { this.model.set('activeEditor', null); @@ -312,6 +322,139 @@ editor.decorationView.stateChange(data.previous, data.current); editor.toolbarView.stateChange(data.previous, data.current); }); + }, + /** + * Makes elements other than the editables unreachable via the tab key. + * + * @todo refactoring. + * + * This method is currently overloaded, handling elements of state modeling + * and application control. The state of the application is spread between + * this view, its model and aspects of the UI widgets in Create.js. In order + * to drive focus management from the application state (and have it + * influence that state of the application), we need to distall state out + * of Create.js components. + * + * This method introduces behaviors that support accessibility of the edit + * application. Although not yet integrated into the application properly, + * it does provide us with the opportunity to collect feedback from + * users who will interact with edit primarily through keyboard input. We + * want this feedback sooner than we can have a refactored application. + */ + _manageDocumentFocus: function () { + var editablesSelector = '.edit-candidate.edit-editable'; + var inputsSelector = 'a:visible, button:visible, input:visible, textarea:visible, select:visible'; + var $editables = $(editablesSelector) + .attr({ + 'tabindex': 0, + 'role': 'button' + }); + // Store the first editable in the set. + var $currentEditable; + // We're using simple function scope to manage 'this' for the internal + // handler, so save this as that. + var that = this; + // Turn on focus management. + $(document).on('keydown.edit', function (event) { + var activeEditor, editableEntity, predicate; + // Handle esc key press. Close any active editors. + if (event.keyCode === 27) { + event.preventDefault(); + activeEditor = that.model.get('activeEditor'); + if (activeEditor) { + editableEntity = activeEditor.options.widget; + predicate = activeEditor.options.property; + editableEntity.setState('candidate', predicate, { reason: 'overlay' }); + } + else { + $(editablesSelector).trigger('tabOut.edit'); + // This should move into the state management for the app model. + location.hash = "#view"; + that.model.set('isViewing', true); + } + return; + } + // Handle enter or space key presses. + if (event.keyCode === 13 || event.keyCode === 32) { + if ($currentEditable && $currentEditable.is(editablesSelector)) { + $currentEditable.trigger('click'); + // Squelch additional handlers. + event.preventDefault(); + return; + } + } + // Handle tab key presses. + if (event.keyCode === 9) { + var context = ''; + // Include the view mode toggle with the editables selector. + var selector = editablesSelector + ', .edit_view-edit-toggle.edit-view'; + activeEditor = that.model.get('activeEditor'); + var $confirmDialog = $('#edit_modal'); + // If the edit modal is active, that is the tabbing context. + if ($confirmDialog.length) { + context = $confirmDialog; + selector = inputsSelector; + if (!$currentEditable.length || $currentEditable.is(editablesSelector)) { + $currentEditable = $(selector, context).eq(-1); + } + } + // If an editor is active, then the tabbing context is the editor and + // its toolbar. + else if (activeEditor) { + context = $(activeEditor.$formContainer).add(activeEditor.toolbarView.$el); + // Include the view mode toggle with the editables selector. + selector = inputsSelector + ', .edit_view-edit-toggle.edit-view'; + if (!$currentEditable.length || $currentEditable.is(editablesSelector)) { + $currentEditable = $(selector, context).eq(-1); + } + } + // Otherwise the tabbing context is the list of editable predicates. + var $editables = $(selector, context); + if (!$currentEditable) { + $currentEditable = $editables.eq(-1); + } + var count = $editables.length - 1; + var index = $editables.index($currentEditable); + console.log(index + " of " + count); + // Navigate backwards. + if (event.shiftKey) { + // Beginning of the set, loop to the end. + if (index === 0) { + index = count; + } + else { + index -= 1; + } + } + // Navigate forewards. + else { + // End of the set, loop to the start. + if (index === count) { + index = 0; + } + else { + index += 1; + } + } + // Tab out of the current editable. + $currentEditable.trigger('tabOut.edit'); + // Update the current editable. + $currentEditable = $editables + .eq(index) + .focus() + .trigger('tabIn.edit'); + // Squelch additional handlers. + event.preventDefault(); + event.stopPropagation(); + } + }); + }, + /** + * Removes key management and edit accessibility features from the DOM. + */ + _releaseDocumentFocusManagement: function () { + $(document).off('keydown.edit'); + $('.edit-candidate.edit-editable').removeAttr('tabindex role'); } }); diff --git a/core/modules/edit/js/createjs/editingWidgets/formwidget.js b/core/modules/edit/js/createjs/editingWidgets/formwidget.js index 9a5153d..f7c77cd 100644 --- a/core/modules/edit/js/createjs/editingWidgets/formwidget.js +++ b/core/modules/edit/js/createjs/editingWidgets/formwidget.js @@ -80,6 +80,7 @@ this.$formContainer .find('.edit-form') .addClass('edit-editable edit-highlighted edit-editing') + .attr('role', 'dialog') .css('background-color', $editorElement.css('background-color')); // Insert form container in DOM. diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js index 2c42068..e208c34 100644 --- a/core/modules/edit/js/edit.js +++ b/core/modules/edit/js/edit.js @@ -6,11 +6,24 @@ "use strict"; +/** + * The edit ARIA live message area. + * + * @todo Eventually the messages area should be converted into a Backbone View + * that will respond to changes in the application's model. For the initial + * implementation, we will call the Drupal.edit.setMessage method when an aural + * message should be read by the user agent. + */ +var $messages; + Drupal.edit = Drupal.edit || {}; Drupal.behaviors.editDiscoverEditables = { attach: function(context) { - // @todo BLOCKED_ON(VIE.js, how to let VIE know that some content was removed and how to scan new content for VIE entities, to make them editable?) + // @todo BLOCKED_ON(VIE.js, how to let VIE know that some content was + // removed and how to scan new content for VIE entities, to make them + // editable?) + // // Also see ToolbarView.save(). // We need to separate the discovery of editables if we want updated // or new content (added by code other than Edit) to be detected @@ -35,6 +48,9 @@ Drupal.behaviors.edit = { }; Drupal.edit.init = function() { + // Append a messages element for appending interaction updates for screen + // readers. + $messages = $(Drupal.theme('editMessageBox')).appendTo(this); // Instantiate EditAppView, which is the controller of it all. EditAppModel // instance tracks global state (viewing/editing in-place). var appModel = new Drupal.edit.models.EditAppModel(); @@ -52,4 +68,42 @@ Drupal.edit.init = function() { Backbone.history.start(); }; +/** + * Places the message in the edit ARIA live message area. + * + * The message will be read by speaking User Agents. + * + * @param {String} message + * A string to be inserted into the message area. + */ +Drupal.edit.setMessage = function (message) { + var args = Array.prototype.slice.call(arguments); + args.unshift('editMessage'); + $messages.html(Drupal.theme.apply(this, args)); +} + +/** + * A region to post messages that a screen reading UA will announce. + * + * @return {String} + * A string representing a DOM fragment. + */ +Drupal.theme.editMessageBox = function () { + return '
'; +}; + +/** + * Wrap message strings in p tags. + * + * @return {String} + * A string representing a DOM fragment. + */ +Drupal.theme.editMessage = function () { + var messages = Array.prototype.slice.call(arguments); + var output = ''; + for (var i = 0; i < messages.length; i++) { + output += '

' + messages[i] + '

'; + } + return output; +}; })(jQuery, Backbone, Drupal); diff --git a/core/modules/edit/js/theme.js b/core/modules/edit/js/theme.js index f92c308..8e154fe 100644 --- a/core/modules/edit/js/theme.js +++ b/core/modules/edit/js/theme.js @@ -48,7 +48,7 @@ Drupal.theme.editBackstage = function(settings) { Drupal.theme.editModal = function(settings) { var classes = 'edit-animate-slow edit-animate-invisible edit-animate-delay-veryfast'; var html = ''; - html += '
'; + html += ''; diff --git a/core/modules/edit/js/views/modal-view.js b/core/modules/edit/js/views/modal-view.js index c5d91ab..13bf38d 100644 --- a/core/modules/edit/js/views/modal-view.js +++ b/core/modules/edit/js/views/modal-view.js @@ -69,6 +69,8 @@ Drupal.edit.views.ModalView = Backbone.View.extend({ setTimeout(function() { that.$el.removeClass('edit-animate-invisible'); }, 0); + + Drupal.edit.setMessage(Drupal.t('Confirmation dialog open')); }, /** diff --git a/core/modules/edit/js/views/propertyeditordecoration-view.js b/core/modules/edit/js/views/propertyeditordecoration-view.js index 7d8a27c..269259a 100644 --- a/core/modules/edit/js/views/propertyeditordecoration-view.js +++ b/core/modules/edit/js/views/propertyeditordecoration-view.js @@ -22,7 +22,9 @@ Drupal.edit.views.PropertyEditorDecorationView = Backbone.View.extend({ events: { 'mouseenter.edit' : 'onMouseEnter', - 'mouseleave.edit' : 'onMouseLeave' + 'mouseleave.edit' : 'onMouseLeave', + 'tabIn.edit': 'onMouseEnter', + 'tabOut.edit': 'onMouseLeave' }, /** diff --git a/core/modules/edit/js/views/toolbar-view.js b/core/modules/edit/js/views/toolbar-view.js index 4b8235c..a48ea28 100644 --- a/core/modules/edit/js/views/toolbar-view.js +++ b/core/modules/edit/js/views/toolbar-view.js @@ -278,7 +278,7 @@ Drupal.edit.views.ToolbarView = Backbone.View.extend({ classes: 'ops', buttons: [ { label: Drupal.t('Save'), classes: 'field-save save gray-button' }, - { label: '', classes: 'field-close close gray-button' } + { label: '' + Drupal.t('Close') + '', classes: 'field-close close gray-button' } ] })); this.show('ops');