Update Composer, update everything

This commit is contained in:
Oliver Davies 2018-11-23 12:29:20 +00:00
parent ea3e94409f
commit dda5c284b6
19527 changed files with 1135420 additions and 351004 deletions

View file

@ -20,6 +20,7 @@ drupal.contextual-links:
dependencies:
- core/jquery
- core/drupal
- core/drupal.ajax
- core/drupalSettings
- core/backbone
- core/modernizr

View file

@ -36,6 +36,7 @@ function contextual_toolbar() {
'#attributes' => [
'class' => ['toolbar-icon', 'toolbar-icon-edit'],
'aria-pressed' => 'false',
'type' => 'button',
],
],
'#wrapper_attributes' => [
@ -85,7 +86,7 @@ function contextual_help($route_name, RouteMatchInterface $route_match) {
$sample_picture = [
'#theme' => 'image',
'#uri' => 'core/misc/icons/bebebe/pencil.svg',
'#alt' => t('contextual links button')
'#alt' => t('contextual links button'),
];
$sample_picture = \Drupal::service('renderer')->render($sample_picture);
$output .= '<li>' . t('Hovering over the area of interest will temporarily make the contextual links button visible (which looks like a pencil in most themes, and is normally displayed in the upper right corner of the area). The icon typically looks like this: @picture', ['@picture' => $sample_picture]) . '</li>';
@ -190,13 +191,19 @@ function _contextual_links_to_id($contextual_links) {
/**
* Unserializes the result of _contextual_links_to_id().
*
* @see _contextual_links_to_id
* Note that $id is user input. Before calling this method the ID should be
* checked against the token stored in the 'data-contextual-token' attribute
* which is passed via the 'tokens' request parameter to
* \Drupal\contextual\ContextualController::render().
*
* @param string $id
* A serialized representation of a #contextual_links property value array.
*
* @return array
* The value for a #contextual_links property.
*
* @see _contextual_links_to_id()
* @see \Drupal\contextual\ContextualController::render()
*/
function _contextual_id_to_links($id) {
$contextual_links = [];

View file

@ -0,0 +1,14 @@
<?php
/**
* @file
* Post update functions for Contextual Links.
*/
/**
* Ensure new page loads use the updated JS and get the updated markup.
*/
function contextual_post_update_fixed_endpoint_and_markup() {
// Empty update to trigger a change to css_js_query_string and invalidate
// cached markup.
}

View file

@ -14,8 +14,8 @@
margin: 0;
}
.toolbar .toolbar-bar .contextual-toolbar-tab .toolbar-item.is-active {
background-image:-webkit-linear-gradient(rgb(78,159,234) 0%, rgb(69,132,221) 100%);
background-image:linear-gradient(rgb(78,159,234) 0%,rgb(69,132,221) 100%);
background-image: -webkit-linear-gradient(rgb(78, 159, 234) 0%, rgb(69, 132, 221) 100%);
background-image: linear-gradient(rgb(78, 159, 234) 0%, rgb(69, 132, 221) 100%);
}
/* @todo get rid of this declaration by making toolbar.module's CSS less specific */

View file

@ -0,0 +1,299 @@
/**
* @file
* Attaches behaviors for the Contextual module.
*/
(function($, Drupal, drupalSettings, _, Backbone, JSON, storage) {
const 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'),
},
},
);
// Clear the cached contextual links whenever the current user's set of
// permissions changes.
const cachedPermissionsHash = storage.getItem(
'Drupal.contextual.permissionsHash',
);
const permissionsHash = drupalSettings.user.permissionsHash;
if (cachedPermissionsHash !== permissionsHash) {
if (typeof permissionsHash === 'string') {
_.chain(storage)
.keys()
.each(key => {
if (key.substring(0, 18) === 'Drupal.contextual.') {
storage.removeItem(key);
}
});
}
storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
}
/**
* 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) {
const $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.
const firstTop = $contextuals.eq(0).offset().top;
const secondTop = $contextuals.eq(1).offset().top;
if (firstTop === secondTop) {
const $nestedContextual = $contextuals.eq(1);
// Retrieve height of nested contextual link.
let height = 0;
const $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 });
}
}
/**
* 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.
* @param {string} html
* The server-side rendered HTML for this contextual link.
*/
function initContextual($contextual, html) {
const $region = $contextual.closest('.contextual-region');
const contextual = Drupal.contextual;
$contextual
// Update the placeholder to contain its rendered contextual links.
.html(html)
// 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'));
// Set the destination parameter on each of the contextual links.
const destination = `destination=${Drupal.encodePath(
Drupal.url(drupalSettings.path.currentPath),
)}`;
$contextual.find('.contextual-links a').each(function() {
const url = this.getAttribute('href');
const glue = url.indexOf('?') === -1 ? '?' : '&';
this.setAttribute('href', url + glue + destination);
});
// Create a model and the appropriate views.
const model = new contextual.StateModel({
title: $region
.find('h2')
.eq(0)
.text()
.trim(),
});
const viewOptions = $.extend({ el: $contextual, 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 }, 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,
model,
});
// Fix visual collisions between contextual link triggers.
adjustIfNestedAndOverlapping($contextual);
}
/**
* 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.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the outline behavior to the right context.
*/
Drupal.behaviors.contextual = {
attach(context) {
const $context = $(context);
// Find all contextual links placeholders, if any.
let $placeholders = $context
.find('[data-contextual-id]')
.once('contextual-render');
if ($placeholders.length === 0) {
return;
}
// Collect the IDs for all contextual links placeholders.
const ids = [];
$placeholders.each(function() {
ids.push({
id: $(this).attr('data-contextual-id'),
token: $(this).attr('data-contextual-token'),
});
});
const uncachedIDs = [];
const uncachedTokens = [];
ids.forEach(contextualID => {
const html = storage.getItem(`Drupal.contextual.${contextualID.id}`);
if (html && html.length) {
// Initialize after the current execution cycle, to make the AJAX
// request for retrieving the uncached contextual links as soon as
// possible, but also to ensure that other Drupal behaviors have had
// the chance to set up an event listener on the Backbone collection
// Drupal.contextual.collection.
window.setTimeout(() => {
initContextual(
$context.find(`[data-contextual-id="${contextualID.id}"]`),
html,
);
});
return;
}
uncachedIDs.push(contextualID.id);
uncachedTokens.push(contextualID.token);
});
// Perform an AJAX request to let the server render the contextual links
// for each of the placeholders.
if (uncachedIDs.length > 0) {
$.ajax({
url: Drupal.url('contextual/render'),
type: 'POST',
data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
dataType: 'json',
success(results) {
_.each(results, (html, contextualID) => {
// Store the metadata.
storage.setItem(`Drupal.contextual.${contextualID}`, html);
// 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 (html.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).
$placeholders = $context.find(
`[data-contextual-id="${contextualID}"]`,
);
// Initialize the contextual links.
for (let i = 0; i < $placeholders.length; i++) {
initContextual($placeholders.eq(i), html);
}
}
});
},
});
}
},
};
/**
* Namespace for contextual related functionality.
*
* @namespace
*/
Drupal.contextual = {
/**
* The {@link Drupal.contextual.View} instances associated with each list
* element of contextual links.
*
* @type {Array}
*/
views: [],
/**
* The {@link Drupal.contextual.RegionView} instances associated with each
* contextual region element.
*
* @type {Array}
*/
regionViews: [],
};
/**
* A Backbone.Collection of {@link Drupal.contextual.StateModel} instances.
*
* @type {Backbone.Collection}
*/
Drupal.contextual.collection = new Backbone.Collection([], {
model: Drupal.contextual.StateModel,
});
/**
* 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>';
};
/**
* Bind Ajax contextual links when added.
*
* @param {jQuery.Event} event
* The `drupalContextualLinkAdded` event.
* @param {object} data
* An object containing the data relevant to the event.
*
* @listens event:drupalContextualLinkAdded
*/
$(document).on('drupalContextualLinkAdded', (event, data) => {
Drupal.ajax.bindAjaxLinks(data.$el[0]);
});
})(
jQuery,
Drupal,
drupalSettings,
_,
Backbone,
window.JSON,
window.sessionStorage,
);

View file

@ -1,24 +1,18 @@
/**
* @file
* Attaches behaviors for the Contextual module.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) {
'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')
}
var options = $.extend(drupalSettings.contextual, {
strings: {
open: Drupal.t('Open'),
close: Drupal.t('Close')
}
);
});
// Clear the cached contextual links whenever the current user's set of
// permissions changes.
var cachedPermissionsHash = storage.getItem('Drupal.contextual.permissionsHash');
var permissionsHash = drupalSettings.user.permissionsHash;
if (cachedPermissionsHash !== permissionsHash) {
@ -32,175 +26,108 @@
storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
}
/**
* 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.
* @param {string} html
* The server-side rendered HTML for this contextual link.
*/
function adjustIfNestedAndOverlapping($contextual) {
var $contextuals = $contextual.parents('.contextual-region').eq(-1).find('.contextual');
if ($contextuals.length <= 1) {
return;
}
var firstTop = $contextuals.eq(0).offset().top;
var secondTop = $contextuals.eq(1).offset().top;
if (firstTop === secondTop) {
var $nestedContextual = $contextuals.eq(1);
var height = 0;
var $trigger = $nestedContextual.find('.trigger');
$trigger.removeClass('visually-hidden');
height = $nestedContextual.height();
$trigger.addClass('visually-hidden');
$nestedContextual.css({ top: $nestedContextual.position().top + height });
}
}
function initContextual($contextual, html) {
var $region = $contextual.closest('.contextual-region');
var contextual = Drupal.contextual;
$contextual
// Update the placeholder to contain its rendered contextual links.
.html(html)
// 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'));
$contextual.html(html).addClass('contextual').prepend(Drupal.theme('contextualTrigger'));
// Set the destination parameter on each of the contextual links.
var destination = 'destination=' + Drupal.encodePath(drupalSettings.path.currentPath);
var destination = 'destination=' + Drupal.encodePath(Drupal.url(drupalSettings.path.currentPath));
$contextual.find('.contextual-links a').each(function () {
var url = this.getAttribute('href');
var glue = (url.indexOf('?') === -1) ? '?' : '&';
var glue = url.indexOf('?') === -1 ? '?' : '&';
this.setAttribute('href', url + glue + destination);
});
// Create a model and the appropriate views.
var model = new contextual.StateModel({
title: $region.find('h2').eq(0).text().trim()
});
var viewOptions = $.extend({el: $contextual, model: model}, options);
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))
);
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.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the outline behavior to the right context.
*/
Drupal.behaviors.contextual = {
attach: function (context) {
attach: function attach(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'));
ids.push({
id: $(this).attr('data-contextual-id'),
token: $(this).attr('data-contextual-token')
});
});
// Update all contextual links placeholders whose HTML is cached.
var uncachedIDs = _.filter(ids, function initIfCached(contextualID) {
var html = storage.getItem('Drupal.contextual.' + contextualID);
var uncachedIDs = [];
var uncachedTokens = [];
ids.forEach(function (contextualID) {
var html = storage.getItem('Drupal.contextual.' + contextualID.id);
if (html && html.length) {
// Initialize after the current execution cycle, to make the AJAX
// request for retrieving the uncached contextual links as soon as
// possible, but also to ensure that other Drupal behaviors have had
// the chance to set up an event listener on the Backbone collection
// Drupal.contextual.collection.
window.setTimeout(function () {
initContextual($context.find('[data-contextual-id="' + contextualID + '"]'), html);
initContextual($context.find('[data-contextual-id="' + contextualID.id + '"]'), html);
});
return false;
return;
}
return true;
uncachedIDs.push(contextualID.id);
uncachedTokens.push(contextualID.token);
});
// Perform an AJAX request to let the server render the contextual links
// for each of the placeholders.
if (uncachedIDs.length > 0) {
$.ajax({
url: Drupal.url('contextual/render'),
type: 'POST',
data: {'ids[]': uncachedIDs},
data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
dataType: 'json',
success: function (results) {
success: function success(results) {
_.each(results, function (html, contextualID) {
// Store the metadata.
storage.setItem('Drupal.contextual.' + contextualID, html);
// 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 (html.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).
$placeholders = $context.find('[data-contextual-id="' + contextualID + '"]');
// Initialize the contextual links.
for (var i = 0; i < $placeholders.length; i++) {
initContextual($placeholders.eq(i), html);
}
@ -212,45 +139,21 @@
}
};
/**
* Namespace for contextual related functionality.
*
* @namespace
*/
Drupal.contextual = {
/**
* The {@link Drupal.contextual.View} instances associated with each list
* element of contextual links.
*
* @type {Array}
*/
views: [],
/**
* The {@link Drupal.contextual.RegionView} instances associated with each
* contextual region element.
*
* @type {Array}
*/
regionViews: []
};
/**
* A Backbone.Collection of {@link Drupal.contextual.StateModel} instances.
*
* @type {Backbone.Collection}
*/
Drupal.contextual.collection = new Backbone.Collection([], {model: Drupal.contextual.StateModel});
Drupal.contextual.collection = new Backbone.Collection([], {
model: Drupal.contextual.StateModel
});
/**
* 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, window.JSON, window.sessionStorage);
$(document).on('drupalContextualLinkAdded', function (event, data) {
Drupal.ajax.bindAjaxLinks(data.$el[0]);
});
})(jQuery, Drupal, drupalSettings, _, Backbone, window.JSON, window.sessionStorage);

View file

@ -0,0 +1,81 @@
/**
* @file
* Attaches behaviors for the Contextual module's edit toolbar tab.
*/
(function($, Drupal, Backbone) {
const strings = {
tabbingReleased: Drupal.t(
'Tabbing is no longer constrained by the Contextual module.',
),
tabbingConstrained: Drupal.t(
'Tabbing is constrained to a set of @contextualsCount and the edit mode toggle.',
),
pressEsc: Drupal.t('Press the esc key to exit.'),
};
/**
* Initializes a contextual link: updates its DOM, sets up model and views.
*
* @param {HTMLElement} context
* A contextual links DOM element as rendered by the server.
*/
function initContextualToolbar(context) {
if (!Drupal.contextual || !Drupal.contextual.collection) {
return;
}
const contextualToolbar = Drupal.contextualToolbar;
contextualToolbar.model = new contextualToolbar.StateModel(
{
// Checks whether localStorage indicates we should start in edit mode
// rather than view mode.
// @see Drupal.contextualToolbar.VisualView.persist
isViewing:
localStorage.getItem('Drupal.contextualToolbar.isViewing') !==
'false',
},
{
contextualCollection: Drupal.contextual.collection,
},
);
const viewOptions = {
el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'),
model: contextualToolbar.model,
strings,
};
new contextualToolbar.VisualView(viewOptions);
new contextualToolbar.AuralView(viewOptions);
}
/**
* Attaches contextual's edit toolbar tab behavior.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches contextual toolbar behavior on a contextualToolbar-init event.
*/
Drupal.behaviors.contextualToolbar = {
attach(context) {
if ($('body').once('contextualToolbar-init').length) {
initContextualToolbar(context);
}
},
};
/**
* Namespace for the contextual toolbar.
*
* @namespace
*/
Drupal.contextualToolbar = {
/**
* The {@link Drupal.contextualToolbar.StateModel} instance.
*
* @type {?Drupal.contextualToolbar.StateModel}
*/
model: null,
};
})(jQuery, Drupal, Backbone);

View file

@ -1,34 +1,24 @@
/**
* @file
* Attaches behaviors for the Contextual module's edit toolbar tab.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, Backbone) {
'use strict';
var strings = {
tabbingReleased: Drupal.t('Tabbing is no longer constrained by the Contextual module.'),
tabbingConstrained: Drupal.t('Tabbing is constrained to a set of @contextualsCount and the edit mode toggle.'),
pressEsc: Drupal.t('Press the esc key to exit.')
};
/**
* Initializes a contextual link: updates its DOM, sets up model and views.
*
* @param {HTMLElement} context
* A contextual links DOM element as rendered by the server.
*/
function initContextualToolbar(context) {
if (!Drupal.contextual || !Drupal.contextual.collection) {
return;
}
var contextualToolbar = Drupal.contextualToolbar;
var model = contextualToolbar.model = new contextualToolbar.StateModel({
// Checks whether localStorage indicates we should start in edit mode
// rather than view mode.
// @see Drupal.contextualToolbar.VisualView.persist
contextualToolbar.model = new contextualToolbar.StateModel({
isViewing: localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false'
}, {
contextualCollection: Drupal.contextual.collection
@ -36,42 +26,22 @@
var viewOptions = {
el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'),
model: model,
model: contextualToolbar.model,
strings: strings
};
new contextualToolbar.VisualView(viewOptions);
new contextualToolbar.AuralView(viewOptions);
}
/**
* Attaches contextual's edit toolbar tab behavior.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches contextual toolbar behavior on a contextualToolbar-init event.
*/
Drupal.behaviors.contextualToolbar = {
attach: function (context) {
attach: function attach(context) {
if ($('body').once('contextualToolbar-init').length) {
initContextualToolbar(context);
}
}
};
/**
* Namespace for the contextual toolbar.
*
* @namespace
*/
Drupal.contextualToolbar = {
/**
* The {@link Drupal.contextualToolbar.StateModel} instance.
*
* @type {?Drupal.contextualToolbar.StateModel}
*/
model: null
};
})(jQuery, Drupal, Backbone);
})(jQuery, Drupal, Backbone);

View file

@ -0,0 +1,127 @@
/**
* @file
* A Backbone Model for the state of a contextual link's trigger, list & region.
*/
(function(Drupal, Backbone) {
/**
* Models the state of a contextual link's trigger, list & region.
*
* @constructor
*
* @augments Backbone.Model
*/
Drupal.contextual.StateModel = Backbone.Model.extend(
/** @lends Drupal.contextual.StateModel# */ {
/**
* @type {object}
*
* @prop {string} title
* @prop {bool} regionIsHovered
* @prop {bool} hasFocus
* @prop {bool} isOpen
* @prop {bool} isLocked
*/
defaults: /** @lends Drupal.contextual.StateModel# */ {
/**
* The title of the entity to which these contextual links apply.
*
* @type {string}
*/
title: '',
/**
* Represents if the contextual region is being hovered.
*
* @type {bool}
*/
regionIsHovered: false,
/**
* Represents if the contextual trigger or options have focus.
*
* @type {bool}
*/
hasFocus: false,
/**
* Represents if the contextual options for an entity are available to
* be selected (i.e. whether the list of options is visible).
*
* @type {bool}
*/
isOpen: false,
/**
* When the model is locked, the trigger remains active.
*
* @type {bool}
*/
isLocked: false,
},
/**
* Opens or closes the contextual link.
*
* If it is opened, then also give focus.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
toggleOpen() {
const 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.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
close() {
this.set('isOpen', false);
return this;
},
/**
* Gives focus to this contextual link.
*
* Also closes + removes focus from every other contextual link.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
focus() {
this.set('hasFocus', true);
const cid = this.cid;
this.collection.each(model => {
if (model.cid !== cid) {
model.close().blur();
}
});
return this;
},
/**
* Removes focus from this contextual link, unless it is open.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
blur() {
if (!this.get('isOpen')) {
this.set('hasFocus', false);
}
return this;
},
},
);
})(Drupal, Backbone);

View file

@ -1,78 +1,25 @@
/**
* @file
* A Backbone Model for the state of a contextual link's trigger, list & region.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
'use strict';
/**
* Models the state of a contextual link's trigger, list & region.
*
* @constructor
*
* @augments Backbone.Model
*/
Drupal.contextual.StateModel = Backbone.Model.extend(/** @lends Drupal.contextual.StateModel# */{
/**
* @type {object}
*
* @prop {string} title
* @prop {bool} regionIsHovered
* @prop {bool} hasFocus
* @prop {bool} isOpen
* @prop {bool} isLocked
*/
defaults: /** @lends Drupal.contextual.StateModel# */{
/**
* The title of the entity to which these contextual links apply.
*
* @type {string}
*/
Drupal.contextual.StateModel = Backbone.Model.extend({
defaults: {
title: '',
/**
* Represents if the contextual region is being hovered.
*
* @type {bool}
*/
regionIsHovered: false,
/**
* Represents if the contextual trigger or options have focus.
*
* @type {bool}
*/
hasFocus: false,
/**
* Represents if the contextual options for an entity are available to
* be selected (i.e. whether the list of options is visible).
*
* @type {bool}
*/
isOpen: false,
/**
* When the model is locked, the trigger remains active.
*
* @type {bool}
*/
isLocked: false
},
/**
* Opens or closes the contextual link.
*
* If it is opened, then also give focus.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
toggleOpen: function () {
toggleOpen: function toggleOpen() {
var newIsOpen = !this.get('isOpen');
this.set('isOpen', newIsOpen);
if (newIsOpen) {
@ -80,30 +27,11 @@
}
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.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
close: function () {
close: function close() {
this.set('isOpen', false);
return this;
},
/**
* Gives focus to this contextual link.
*
* Also closes + removes focus from every other contextual link.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
focus: function () {
focus: function focus() {
this.set('hasFocus', true);
var cid = this.cid;
this.collection.each(function (model) {
@ -113,20 +41,11 @@
});
return this;
},
/**
* Removes focus from this contextual link, unless it is open.
*
* @return {Drupal.contextual.StateModel}
* The current contextual state model.
*/
blur: function () {
blur: function blur() {
if (!this.get('isOpen')) {
this.set('hasFocus', false);
}
return this;
}
});
})(Drupal, Backbone);
})(Drupal, Backbone);

View file

@ -0,0 +1,122 @@
/**
* @file
* A Backbone Model for the state of Contextual module's edit toolbar tab.
*/
(function(Drupal, Backbone) {
Drupal.contextualToolbar.StateModel = Backbone.Model.extend(
/** @lends Drupal.contextualToolbar.StateModel# */ {
/**
* @type {object}
*
* @prop {bool} isViewing
* @prop {bool} isVisible
* @prop {number} contextualCount
* @prop {Drupal~TabbingContext} tabbingContext
*/
defaults: /** @lends Drupal.contextualToolbar.StateModel# */ {
/**
* Indicates whether the toggle is currently in "view" or "edit" mode.
*
* @type {bool}
*/
isViewing: true,
/**
* Indicates whether the toggle should be visible or hidden. Automatically
* calculated, depends on contextualCount.
*
* @type {bool}
*/
isVisible: false,
/**
* Tracks how many contextual links exist on the page.
*
* @type {number}
*/
contextualCount: 0,
/**
* A TabbingContext object as returned by {@link Drupal~TabbingManager}:
* the set of tabbable elements when edit mode is enabled.
*
* @type {?Drupal~TabbingContext}
*/
tabbingContext: null,
},
/**
* Models the state of the edit mode toggle.
*
* @constructs
*
* @augments Backbone.Model
*
* @param {object} attrs
* Attributes for the backbone model.
* @param {object} options
* An object with the following option:
* @param {Backbone.collection} options.contextualCollection
* The collection of {@link Drupal.contextual.StateModel} models that
* represent the contextual links on the page.
*/
initialize(attrs, options) {
// Respond to new/removed contextual links.
this.listenTo(
options.contextualCollection,
'reset remove add',
this.countContextualLinks,
);
this.listenTo(
options.contextualCollection,
'add',
this.lockNewContextualLinks,
);
// Automatically determine visibility.
this.listenTo(this, 'change:contextualCount', this.updateVisibility);
// Whenever edit mode is toggled, lock all contextual links.
this.listenTo(this, 'change:isViewing', (model, isViewing) => {
options.contextualCollection.each(contextualModel => {
contextualModel.set('isLocked', !isViewing);
});
});
},
/**
* Tracks the number of contextual link models in the collection.
*
* @param {Drupal.contextual.StateModel} contextualModel
* The contextual links model that was added or removed.
* @param {Backbone.Collection} contextualCollection
* The collection of contextual link models.
*/
countContextualLinks(contextualModel, contextualCollection) {
this.set('contextualCount', contextualCollection.length);
},
/**
* Lock newly added contextual links if edit mode is enabled.
*
* @param {Drupal.contextual.StateModel} contextualModel
* The contextual links model that was added.
* @param {Backbone.Collection} [contextualCollection]
* The collection of contextual link models.
*/
lockNewContextualLinks(contextualModel, contextualCollection) {
if (!this.get('isViewing')) {
contextualModel.set('isLocked', true);
}
},
/**
* Automatically updates visibility of the view/edit mode toggle.
*/
updateVisibility() {
this.set('isVisible', this.get('contextualCount') > 0);
},
},
);
})(Drupal, Backbone);

View file

@ -1,119 +1,44 @@
/**
* @file
* A Backbone Model for the state of Contextual module's edit toolbar tab.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
'use strict';
Drupal.contextualToolbar.StateModel = Backbone.Model.extend(/** @lends Drupal.contextualToolbar.StateModel# */{
/**
* @type {object}
*
* @prop {bool} isViewing
* @prop {bool} isVisible
* @prop {number} contextualCount
* @prop {Drupal~TabbingContext} tabbingContext
*/
defaults: /** @lends Drupal.contextualToolbar.StateModel# */{
/**
* Indicates whether the toggle is currently in "view" or "edit" mode.
*
* @type {bool}
*/
Drupal.contextualToolbar.StateModel = Backbone.Model.extend({
defaults: {
isViewing: true,
/**
* Indicates whether the toggle should be visible or hidden. Automatically
* calculated, depends on contextualCount.
*
* @type {bool}
*/
isVisible: false,
/**
* Tracks how many contextual links exist on the page.
*
* @type {number}
*/
contextualCount: 0,
/**
* A TabbingContext object as returned by {@link Drupal~TabbingManager}:
* the set of tabbable elements when edit mode is enabled.
*
* @type {?Drupal~TabbingContext}
*/
tabbingContext: null
},
/**
* Models the state of the edit mode toggle.
*
* @constructs
*
* @augments Backbone.Model
*
* @param {object} attrs
* Attributes for the backbone model.
* @param {object} options
* An object with the following option:
* @param {Backbone.collection} options.contextualCollection
* The collection of {@link Drupal.contextual.StateModel} models that
* represent the contextual links on the page.
*/
initialize: function (attrs, options) {
// Respond to new/removed contextual links.
initialize: function initialize(attrs, options) {
this.listenTo(options.contextualCollection, 'reset remove add', this.countContextualLinks);
this.listenTo(options.contextualCollection, 'add', this.lockNewContextualLinks);
// Automatically determine visibility.
this.listenTo(this, 'change:contextualCount', this.updateVisibility);
// Whenever edit mode is toggled, lock all contextual links.
this.listenTo(this, 'change:isViewing', function (model, isViewing) {
options.contextualCollection.each(function (contextualModel) {
contextualModel.set('isLocked', !isViewing);
});
});
},
/**
* Tracks the number of contextual link models in the collection.
*
* @param {Drupal.contextual.StateModel} contextualModel
* The contextual links model that was added or removed.
* @param {Backbone.Collection} contextualCollection
* The collection of contextual link models.
*/
countContextualLinks: function (contextualModel, contextualCollection) {
countContextualLinks: function countContextualLinks(contextualModel, contextualCollection) {
this.set('contextualCount', contextualCollection.length);
},
/**
* Lock newly added contextual links if edit mode is enabled.
*
* @param {Drupal.contextual.StateModel} contextualModel
* The contextual links model that was added.
* @param {Backbone.Collection} [contextualCollection]
* The collection of contextual link models.
*/
lockNewContextualLinks: function (contextualModel, contextualCollection) {
lockNewContextualLinks: function lockNewContextualLinks(contextualModel, contextualCollection) {
if (!this.get('isViewing')) {
contextualModel.set('isLocked', true);
}
},
/**
* Automatically updates visibility of the view/edit mode toggle.
*/
updateVisibility: function () {
updateVisibility: function updateVisibility() {
this.set('isVisible', this.get('contextualCount') > 0);
}
});
})(Drupal, Backbone);
})(Drupal, Backbone);

View file

@ -0,0 +1,118 @@
/**
* @file
* A Backbone View that provides the aural view of the edit mode toggle.
*/
(function($, Drupal, Backbone, _) {
Drupal.contextualToolbar.AuralView = Backbone.View.extend(
/** @lends Drupal.contextualToolbar.AuralView# */ {
/**
* Tracks whether the tabbing constraint announcement has been read once.
*
* @type {bool}
*/
announcedOnce: false,
/**
* Renders the aural view of the edit mode toggle (screen reader support).
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options for the view.
*/
initialize(options) {
this.options = options;
this.listenTo(this.model, 'change', this.render);
this.listenTo(this.model, 'change:isViewing', this.manageTabbing);
$(document).on('keyup', _.bind(this.onKeypress, this));
this.manageTabbing();
},
/**
* @inheritdoc
*
* @return {Drupal.contextualToolbar.AuralView}
* The current contextual toolbar aural view.
*/
render() {
// Render the state.
this.$el
.find('button')
.attr('aria-pressed', !this.model.get('isViewing'));
return this;
},
/**
* Limits tabbing to the contextual links and edit mode toolbar tab.
*/
manageTabbing() {
let tabbingContext = this.model.get('tabbingContext');
// Always release an existing tabbing context.
if (tabbingContext) {
// Only announce release when the context was active.
if (tabbingContext.active) {
Drupal.announce(this.options.strings.tabbingReleased);
}
tabbingContext.release();
}
// Create a new tabbing context when edit mode is enabled.
if (!this.model.get('isViewing')) {
tabbingContext = Drupal.tabbingManager.constrain(
$('.contextual-toolbar-tab, .contextual'),
);
this.model.set('tabbingContext', tabbingContext);
this.announceTabbingConstraint();
this.announcedOnce = true;
}
},
/**
* Announces the current tabbing constraint.
*/
announceTabbingConstraint() {
const strings = this.options.strings;
Drupal.announce(
Drupal.formatString(strings.tabbingConstrained, {
'@contextualsCount': Drupal.formatPlural(
Drupal.contextual.collection.length,
'@count contextual link',
'@count contextual links',
),
}),
);
Drupal.announce(strings.pressEsc);
},
/**
* Responds to esc and tab key press events.
*
* @param {jQuery.Event} event
* The keypress event.
*/
onKeypress(event) {
// The first tab key press is tracked so that an announcement about
// tabbing constraints can be raised if edit mode is enabled when the page
// is loaded.
if (
!this.announcedOnce &&
event.keyCode === 9 &&
!this.model.get('isViewing')
) {
this.announceTabbingConstraint();
// Set announce to true so that this conditional block won't run again.
this.announcedOnce = true;
}
// Respond to the ESC key. Exit out of edit mode.
if (event.keyCode === 27) {
this.model.set('isViewing', true);
}
},
},
);
})(jQuery, Drupal, Backbone, _);

View file

@ -1,64 +1,38 @@
/**
* @file
* A Backbone View that provides the aural view of the edit mode toggle.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, Backbone, _) {
'use strict';
Drupal.contextualToolbar.AuralView = Backbone.View.extend(/** @lends Drupal.contextualToolbar.AuralView# */{
/**
* Tracks whether the tabbing constraint announcement has been read once.
*
* @type {bool}
*/
Drupal.contextualToolbar.AuralView = Backbone.View.extend({
announcedOnce: false,
/**
* Renders the aural view of the edit mode toggle (screen reader support).
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options for the view.
*/
initialize: function (options) {
initialize: function initialize(options) {
this.options = options;
this.listenTo(this.model, 'change', this.render);
this.listenTo(this.model, 'change:isViewing', this.manageTabbing);
$(document).on('keyup', _.bind(this.onKeypress, this));
this.manageTabbing();
},
/**
* @inheritdoc
*
* @return {Drupal.contextualToolbar.AuralView}
* The current contextual toolbar aural view.
*/
render: function () {
// Render the state.
render: function render() {
this.$el.find('button').attr('aria-pressed', !this.model.get('isViewing'));
return this;
},
/**
* Limits tabbing to the contextual links and edit mode toolbar tab.
*/
manageTabbing: function () {
manageTabbing: function manageTabbing() {
var tabbingContext = this.model.get('tabbingContext');
// Always release an existing tabbing context.
if (tabbingContext) {
if (tabbingContext.active) {
Drupal.announce(this.options.strings.tabbingReleased);
}
tabbingContext.release();
Drupal.announce(this.options.strings.tabbingReleased);
}
// Create a new tabbing context when edit mode is enabled.
if (!this.model.get('isViewing')) {
tabbingContext = Drupal.tabbingManager.constrain($('.contextual-toolbar-tab, .contextual'));
this.model.set('tabbingContext', tabbingContext);
@ -66,39 +40,23 @@
this.announcedOnce = true;
}
},
/**
* Announces the current tabbing constraint.
*/
announceTabbingConstraint: function () {
announceTabbingConstraint: function announceTabbingConstraint() {
var strings = this.options.strings;
Drupal.announce(Drupal.formatString(strings.tabbingConstrained, {
'@contextualsCount': Drupal.formatPlural(Drupal.contextual.collection.length, '@count contextual link', '@count contextual links')
}));
Drupal.announce(strings.pressEsc);
},
/**
* Responds to esc and tab key press events.
*
* @param {jQuery.Event} event
* The keypress event.
*/
onKeypress: function (event) {
// The first tab key press is tracked so that an annoucement about tabbing
// constraints can be raised if edit mode is enabled when the page is
// loaded.
onKeypress: function onKeypress(event) {
if (!this.announcedOnce && event.keyCode === 9 && !this.model.get('isViewing')) {
this.announceTabbingConstraint();
// Set announce to true so that this conditional block won't run again.
this.announcedOnce = true;
}
// Respond to the ESC key. Exit out of edit mode.
if (event.keyCode === 27) {
this.model.set('isViewing', true);
}
}
});
})(jQuery, Drupal, Backbone, _);
})(jQuery, Drupal, Backbone, _);

View file

@ -0,0 +1,81 @@
/**
* @file
* A Backbone View that provides the visual view of the edit mode toggle.
*/
(function(Drupal, Backbone) {
Drupal.contextualToolbar.VisualView = Backbone.View.extend(
/** @lends Drupal.contextualToolbar.VisualView# */ {
/**
* Events for the Backbone view.
*
* @return {object}
* A mapping of events to be used in the view.
*/
events() {
// Prevents delay and simulated mouse events.
const touchEndToClick = function(event) {
event.preventDefault();
event.target.click();
};
return {
click() {
this.model.set('isViewing', !this.model.get('isViewing'));
},
touchend: touchEndToClick,
};
},
/**
* Renders the visual view of the edit mode toggle.
*
* Listens to mouse & touch and handles edit mode toggle interactions.
*
* @constructs
*
* @augments Backbone.View
*/
initialize() {
this.listenTo(this.model, 'change', this.render);
this.listenTo(this.model, 'change:isViewing', this.persist);
},
/**
* @inheritdoc
*
* @return {Drupal.contextualToolbar.VisualView}
* The current contextual toolbar visual view.
*/
render() {
// Render the visibility.
this.$el.toggleClass('hidden', !this.model.get('isVisible'));
// Render the state.
this.$el
.find('button')
.toggleClass('is-active', !this.model.get('isViewing'));
return this;
},
/**
* Model change handler; persists the isViewing value to localStorage.
*
* `isViewing === true` is the default, so only stores in localStorage when
* it's not the default value (i.e. false).
*
* @param {Drupal.contextualToolbar.StateModel} model
* A {@link Drupal.contextualToolbar.StateModel} model.
* @param {bool} isViewing
* The value of the isViewing attribute in the model.
*/
persist(model, isViewing) {
if (!isViewing) {
localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false');
} else {
localStorage.removeItem('Drupal.contextualToolbar.isViewing');
}
},
},
);
})(Drupal, Backbone);

View file

@ -1,84 +1,43 @@
/**
* @file
* A Backbone View that provides the visual view of the edit mode toggle.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
'use strict';
Drupal.contextualToolbar.VisualView = Backbone.View.extend(/** @lends Drupal.contextualToolbar.VisualView# */{
/**
* Events for the Backbone view.
*
* @return {object}
* A mapping of events to be used in the view.
*/
events: function () {
// Prevents delay and simulated mouse events.
var touchEndToClick = function (event) {
Drupal.contextualToolbar.VisualView = Backbone.View.extend({
events: function events() {
var touchEndToClick = function touchEndToClick(event) {
event.preventDefault();
event.target.click();
};
return {
click: function () {
click: function click() {
this.model.set('isViewing', !this.model.get('isViewing'));
},
touchend: touchEndToClick
};
},
/**
* Renders the visual view of the edit mode toggle.
*
* Listens to mouse & touch and handles edit mode toggle interactions.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
initialize: function initialize() {
this.listenTo(this.model, 'change', this.render);
this.listenTo(this.model, 'change:isViewing', this.persist);
},
/**
* @inheritdoc
*
* @return {Drupal.contextualToolbar.VisualView}
* The current contextual toolbar visual view.
*/
render: function () {
// Render the visibility.
render: function render() {
this.$el.toggleClass('hidden', !this.model.get('isVisible'));
// Render the state.
this.$el.find('button').toggleClass('is-active', !this.model.get('isViewing'));
return this;
},
/**
* Model change handler; persists the isViewing value to localStorage.
*
* `isViewing === true` is the default, so only stores in localStorage when
* it's not the default value (i.e. false).
*
* @param {Drupal.contextualToolbar.StateModel} model
* A {@link Drupal.contextualToolbar.StateModel} model.
* @param {bool} isViewing
* The value of the isViewing attribute in the model.
*/
persist: function (model, isViewing) {
persist: function persist(model, isViewing) {
if (!isViewing) {
localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false');
}
else {
} else {
localStorage.removeItem('Drupal.contextualToolbar.isViewing');
}
}
});
})(Drupal, Backbone);
})(Drupal, Backbone);

View file

@ -0,0 +1,55 @@
/**
* @file
* A Backbone View that provides the aural view of a contextual link.
*/
(function(Drupal, Backbone) {
Drupal.contextual.AuralView = Backbone.View.extend(
/** @lends Drupal.contextual.AuralView# */ {
/**
* Renders the aural view of a contextual link (i.e. screen reader support).
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options for the view.
*/
initialize(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() {
const 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);

View file

@ -1,55 +1,30 @@
/**
* @file
* A Backbone View that provides the aural view of a contextual link.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
'use strict';
Drupal.contextual.AuralView = Backbone.View.extend(/** @lends Drupal.contextual.AuralView# */{
/**
* Renders the aural view of a contextual link (i.e. screen reader support).
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options for the view.
*/
initialize: function (options) {
Drupal.contextual.AuralView = Backbone.View.extend({
initialize: function initialize(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 () {
render: function render() {
var isOpen = this.model.get('isOpen');
// Set the hidden property of the links.
this.$el.find('.contextual-links')
.prop('hidden', !isOpen);
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);
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);
})(Drupal, Backbone);

View file

@ -0,0 +1,58 @@
/**
* @file
* A Backbone View that provides keyboard interaction for a contextual link.
*/
(function(Drupal, Backbone) {
Drupal.contextual.KeyboardView = Backbone.View.extend(
/** @lends Drupal.contextual.KeyboardView# */ {
/**
* @type {object}
*/
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.
const that = this;
this.timer = window.setTimeout(() => {
that.model.close().blur();
}, 150);
},
},
/**
* Provides keyboard interaction for a contextual link.
*
* @constructs
*
* @augments Backbone.View
*/
initialize() {
/**
* 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.
*
* @type {NaN|number}
*/
this.timer = NaN;
},
/**
* Sets focus on the model; Clears the timer that dismisses the links.
*/
focus() {
// Clear the timeout that might have been set by blurring a link.
window.clearTimeout(this.timer);
this.model.focus();
},
},
);
})(Drupal, Backbone);

View file

@ -1,24 +1,19 @@
/**
* @file
* A Backbone View that provides keyboard interaction for a contextual link.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
'use strict';
Drupal.contextual.KeyboardView = Backbone.View.extend(/** @lends Drupal.contextual.KeyboardView# */{
/**
* @type {object}
*/
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.
'blur .trigger': function blurTrigger() {
this.model.blur();
},
'blur .contextual-links a': function blurContextualLinksA() {
var that = this;
this.timer = window.setTimeout(function () {
that.model.close().blur();
@ -26,36 +21,12 @@
}
},
/**
* Provides keyboard interaction for a contextual link.
*
* @constructs
*
* @augments Backbone.View
*/
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.
*
* @type {NaN|number}
*/
initialize: function initialize() {
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.
focus: function focus() {
window.clearTimeout(this.timer);
this.model.focus();
}
});
})(Drupal, Backbone);
})(Drupal, Backbone);

View file

@ -0,0 +1,58 @@
/**
* @file
* A Backbone View that renders the visual view of a contextual region element.
*/
(function(Drupal, Backbone, Modernizr) {
Drupal.contextual.RegionView = Backbone.View.extend(
/** @lends Drupal.contextual.RegionView# */ {
/**
* Events for the Backbone view.
*
* @return {object}
* A mapping of events to be used in the view.
*/
events() {
let mapping = {
mouseenter() {
this.model.set('regionIsHovered', true);
},
mouseleave() {
this.model
.close()
.blur()
.set('regionIsHovered', false);
},
};
// We don't want mouse hover events on touch.
if (Modernizr.touchevents) {
mapping = {};
}
return mapping;
},
/**
* Renders the visual view of a contextual region element.
*
* @constructs
*
* @augments Backbone.View
*/
initialize() {
this.listenTo(this.model, 'change:hasFocus', this.render);
},
/**
* @inheritdoc
*
* @return {Drupal.contextual.RegionView}
* The current contextual region view.
*/
render() {
this.$el.toggleClass('focus', this.model.get('hasFocus'));
return this;
},
},
);
})(Drupal, Backbone, Modernizr);

View file

@ -1,57 +1,34 @@
/**
* @file
* A Backbone View that renders the visual view of a contextual region element.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone, Modernizr) {
'use strict';
Drupal.contextual.RegionView = Backbone.View.extend(/** @lends Drupal.contextual.RegionView# */{
/**
* Events for the Backbone view.
*
* @return {object}
* A mapping of events to be used in the view.
*/
events: function () {
Drupal.contextual.RegionView = Backbone.View.extend({
events: function events() {
var mapping = {
mouseenter: function () { this.model.set('regionIsHovered', true); },
mouseleave: function () {
mouseenter: function mouseenter() {
this.model.set('regionIsHovered', true);
},
mouseleave: function mouseleave() {
this.model.close().blur().set('regionIsHovered', false);
}
};
// We don't want mouse hover events on touch.
if (Modernizr.touchevents) {
mapping = {};
}
return mapping;
},
/**
* Renders the visual view of a contextual region element.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
initialize: function initialize() {
this.listenTo(this.model, 'change:hasFocus', this.render);
},
/**
* @inheritdoc
*
* @return {Drupal.contextual.RegionView}
* The current contextual region view.
*/
render: function () {
render: function render() {
this.$el.toggleClass('focus', this.model.get('hasFocus'));
return this;
}
});
})(Drupal, Backbone, Modernizr);
})(Drupal, Backbone, Modernizr);

View file

@ -0,0 +1,87 @@
/**
* @file
* A Backbone View that provides the visual view of a contextual link.
*/
(function(Drupal, Backbone, Modernizr) {
Drupal.contextual.VisualView = Backbone.View.extend(
/** @lends Drupal.contextual.VisualView# */ {
/**
* Events for the Backbone view.
*
* @return {object}
* A mapping of events to be used in the view.
*/
events() {
// Prevents delay and simulated mouse events.
const touchEndToClick = function(event) {
event.preventDefault();
event.target.click();
};
const 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.touchevents) {
mapping.mouseenter = function() {
this.model.focus();
};
}
return mapping;
},
/**
* Renders the visual view of a contextual link. Listens to mouse & touch.
*
* @constructs
*
* @augments Backbone.View
*/
initialize() {
this.listenTo(this.model, 'change', this.render);
},
/**
* @inheritdoc
*
* @return {Drupal.contextual.VisualView}
* The current contextual visual view.
*/
render() {
const 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.
const 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);

View file

@ -1,80 +1,50 @@
/**
* @file
* A Backbone View that provides the visual view of a contextual link.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone, Modernizr) {
'use strict';
Drupal.contextual.VisualView = Backbone.View.extend(/** @lends Drupal.contextual.VisualView# */{
/**
* Events for the Backbone view.
*
* @return {object}
* A mapping of events to be used in the view.
*/
events: function () {
// Prevents delay and simulated mouse events.
var touchEndToClick = function (event) {
Drupal.contextual.VisualView = Backbone.View.extend({
events: function events() {
var touchEndToClick = function touchEndToClick(event) {
event.preventDefault();
event.target.click();
};
var mapping = {
'click .trigger': function () { this.model.toggleOpen(); },
'click .trigger': function clickTrigger() {
this.model.toggleOpen();
},
'touchend .trigger': touchEndToClick,
'click .contextual-links a': function () { this.model.close().blur(); },
'click .contextual-links a': function clickContextualLinksA() {
this.model.close().blur();
},
'touchend .contextual-links a': touchEndToClick
};
// We only want mouse hover events on non-touch.
if (!Modernizr.touchevents) {
mapping.mouseenter = function () { this.model.focus(); };
mapping.mouseenter = function () {
this.model.focus();
};
}
return mapping;
},
/**
* Renders the visual view of a contextual link. Listens to mouse & touch.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
initialize: function initialize() {
this.listenTo(this.model, 'change', this.render);
},
/**
* @inheritdoc
*
* @return {Drupal.contextual.VisualView}
* The current contextual visual view.
*/
render: function () {
render: function render() {
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);
this.$el.toggleClass('open', isOpen).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);
this.$el.closest('.contextual-region').find('.contextual .trigger:not(:first)').toggle(!isOpen);
}
return this;
}
});
})(Drupal, Backbone, Modernizr);
})(Drupal, Backbone, Modernizr);

View file

@ -2,8 +2,11 @@
namespace Drupal\contextual;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Site\Settings;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@ -11,9 +14,33 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Returns responses for Contextual module routes.
*/
class ContextualController implements ContainerAwareInterface {
class ContextualController implements ContainerInjectionInterface {
use ContainerAwareTrait;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructors a new ContextualController.
*
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(RendererInterface $renderer) {
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('renderer')
);
}
/**
* Returns the requested rendered contextual links.
@ -21,10 +48,16 @@ class ContextualController implements ContainerAwareInterface {
* Given a list of contextual links IDs, render them. Hence this must be
* robust to handle arbitrary input.
*
* @see contextual_preprocess()
* @param \Symfony\Component\HttpFoundation\Request $request
* The Symfony request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Thrown when the request contains no ids.
*
* @see contextual_preprocess()
*/
public function render(Request $request) {
$ids = $request->request->get('ids');
@ -32,13 +65,21 @@ class ContextualController implements ContainerAwareInterface {
throw new BadRequestHttpException(t('No contextual ids specified.'));
}
$tokens = $request->request->get('tokens');
if (!isset($tokens)) {
throw new BadRequestHttpException(t('No contextual ID tokens specified.'));
}
$rendered = [];
foreach ($ids as $id) {
foreach ($ids as $key => $id) {
if (!isset($tokens[$key]) || !Crypt::hashEquals($tokens[$key], Crypt::hmacBase64($id, Settings::getHashSalt() . \Drupal::service('private_key')->get()))) {
throw new BadRequestHttpException('Invalid contextual ID specified.');
}
$element = [
'#type' => 'contextual_links',
'#contextual_links' => _contextual_id_to_links($id),
];
$rendered[$id] = $this->container->get('renderer')->renderRoot($element);
$rendered[$id] = $this->renderer->renderRoot($element);
}
return new JsonResponse($rendered);

View file

@ -78,7 +78,7 @@ class ContextualLinks extends RenderElement {
$class = Html::getClass($class);
$links[$class] = [
'title' => $item['title'],
'url' => Url::fromRoute(isset($item['route_name']) ? $item['route_name'] : '', isset($item['route_parameters']) ? $item['route_parameters'] : []),
'url' => Url::fromRoute(isset($item['route_name']) ? $item['route_name'] : '', isset($item['route_parameters']) ? $item['route_parameters'] : [], $item['localized_options']),
];
}
$element['#links'] = $links;

View file

@ -2,9 +2,11 @@
namespace Drupal\contextual\Element;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Site\Settings;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Render\Element\RenderElement;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Component\Render\FormattableMarkup;
/**
* Provides a contextual_links_placeholder element.
@ -43,7 +45,12 @@ class ContextualLinksPlaceholder extends RenderElement {
* @see _contextual_links_to_id()
*/
public static function preRenderPlaceholder(array $element) {
$element['#markup'] = SafeMarkup::format('<div@attributes></div>', ['@attributes' => new Attribute(['data-contextual-id' => $element['#id']])]);
$token = Crypt::hmacBase64($element['#id'], Settings::getHashSalt() . \Drupal::service('private_key')->get());
$attribute = new Attribute([
'data-contextual-id' => $element['#id'],
'data-contextual-token' => $token,
]);
$element['#markup'] = new FormattableMarkup('<div@attributes></div>', ['@attributes' => $attribute]);
return $element;
}

View file

@ -130,15 +130,15 @@ class ContextualLinks extends FieldPluginBase {
[],
[
'contextual-views-field-links' => UrlHelper::encodePath(Json::encode($links)),
]
]
],
],
];
$element = [
'#type' => 'contextual_links_placeholder',
'#id' => _contextual_links_to_id($contextual_links),
];
return drupal_render($element);
return \Drupal::service('renderer')->render($element);
}
else {
return '';
@ -148,6 +148,6 @@ class ContextualLinks extends FieldPluginBase {
/**
* {@inheritdoc}
*/
public function query() { }
public function query() {}
}

View file

@ -0,0 +1,8 @@
name: 'Contextual Test'
type: module
description: 'Provides test contextual links.'
package: Testing
version: VERSION
core: 8.x
dependencies:
- drupal:contextual

View file

@ -0,0 +1,12 @@
contextual_test:
title: 'Test Link'
route_name: 'contextual_test'
group: 'contextual_test'
contextual_test_ajax:
title: 'Test Link with Ajax'
route_name: 'contextual_test'
group: 'contextual_test'
options:
attributes:
class: ['use-ajax']
data-dialog-type: 'modal'

View file

@ -0,0 +1,37 @@
<?php
/**
* @file
* Provides test contextual link on blocks.
*/
use Drupal\Core\Block\BlockPluginInterface;
/**
* Implements hook_block_view_alter().
*/
function contextual_test_block_view_alter(array &$build, BlockPluginInterface $block) {
$build['#contextual_links']['contextual_test'] = [
'route_parameters' => [],
];
}
/**
* Implements hook_contextual_links_view_alter().
*
* @todo Apparently this too late to attach the library?
* It won't work without contextual_test_page_attachments_alter()
* Is that a problem? Should the contextual module itself do the attaching?
*/
function contextual_test_contextual_links_view_alter(&$element, $items) {
if (isset($element['#links']['contextual-test-ajax'])) {
$element['#attached']['library'][] = 'core/drupal.dialog.ajax';
}
}
/**
* Implements hook_page_attachments_alter().
*/
function contextual_test_page_attachments_alter(array &$attachments) {
$attachments['#attached']['library'][] = 'core/drupal.dialog.ajax';
}

View file

@ -0,0 +1,6 @@
contextual_test:
path: '/contextual-tests'
defaults:
_controller: '\Drupal\contextual_test\Controller\TestController::render'
requirements:
_access: 'TRUE'

View file

@ -0,0 +1,23 @@
<?php
namespace Drupal\contextual_test\Controller;
/**
* Test controller to provide a callback for the contextual link.
*/
class TestController {
/**
* Callback for the contextual link.
*
* @return array
* Render array.
*/
public function render() {
return [
'#type' => 'markup',
'#markup' => 'Everything is contextual!',
];
}
}

View file

@ -1,12 +1,13 @@
<?php
namespace Drupal\contextual\Tests;
namespace Drupal\Tests\contextual\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Site\Settings;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\simpletest\WebTestBase;
use Drupal\Core\Template\Attribute;
use Drupal\Tests\BrowserTestBase;
/**
* Tests if contextual links are showing on the front page depending on
@ -14,7 +15,7 @@ use Drupal\Core\Template\Attribute;
*
* @group contextual
*/
class ContextualDynamicContextTest extends WebTestBase {
class ContextualDynamicContextTest extends BrowserTestBase {
/**
* A user with permission to access contextual links and edit content.
@ -89,12 +90,12 @@ class ContextualDynamicContextTest extends WebTestBase {
for ($i = 0; $i < count($ids); $i++) {
$this->assertContextualLinkPlaceHolder($ids[$i]);
}
$this->renderContextualLinks([], 'node');
$this->assertResponse(400);
$this->assertRaw('No contextual ids specified.');
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(400, $response->getStatusCode());
$this->assertContains('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$this->assertResponse(200);
$json = Json::decode($response);
$this->assertSame(200, $response->getStatusCode());
$json = Json::decode((string) $response->getBody());
$this->assertIdentical($json[$ids[0]], '<ul class="contextual-links"><li class="entitynodeedit-form"><a href="' . base_path() . 'node/1/edit">Edit</a></li></ul>');
$this->assertIdentical($json[$ids[1]], '');
$this->assertIdentical($json[$ids[2]], '<ul class="contextual-links"><li class="entitynodeedit-form"><a href="' . base_path() . 'node/3/edit">Edit</a></li></ul>');
@ -112,12 +113,12 @@ class ContextualDynamicContextTest extends WebTestBase {
for ($i = 0; $i < count($ids); $i++) {
$this->assertContextualLinkPlaceHolder($ids[$i]);
}
$this->renderContextualLinks([], 'node');
$this->assertResponse(400);
$this->assertRaw('No contextual ids specified.');
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(400, $response->getStatusCode());
$this->assertContains('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$this->assertResponse(200);
$json = Json::decode($response);
$this->assertSame(200, $response->getStatusCode());
$json = Json::decode((string) $response->getBody());
$this->assertIdentical($json[$ids[0]], '');
$this->assertIdentical($json[$ids[1]], '');
$this->assertIdentical($json[$ids[2]], '');
@ -129,15 +130,72 @@ class ContextualDynamicContextTest extends WebTestBase {
for ($i = 0; $i < count($ids); $i++) {
$this->assertNoContextualLinkPlaceHolder($ids[$i]);
}
$this->renderContextualLinks([], 'node');
$this->assertResponse(403);
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(403, $response->getStatusCode());
$this->renderContextualLinks($ids, 'node');
$this->assertResponse(403);
$this->assertSame(403, $response->getStatusCode());
// Get a page where contextual links are directly rendered.
$this->drupalGet(Url::fromRoute('menu_test.contextual_test'));
$this->assertEscaped("<script>alert('Welcome to the jungle!')</script>");
$this->assertLink('Edit menu - contextual');
$this->assertRaw('<li class="menu-testcontextual-hidden-manage-edit"><a href="' . base_path() . 'menu-test-contextual/1/edit" class="use-ajax" data-dialog-type="modal" data-is-something>Edit menu - contextual</a></li>');
}
/**
* Tests the contextual placeholder content is protected by a token.
*/
public function testTokenProtection() {
$this->drupalLogin($this->editorUser);
// Create a node that will have a contextual link.
$node1 = $this->drupalCreateNode(['type' => 'article', 'promote' => 1]);
// Now, on the front page, all article nodes should have contextual links
// placeholders, as should the view that contains them.
$id = 'node:node=' . $node1->id() . ':changed=' . $node1->getChangedTime() . '&langcode=en';
// Editor user: can access contextual links and can edit articles.
$this->drupalGet('node');
$this->assertContextualLinkPlaceHolder($id);
$http_client = $this->getHttpClient();
$url = Url::fromRoute('contextual.render', [], [
'query' => [
'_format' => 'json',
'destination' => 'node',
],
])->setAbsolute()->toString();
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => []],
'http_errors' => FALSE,
]);
$this->assertEquals('400', $response->getStatusCode());
$this->assertContains('No contextual ID tokens specified.', (string) $response->getBody());
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => ['wrong_token']],
'http_errors' => FALSE,
]);
$this->assertEquals('400', $response->getStatusCode());
$this->assertContains('Invalid contextual ID specified.', (string) $response->getBody());
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => ['wrong_key' => $this->createContextualIdToken($id)]],
'http_errors' => FALSE,
]);
$this->assertEquals('400', $response->getStatusCode());
$this->assertContains('Invalid contextual ID specified.', (string) $response->getBody());
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => [$this->createContextualIdToken($id)]],
'http_errors' => FALSE,
]);
$this->assertEquals('200', $response->getStatusCode());
}
/**
@ -145,12 +203,14 @@ class ContextualDynamicContextTest extends WebTestBase {
*
* @param string $id
* A contextual link id.
*
* @return bool
* The result of the assertion.
*/
protected function assertContextualLinkPlaceHolder($id) {
return $this->assertRaw('<div' . new Attribute(['data-contextual-id' => $id]) . '></div>', format_string('Contextual link placeholder with id @id exists.', ['@id' => $id]));
$this->assertSession()->elementAttributeContains(
'css',
'div[data-contextual-id="' . $id . '"]',
'data-contextual-token',
$this->createContextualIdToken($id)
);
}
/**
@ -158,12 +218,9 @@ class ContextualDynamicContextTest extends WebTestBase {
*
* @param string $id
* A contextual link id.
*
* @return bool
* The result of the assertion.
*/
protected function assertNoContextualLinkPlaceHolder($id) {
return $this->assertNoRaw('<div' . new Attribute(['data-contextual-id' => $id]) . '></div>', format_string('Contextual link placeholder with id @id does not exist.', ['@id' => $id]));
$this->assertSession()->elementNotExists('css', 'div[data-contextual-id="' . $id . '"]');
}
/**
@ -174,15 +231,37 @@ class ContextualDynamicContextTest extends WebTestBase {
* @param string $current_path
* The Drupal path for the page for which the contextual links are rendered.
*
* @return string
* The response body.
* @return \Psr\Http\Message\ResponseInterface
* The response object.
*/
protected function renderContextualLinks($ids, $current_path) {
$post = [];
for ($i = 0; $i < count($ids); $i++) {
$post['ids[' . $i . ']'] = $ids[$i];
}
return $this->drupalPostWithFormat('contextual/render', 'json', $post, ['query' => ['destination' => $current_path]]);
$tokens = array_map([$this, 'createContextualIdToken'], $ids);
$http_client = $this->getHttpClient();
$url = Url::fromRoute('contextual.render', [], [
'query' => [
'_format' => 'json',
'destination' => $current_path,
],
]);
return $http_client->request('POST', $this->buildUrl($url), [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => $ids, 'tokens' => $tokens],
'http_errors' => FALSE,
]);
}
/**
* Creates a contextual ID token.
*
* @param string $id
* The contextual ID to create a token for.
*
* @return string
* The contextual ID token.
*/
protected function createContextualIdToken($id) {
return Crypt::hmacBase64($id, Settings::getHashSalt() . $this->container->get('private_key')->get());
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Drupal\Tests\contextual\FunctionalJavascript;
/**
* Functions for testing contextual links.
*/
trait ContextualLinkClickTrait {
/**
* Clicks a contextual link.
*
* @param string $selector
* The selector for the element that contains the contextual link.
* @param string $link_locator
* The link id, title, or text.
* @param bool $force_visible
* If true then the button will be forced to visible so it can be clicked.
*/
protected function clickContextualLink($selector, $link_locator, $force_visible = TRUE) {
$page = $this->getSession()->getPage();
$page->waitFor(10, function () use ($page, $selector) {
return $page->find('css', "$selector .contextual-links");
});
if ($force_visible) {
$this->toggleContextualTriggerVisibility($selector);
}
$element = $this->getSession()->getPage()->find('css', $selector);
$element->find('css', '.contextual button')->press();
$element->findLink($link_locator)->click();
if ($force_visible) {
$this->toggleContextualTriggerVisibility($selector);
}
}
/**
* Toggles the visibility of a contextual trigger.
*
* @param string $selector
* The selector for the element that contains the contextual link.
*/
protected function toggleContextualTriggerVisibility($selector) {
// Hovering over the element itself with should be enough, but does not
// work. Manually remove the visually-hidden class.
$this->getSession()->executeScript("jQuery('{$selector} .contextual .trigger').toggleClass('visually-hidden');");
}
}

View file

@ -2,7 +2,7 @@
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\user\Entity\Role;
/**
@ -10,7 +10,9 @@ use Drupal\user\Entity\Role;
*
* @group contextual
*/
class ContextualLinksTest extends JavascriptTestBase {
class ContextualLinksTest extends WebDriverTestBase {
use ContextualLinkClickTrait;
/**
* {@inheritdoc}
@ -23,6 +25,7 @@ class ContextualLinksTest extends JavascriptTestBase {
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->createUser(['access contextual links']));
$this->placeBlock('system_branding_block', ['id' => 'branding']);
}
@ -30,10 +33,6 @@ class ContextualLinksTest extends JavascriptTestBase {
* Tests the visibility of contextual links.
*/
public function testContextualLinksVisibility() {
$this->drupalLogin($this->drupalCreateUser([
'access contextual links'
]));
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertEmpty($contextualLinks);
@ -59,4 +58,53 @@ class ContextualLinksTest extends JavascriptTestBase {
$this->assertNotEmpty($contextualLinks);
}
/**
* Test clicking contextual links.
*/
public function testContextualLinksClick() {
$this->container->get('module_installer')->install(['contextual_test']);
// Test clicking contextual link without toolbar.
$this->drupalGet('user');
$this->assertSession()->assertWaitOnAjaxRequest();
$this->clickContextualLink('#block-branding', 'Test Link');
$this->assertSession()->pageTextContains('Everything is contextual!');
// Test click a contextual link that uses ajax.
$this->drupalGet('user');
$this->assertSession()->assertWaitOnAjaxRequest();
$current_page_string = 'NOT_RELOADED_IF_ON_PAGE';
$this->getSession()->executeScript('document.body.appendChild(document.createTextNode("' . $current_page_string . '"));');
$this->clickContextualLink('#block-branding', 'Test Link with Ajax');
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '#drupal-modal'));
$this->assertSession()->elementContains('css', '#drupal-modal', 'Everything is contextual!');
// Check to make sure that page was not reloaded.
$this->assertSession()->pageTextContains($current_page_string);
// Test clicking contextual link with toolbar.
$this->container->get('module_installer')->install(['toolbar']);
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), ['access toolbar']);
$this->drupalGet('user');
$this->assertSession()->assertWaitOnAjaxRequest();
// Click "Edit" in toolbar to show contextual links.
$this->getSession()->getPage()->find('css', '.contextual-toolbar-tab button')->press();
$this->clickContextualLink('#block-branding', 'Test Link', FALSE);
$this->assertSession()->pageTextContains('Everything is contextual!');
}
/**
* Test the contextual links destination.
*/
public function testContextualLinksDestination() {
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), [
'access contextual links',
'administer blocks',
]);
$this->drupalGet('user');
$this->assertSession()->waitForElement('css', '.contextual button');
$expected_destination_value = (string) $this->loggedInUser->toUrl()->toString();
$contextual_link_url_parsed = parse_url($this->getSession()->getPage()->findLink('Configure block')->getAttribute('href'));
$this->assertEquals("destination=$expected_destination_value", $contextual_link_url_parsed['query']);
}
}

View file

@ -0,0 +1,137 @@
<?php
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests edit mode.
*
* @group contextual
*/
class EditModeTest extends WebDriverTestBase {
/**
* CSS selector for Drupal's announce element.
*/
const ANNOUNCE_SELECTOR = '#drupal-live-announce';
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'block',
'user',
'system',
'breakpoint',
'toolbar',
'contextual',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->createUser([
'administer blocks',
'access contextual links',
'access toolbar',
]));
$this->placeBlock('system_powered_by_block', ['id' => 'powered']);
}
/**
* Tests enabling and disabling edit mode.
*/
public function testEditModeEnableDisalbe() {
$web_assert = $this->assertSession();
$page = $this->getSession()->getPage();
// Get the page twice to ensure edit mode remains enabled after a new page
// request.
for ($page_get_count = 0; $page_get_count < 2; $page_get_count++) {
$this->drupalGet('user');
$expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]'));
// After the page loaded we need to additionally wait until the settings
// tray Ajax activity is done.
$web_assert->assertWaitOnAjaxRequest();
if ($page_get_count == 0) {
$unrestricted_tab_count = $this->getTabbableElementsCount();
$this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count);
// Enable edit mode.
// After the first page load the page will be in edit mode when loaded.
$this->pressToolbarEditButton();
}
$this->assertAnnounceEditMode();
$this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
// Disable edit mode.
$this->pressToolbarEditButton();
$this->assertAnnounceLeaveEditMode();
$this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount());
// Enable edit mode again.
$this->pressToolbarEditButton();
// Finally assert that the 'edit mode enabled' announcement is still
// correct after toggling the edit mode at least once.
$this->assertAnnounceEditMode();
$this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
}
}
/**
* Presses the toolbar edit mode.
*/
protected function pressToolbarEditButton() {
$edit_button = $this->getSession()->getPage()->find('css', '#toolbar-bar div.contextual-toolbar-tab button');
$edit_button->press();
}
/**
* Asserts that the correct message was announced when entering edit mode.
*/
protected function assertAnnounceEditMode() {
$web_assert = $this->assertSession();
// Wait for contextual trigger button.
$web_assert->waitForElementVisible('css', '.contextual trigger');
$web_assert->elementContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is constrained to a set of');
$web_assert->elementNotContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is no longer constrained by the Contextual module.');
}
/**
* Assert that the correct message was announced when leaving edit mode.
*/
protected function assertAnnounceLeaveEditMode() {
$web_assert = $this->assertSession();
$page = $this->getSession()->getPage();
// Wait till all the contextual links are hidden.
$page->waitFor(1, function () use ($page, $web_assert) {
return empty($page->find('css', '.contextual .trigger.visually-hidden'));
});
$web_assert->elementContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is no longer constrained by the Contextual module.');
$web_assert->elementNotContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is constrained to a set of');
}
/**
* Gets the number of elements that are tabbable.
*
* @return int
* The number of tabbable elements.
*/
protected function getTabbableElementsCount() {
// Mark all tabbable elements.
$this->getSession()->executeScript("jQuery(':tabbable').attr('data-marked', '');");
// Count all marked elements.
$count = count($this->getSession()->getPage()->findAll('css', "[data-marked]"));
// Remove set attributes.
$this->getSession()->executeScript("jQuery('[data-marked]').removeAttr('data-marked');");
return $count;
}
}

View file

@ -65,7 +65,7 @@ class ContextualUnitTest extends KernelTestBase {
'links' => [
'views_ui_edit' => [
'route_parameters' => [
'view' => 'frontpage'
'view' => 'frontpage',
],
'metadata' => [
'location' => 'page',