drupal/core/modules/contextual/js/contextual.js

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);