diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module index a465e4767adf8c125e13159fda72c53f64685322..47f521fbb25c248f926a37015ddde7bc5af386c8 100644 --- a/core/modules/contextual/contextual.module +++ b/core/modules/contextual/contextual.module @@ -94,14 +94,26 @@ function contextual_permission() { */ function contextual_library_info() { $path = drupal_get_path('module', 'contextual'); + // Add the JavaScript, with a group and weight such that it will run + // before core/modules/contextual/js/contextual.toolbar.js. + $options = array( + 'group' => JS_LIBRARY, + 'weight' => -2, + ); $libraries['drupal.contextual-links'] = array( 'title' => 'Contextual Links', 'website' => 'http://drupal.org/node/473268', 'version' => \Drupal::VERSION, 'js' => array( - // Add the JavaScript, with a group and weight such that it will run - // before modules/contextual/js/contextual.toolbar.js. - $path . '/js/contextual.js' => array('group' => JS_LIBRARY, 'weight' => -2), + // Core. + $path . '/js/contextual.js' => $options, + // Models. + $path . '/js/models/StateModel.js' => $options, + // Views. + $path . '/js/views/AuralView.js' => $options, + $path . '/js/views/KeyboardView.js' => $options, + $path . '/js/views/RegionView.js' => $options, + $path . '/js/views/VisualView.js' => $options, ), 'css' => array( $path . '/css/contextual.module.css' => array(), diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js index 46164113362d8c1cf10f7f951ff9979cb2f663bc..0b0561e02355f2ab7854d6f35826beba0153d875 100644 --- a/core/modules/contextual/js/contextual.js +++ b/core/modules/contextual/js/contextual.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module. */ -(function ($, Drupal, drupalSettings, Backbone, Modernizr) { +(function ($, Drupal, drupalSettings, Backbone) { "use strict"; @@ -36,7 +36,7 @@ function initContextual ($contextual) { .prepend(Drupal.theme('contextualTrigger')); // Create a model and the appropriate views. - var model = new contextual.Model({ + var model = new contextual.StateModel({ title: $region.find('h2:first').text().trim() }); var viewOptions = $.extend({ el: $contextual, model: model }, options); @@ -160,9 +160,6 @@ Drupal.behaviors.contextual = { } }; -/** - * Model and View definitions. - */ Drupal.contextual = { // The Drupal.contextual.View instances associated with each list element of // contextual links. @@ -170,253 +167,11 @@ Drupal.contextual = { // The Drupal.contextual.RegionView instances associated with each contextual // region element. - regionViews: [], - - /** - * Models the state of a contextual link's trigger and list. - */ - Model: Backbone.Model.extend({ - defaults: { - // The title of the entity to which these contextual links apply. - title: '', - // Represents if the contextual region is being hovered. - regionIsHovered: false, - // Represents if the contextual trigger or options have focus. - hasFocus: false, - // Represents if the contextual options for an entity are available to - // be selected. - isOpen: false, - // When the model is locked, the trigger remains active. - isLocked: false - }, - - /** - * Opens or closes the contextual link. - * - * If it is opened, then also give focus. - */ - toggleOpen: function () { - var newIsOpen = !this.get('isOpen'); - this.set('isOpen', newIsOpen); - if (newIsOpen) { - this.focus(); - } - return this; - }, - - /** - * Closes this contextual link. - * - * Does not call blur() because we want to allow a contextual link to have - * focus, yet be closed for example when hovering. - */ - close: function () { - this.set('isOpen', false); - return this; - }, - - /** - * Gives focus to this contextual link. - * - * Also closes + removes focus from every other contextual link. - */ - focus: function () { - this.set('hasFocus', true); - var cid = this.cid; - this.collection.each(function (model) { - if (model.cid !== cid) { - model.close().blur(); - } - }); - return this; - }, - - /** - * Removes focus from this contextual link, unless it is open. - */ - blur: function () { - if (!this.get('isOpen')) { - this.set('hasFocus', false); - } - return this; - } - }), - - /** - * Renders the visual view of a contextual link. Listens to mouse & touch. - */ - VisualView: Backbone.View.extend({ - events: function () { - // Prevents delay and simulated mouse events. - var touchEndToClick = function (event) { - event.preventDefault(); - event.target.click(); - }; - var mapping = { - 'click .trigger': function () { this.model.toggleOpen(); }, - 'touchend .trigger': touchEndToClick, - 'click .contextual-links a': function () { this.model.close().blur(); }, - 'touchend .contextual-links a': touchEndToClick - }; - // We only want mouse hover events on non-touch. - if (!Modernizr.touch) { - mapping.mouseenter = function () { this.model.focus(); }; - } - return mapping; - }, - - /** - * {@inheritdoc} - */ - initialize: function () { - this.listenTo(this.model, 'change', this.render); - }, - - /** - * {@inheritdoc} - */ - render: function () { - var isOpen = this.model.get('isOpen'); - // The trigger should be visible when: - // - the mouse hovered over the region, - // - the trigger is locked, - // - and for as long as the contextual menu is open. - var isVisible = this.model.get('isLocked') || this.model.get('regionIsHovered') || isOpen; - - this.$el - // The open state determines if the links are visible. - .toggleClass('open', isOpen) - // Update the visibility of the trigger. - .find('.trigger').toggleClass('visually-hidden', !isVisible); - - // Nested contextual region handling: hide any nested contextual triggers. - if ('isOpen' in this.model.changed) { - this.$el.closest('.contextual-region') - .find('.contextual .trigger:not(:first)') - .toggle(!isOpen); - } - - return this; - } - }), - - /** - * Renders the aural view of a contextual link (i.e. screen reader support). - */ - AuralView: Backbone.View.extend({ - /** - * {@inheritdoc} - */ - initialize: function (options) { - this.options = options; - - this.listenTo(this.model, 'change', this.render); - - // Use aria-role form so that the number of items in the list is spoken. - this.$el.attr('role', 'form'); - - // Initial render. - this.render(); - }, - - /** - * {@inheritdoc} - */ - render: function () { - var isOpen = this.model.get('isOpen'); - - // Set the hidden property of the links. - this.$el.find('.contextual-links') - .prop('hidden', !isOpen); - - // Update the view of the trigger. - this.$el.find('.trigger') - .text(Drupal.t('@action @title configuration options', { - '@action': (!isOpen) ? this.options.strings.open : this.options.strings.close, - '@title': this.model.get('title') - })) - .attr('aria-pressed', isOpen); - } - }), - - /** - * Listens to keyboard. - */ - KeyboardView: Backbone.View.extend({ - events: { - 'focus .trigger': 'focus', - 'focus .contextual-links a': 'focus', - 'blur .trigger': function () { this.model.blur(); }, - 'blur .contextual-links a': function () { - // Set up a timeout to allow a user to tab between the trigger and the - // contextual links without the menu dismissing. - var that = this; - this.timer = window.setTimeout(function () { - that.model.close().blur(); - }, 150); - } - }, - - /** - * {@inheritdoc} - */ - initialize: function () { - // The timer is used to create a delay before dismissing the contextual - // links on blur. This is only necessary when keyboard users tab into - // contextual links without edit mode (i.e. without TabbingManager). - // That means that if we decide to disable tabbing of contextual links - // without edit mode, all this timer logic can go away. - this.timer = NaN; - }, - - /** - * Sets focus on the model; Clears the timer that dismisses the links. - */ - focus: function () { - // Clear the timeout that might have been set by blurring a link. - window.clearTimeout(this.timer); - this.model.focus(); - } - }), - - /** - * Renders the visual view of a contextual region element. - */ - RegionView: Backbone.View.extend({ - events: function () { - var mapping = { - mouseenter: function () { this.model.set('regionIsHovered', true); }, - mouseleave: function () { - this.model.close().blur().set('regionIsHovered', false); - } - }; - // We don't want mouse hover events on touch. - if (Modernizr.touch) { - mapping = {}; - } - return mapping; - }, - - /** - * {@inheritdoc} - */ - initialize: function () { - this.listenTo(this.model, 'change:hasFocus', this.render); - }, - - /** - * {@inheritdoc} - */ - render: function () { - this.$el.toggleClass('focus', this.model.get('hasFocus')); - - return this; - } - }) + regionViews: [] }; -// A Backbone.Collection of Drupal.contextual.Model instances. -Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.Model }); +// A Backbone.Collection of Drupal.contextual.StateModel instances. +Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.StateModel }); /** * A trigger is an interactive element often bound to a click handler. @@ -428,4 +183,4 @@ Drupal.theme.contextualTrigger = function () { return '<button class="trigger visually-hidden focusable" type="button"></button>'; }; -})(jQuery, Drupal, drupalSettings, Backbone, Modernizr); +})(jQuery, Drupal, drupalSettings, Backbone); diff --git a/core/modules/contextual/js/models/StateModel.js b/core/modules/contextual/js/models/StateModel.js new file mode 100644 index 0000000000000000000000000000000000000000..dc633f21e0c3df7cf8c66a508d640259482dfdb8 --- /dev/null +++ b/core/modules/contextual/js/models/StateModel.js @@ -0,0 +1,82 @@ +/** + * @file + * A Backbone Model for the state of a contextual link's trigger, list & region. + */ + +(function (Drupal, Backbone) { + +"use strict"; + +/** + * Models the state of a contextual link's trigger, list & region. + */ +Drupal.contextual.StateModel = Backbone.Model.extend({ + + defaults: { + // The title of the entity to which these contextual links apply. + title: '', + // Represents if the contextual region is being hovered. + regionIsHovered: false, + // Represents if the contextual trigger or options have focus. + hasFocus: false, + // Represents if the contextual options for an entity are available to + // be selected (i.e. whether the list of options is visible). + isOpen: false, + // When the model is locked, the trigger remains active. + isLocked: false + }, + + /** + * Opens or closes the contextual link. + * + * If it is opened, then also give focus. + */ + toggleOpen: function () { + var newIsOpen = !this.get('isOpen'); + this.set('isOpen', newIsOpen); + if (newIsOpen) { + this.focus(); + } + return this; + }, + + /** + * Closes this contextual link. + * + * Does not call blur() because we want to allow a contextual link to have + * focus, yet be closed for example when hovering. + */ + close: function () { + this.set('isOpen', false); + return this; + }, + + /** + * Gives focus to this contextual link. + * + * Also closes + removes focus from every other contextual link. + */ + focus: function () { + this.set('hasFocus', true); + var cid = this.cid; + this.collection.each(function (model) { + if (model.cid !== cid) { + model.close().blur(); + } + }); + return this; + }, + + /** + * Removes focus from this contextual link, unless it is open. + */ + blur: function () { + if (!this.get('isOpen')) { + this.set('hasFocus', false); + } + return this; + } + +}); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/AuralView.js b/core/modules/contextual/js/views/AuralView.js new file mode 100644 index 0000000000000000000000000000000000000000..9ce04c1af4eb8105659c051753973cd72cff3770 --- /dev/null +++ b/core/modules/contextual/js/views/AuralView.js @@ -0,0 +1,51 @@ +/** + * @file + * A Backbone View that provides the aural view of a contextual link. + */ + +(function (Drupal, Backbone) { + +"use strict"; + +/** + * Renders the aural view of a contextual link (i.e. screen reader support). + */ +Drupal.contextual.AuralView = Backbone.View.extend({ + + /** + * {@inheritdoc} + */ + initialize: function (options) { + this.options = options; + + this.listenTo(this.model, 'change', this.render); + + // Use aria-role form so that the number of items in the list is spoken. + this.$el.attr('role', 'form'); + + // Initial render. + this.render(); + }, + + /** + * {@inheritdoc} + */ + render: function () { + var isOpen = this.model.get('isOpen'); + + // Set the hidden property of the links. + this.$el.find('.contextual-links') + .prop('hidden', !isOpen); + + // Update the view of the trigger. + this.$el.find('.trigger') + .text(Drupal.t('@action @title configuration options', { + '@action': (!isOpen) ? this.options.strings.open : this.options.strings.close, + '@title': this.model.get('title') + })) + .attr('aria-pressed', isOpen); + } + +}); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/KeyboardView.js b/core/modules/contextual/js/views/KeyboardView.js new file mode 100644 index 0000000000000000000000000000000000000000..ec55523a13f2d1f31d1027ef7809c5c4106edf22 --- /dev/null +++ b/core/modules/contextual/js/views/KeyboardView.js @@ -0,0 +1,51 @@ +/** + * @file + * A Backbone View that provides keyboard interaction for a contextual link. + */ + +(function (Drupal, Backbone) { + +"use strict"; + +/** + * Provides keyboard interaction for a contextual link. + */ +Drupal.contextual.KeyboardView = Backbone.View.extend({ + events: { + 'focus .trigger': 'focus', + 'focus .contextual-links a': 'focus', + 'blur .trigger': function () { this.model.blur(); }, + 'blur .contextual-links a': function () { + // Set up a timeout to allow a user to tab between the trigger and the + // contextual links without the menu dismissing. + var that = this; + this.timer = window.setTimeout(function () { + that.model.close().blur(); + }, 150); + } + }, + + /** + * {@inheritdoc} + */ + initialize: function () { + // The timer is used to create a delay before dismissing the contextual + // links on blur. This is only necessary when keyboard users tab into + // contextual links without edit mode (i.e. without TabbingManager). + // That means that if we decide to disable tabbing of contextual links + // without edit mode, all this timer logic can go away. + this.timer = NaN; + }, + + /** + * Sets focus on the model; Clears the timer that dismisses the links. + */ + focus: function () { + // Clear the timeout that might have been set by blurring a link. + window.clearTimeout(this.timer); + this.model.focus(); + } + +}); + +})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/RegionView.js b/core/modules/contextual/js/views/RegionView.js new file mode 100644 index 0000000000000000000000000000000000000000..c29ce47e455c0a53d6f7f711367b8187a6696caf --- /dev/null +++ b/core/modules/contextual/js/views/RegionView.js @@ -0,0 +1,47 @@ +/** + * @file + * A Backbone View that renders the visual view of a contextual region element. + */ + +(function (Drupal, Backbone, Modernizr) { + +"use strict"; + +/** + * Renders the visual view of a contextual region element. + */ +Drupal.contextual.RegionView = Backbone.View.extend({ + + events: function () { + var mapping = { + mouseenter: function () { this.model.set('regionIsHovered', true); }, + mouseleave: function () { + this.model.close().blur().set('regionIsHovered', false); + } + }; + // We don't want mouse hover events on touch. + if (Modernizr.touch) { + mapping = {}; + } + return mapping; + }, + + /** + * {@inheritdoc} + */ + initialize: function () { + this.listenTo(this.model, 'change:hasFocus', this.render); + }, + + /** + * {@inheritdoc} + */ + render: function () { + this.$el.toggleClass('focus', this.model.get('hasFocus')); + + return this; + } + +}); + +})(Drupal, Backbone, Modernizr); diff --git a/core/modules/contextual/js/views/VisualView.js b/core/modules/contextual/js/views/VisualView.js new file mode 100644 index 0000000000000000000000000000000000000000..3c9119b0bc383744b3274f0dc00c8aef592cc9f7 --- /dev/null +++ b/core/modules/contextual/js/views/VisualView.js @@ -0,0 +1,70 @@ +/** + * @file + * A Backbone View that provides the visual view of a contextual link. + */ + +(function (Drupal, Backbone, Modernizr) { + +"use strict"; + +/** + * Renders the visual view of a contextual link. Listens to mouse & touch. + */ +Drupal.contextual.VisualView = Backbone.View.extend({ + + events: function () { + // Prevents delay and simulated mouse events. + var touchEndToClick = function (event) { + event.preventDefault(); + event.target.click(); + }; + var mapping = { + 'click .trigger': function () { this.model.toggleOpen(); }, + 'touchend .trigger': touchEndToClick, + 'click .contextual-links a': function () { this.model.close().blur(); }, + 'touchend .contextual-links a': touchEndToClick + }; + // We only want mouse hover events on non-touch. + if (!Modernizr.touch) { + mapping.mouseenter = function () { this.model.focus(); }; + } + return mapping; + }, + + /** + * {@inheritdoc} + */ + initialize: function () { + this.listenTo(this.model, 'change', this.render); + }, + + /** + * {@inheritdoc} + */ + render: function () { + var isOpen = this.model.get('isOpen'); + // The trigger should be visible when: + // - the mouse hovered over the region, + // - the trigger is locked, + // - and for as long as the contextual menu is open. + var isVisible = this.model.get('isLocked') || this.model.get('regionIsHovered') || isOpen; + + this.$el + // The open state determines if the links are visible. + .toggleClass('open', isOpen) + // Update the visibility of the trigger. + .find('.trigger').toggleClass('visually-hidden', !isVisible); + + // Nested contextual region handling: hide any nested contextual triggers. + if ('isOpen' in this.model.changed) { + this.$el.closest('.contextual-region') + .find('.contextual .trigger:not(:first)') + .toggle(!isOpen); + } + + return this; + } + +}); + +})(Drupal, Backbone, Modernizr);