/** * @file * Attaches behaviors for the Contextual module. */ (function ($, Drupal, drupalSettings, Backbone, Modernizr) { "use strict"; var options = $.extend(drupalSettings.contextual, // Merge strings on top of drupalSettings so that they are not mutable. { strings: { open: Drupal.t('Open'), close: Drupal.t('Close') } } ); /** * Initializes a contextual link: updates its DOM, sets up model and views * * @param jQuery $contextual * A contextual links placeholder DOM element, containing the actual * contextual links as rendered by the server. */ function initContextual ($contextual) { var $region = $contextual.closest('.contextual-region'); var contextual = Drupal.contextual; $contextual // Use the placeholder as a wrapper with a specific class to provide // positioning and behavior attachment context. .addClass('contextual') // Ensure a trigger element exists before the actual contextual links. .prepend(Drupal.theme('contextualTrigger')); // Create a model and the appropriate views. var model = new contextual.Model({ title: $region.find('h2:first').text().trim() }); var viewOptions = $.extend({ el: $contextual, model: model }, options); contextual.views.push({ visual: new contextual.VisualView(viewOptions), aural: new contextual.AuralView(viewOptions), keyboard: new contextual.KeyboardView(viewOptions) }); contextual.regionViews.push(new contextual.RegionView( $.extend({ el: $region, model: model }, options)) ); // Add the model to the collection. This must happen after the views have been // associated with it, otherwise collection change event handlers can't // trigger the model change event handler in its views. contextual.collection.add(model); // Let other JavaScript react to the adding of a new contextual link. $(document).trigger('drupalContextualLinkAdded', { $el: $contextual, $region: $region, model: model }); // Fix visual collisions between contextual link triggers. adjustIfNestedAndOverlapping($contextual); } /** * Determines if a contextual link is nested & overlapping, if so: adjusts it. * * This only deals with two levels of nesting; deeper levels are not touched. * * @param jQuery $contextual * A contextual links placeholder DOM element, containing the actual * contextual links as rendered by the server. */ function adjustIfNestedAndOverlapping ($contextual) { var $contextuals = $contextual // @todo confirm that .closest() is not sufficient .parents('.contextual-region').eq(-1) .find('.contextual'); // Early-return when there's no nesting. if ($contextuals.length === 1) { return; } // If the two contextual links overlap, then we move the second one. var firstTop = $contextuals.eq(0).offset().top; var secondTop = $contextuals.eq(1).offset().top; if (firstTop === secondTop) { var $nestedContextual = $contextuals.eq(1); // Retrieve height of nested contextual link. var height = 0; var $trigger = $nestedContextual.find('.trigger'); // Elements with the .visually-hidden class have no dimensions, so this // class must be temporarily removed to the calculate the height. $trigger.removeClass('visually-hidden'); height = $nestedContextual.height(); $trigger.addClass('visually-hidden'); // Adjust nested contextual link's position. $nestedContextual.css({ top: $nestedContextual.position().top + height }); } } /** * Attaches outline behavior for regions associated with contextual links. * * Events * Contextual triggers an event that can be used by other scripts. * - drupalContextualLinkAdded: Triggered when a contextual link is added. */ Drupal.behaviors.contextual = { attach: function (context) { var $context = $(context); // Find all contextual links placeholders, if any. var $placeholders = $context.find('[data-contextual-id]').once('contextual-render'); if ($placeholders.length === 0) { return; } // Collect the IDs for all contextual links placeholders. var ids = []; $placeholders.each(function () { ids.push($(this).attr('data-contextual-id')); }); // Perform an AJAX request to let the server render the contextual links for // each of the placeholders. $.ajax({ url: Drupal.url('contextual/render') + '?destination=' + Drupal.encodePath(drupalSettings.currentPath), type: 'POST', data: { 'ids[]' : ids }, dataType: 'json', success: function (results) { for (var id in results) { // If the rendered contextual links are empty, then the current user // does not have permission to access the associated links: don't // render anything. if (results.hasOwnProperty(id) && results[id].length > 0) { // Update the placeholders to contain its rendered contextual links. // Usually there will only be one placeholder, but it's possible for // multiple identical placeholders exist on the page (probably // because the same content appears more than once). var $placeholders = $context .find('[data-contextual-id="' + id + '"]') .html(results[id]); // Initialize the contextual links. for (var i = 0; i < $placeholders.length; i++) { initContextual($placeholders.eq(i)); } } } } }); } }; /** * Model and View definitions. */ Drupal.contextual = { // The Drupal.contextual.View instances associated with each list element of // contextual links. views: [], // 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.model.on('change', this.render, this); }, /** * {@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.model.on('change', this.render, this); // 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.model.on('change:hasFocus', this.render, this); }, /** * {@inheritdoc} */ render: function () { this.$el.toggleClass('focus', this.model.get('hasFocus')); return this; } }) }; // A Backbone.Collection of Drupal.contextual.Model instances. Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.Model }); /** * A trigger is an interactive element often bound to a click handler. * * @return String * A string representing a DOM fragment. */ Drupal.theme.contextualTrigger = function () { return ''; }; })(jQuery, Drupal, drupalSettings, Backbone, Modernizr);