430 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
			
		
		
	
	
			430 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
/**
 | 
						|
 * @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 '<button class="trigger visually-hidden focusable" type="button"></button>';
 | 
						|
};
 | 
						|
 | 
						|
})(jQuery, Drupal, drupalSettings, Backbone, Modernizr);
 |