Move all files to 2017/

This commit is contained in:
Oliver Davies 2025-09-29 22:25:17 +01:00
parent ac7370f67f
commit 2875863330
15717 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,15 @@
# Schema for the views plugins of the Contextual module.
views.field.contextual_links:
type: views_field
label: 'Contextual link'
mapping:
fields:
type: sequence
label: 'Fields'
sequence:
type: string
label: 'Link'
destination:
type: boolean
label: 'Include destination'

View file

@ -0,0 +1,41 @@
<?php
/**
* @file
* Hooks provided by Contextual module.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Alter a contextual links element before it is rendered.
*
* This hook is invoked by contextual_pre_render_links(). The renderable array
* of #type 'contextual_links', containing the entire contextual links data that
* is passed in by reference. Further links may be added or existing links can
* be altered.
*
* @param $element
* A renderable array representing the contextual links.
* @param $items
* An associative array containing the original contextual link items, as
* generated by
* \Drupal\Core\Menu\ContextualLinkManagerInterface::getContextualLinksArrayByGroup(),
* which were used to build $element['#links'].
*
* @see hook_contextual_links_alter()
* @see hook_contextual_links_plugins_alter()
* @see contextual_pre_render_links()
*/
function hook_contextual_links_view_alter(&$element, $items) {
// Add another class to all contextual link lists to facilitate custom
// styling.
$element['#attributes']['class'][] = 'custom-class';
}
/**
* @} End of "addtogroup hooks".
*/

View file

@ -0,0 +1,6 @@
name: 'Contextual Links'
type: module
description: 'Provides contextual links to perform actions related to elements on a page.'
package: Core
version: VERSION
core: 8.x

View file

@ -0,0 +1,47 @@
drupal.contextual-links:
version: VERSION
js:
# Ensure to run before contextual/drupal.context-toolbar.
# Core.
js/contextual.js: { weight: -2 }
# Models.
js/models/StateModel.js: { weight: -2 }
# Views.
js/views/AuralView.js: { weight: -2 }
js/views/KeyboardView.js: { weight: -2 }
js/views/RegionView.js: { weight: -2 }
js/views/VisualView.js: { weight: -2 }
css:
component:
css/contextual.module.css: {}
theme:
css/contextual.theme.css: {}
css/contextual.icons.theme.css: {}
dependencies:
- core/jquery
- core/drupal
- core/drupal.ajax
- core/drupalSettings
- core/backbone
- core/modernizr
- core/jquery.once
drupal.contextual-toolbar:
version: VERSION
js:
js/contextual.toolbar.js: {}
# Models.
js/toolbar/models/StateModel.js: {}
# Views.
js/toolbar/views/AuralView.js: {}
js/toolbar/views/VisualView.js: {}
css:
component:
css/contextual.toolbar.css: {}
dependencies:
- core/jquery
- core/drupal
- core/backbone
- core/jquery.once
- core/drupal.tabbingmanager
- core/drupal.announce

View file

@ -0,0 +1,222 @@
<?php
/**
* @file
* Adds contextual links to perform actions related to elements on a page.
*/
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_toolbar().
*/
function contextual_toolbar() {
$items = [];
$items['contextual'] = [
'#cache' => [
'contexts' => [
'user.permissions',
],
],
];
if (!\Drupal::currentUser()->hasPermission('access contextual links')) {
return $items;
}
$items['contextual'] += [
'#type' => 'toolbar_item',
'tab' => [
'#type' => 'html_tag',
'#tag' => 'button',
'#value' => t('Edit'),
'#attributes' => [
'class' => ['toolbar-icon', 'toolbar-icon-edit'],
'aria-pressed' => 'false',
'type' => 'button',
],
],
'#wrapper_attributes' => [
'class' => ['hidden', 'contextual-toolbar-tab'],
],
'#attached' => [
'library' => [
'contextual/drupal.contextual-toolbar',
],
],
];
return $items;
}
/**
* Implements hook_page_attachments().
*
* Adds the drupal.contextual-links library to the page for any user who has the
* 'access contextual links' permission.
*
* @see contextual_preprocess()
*/
function contextual_page_attachments(array &$page) {
if (!\Drupal::currentUser()->hasPermission('access contextual links')) {
return;
}
$page['#attached']['library'][] = 'contextual/drupal.contextual-links';
}
/**
* Implements hook_help().
*/
function contextual_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.contextual':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The Contextual links module gives users with the <em>Use contextual links</em> permission quick access to tasks associated with certain areas of pages on your site. For example, a menu displayed as a block has links to edit the menu and configure the block. For more information, see the <a href=":contextual">online documentation for the Contextual Links module</a>.', [':contextual' => 'https://www.drupal.org/documentation/modules/contextual']) . '</p>';
$output .= '<h3>' . t('Uses') . '</h3>';
$output .= '<dl>';
$output .= '<dt>' . t('Displaying contextual links') . '</dt>';
$output .= '<dd>';
$output .= t('Contextual links for an area on a page are displayed using a contextual links button. There are two ways to make the contextual links button visible:');
$output .= '<ol>';
$sample_picture = [
'#theme' => 'image',
'#uri' => 'core/misc/icons/bebebe/pencil.svg',
'#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>';
$output .= '<li>' . t('If you have the <a href=":toolbar">Toolbar module</a> enabled, clicking the contextual links button in the toolbar (which looks like a pencil) will make all contextual links buttons on the page visible. Clicking this button again will toggle them to invisible.', [':toolbar' => (\Drupal::moduleHandler()->moduleExists('toolbar')) ? \Drupal::url('help.page', ['name' => 'toolbar']) : '#']) . '</li>';
$output .= '</ol>';
$output .= t('Once the contextual links button for the area of interest is visible, click the button to display the links.');
$output .= '</dd>';
$output .= '</dl>';
return $output;
}
}
/**
* Implements hook_preprocess().
*
* @see contextual_pre_render_placeholder()
* @see contextual_page_attachments()
* @see \Drupal\contextual\ContextualController::render()
*/
function contextual_preprocess(&$variables, $hook, $info) {
$variables['#cache']['contexts'][] = 'user.permissions';
if (!\Drupal::currentUser()->hasPermission('access contextual links')) {
return;
}
// Determine the primary theme function argument.
if (!empty($info['variables'])) {
$keys = array_keys($info['variables']);
$key = $keys[0];
}
elseif (!empty($info['render element'])) {
$key = $info['render element'];
}
if (!empty($key) && isset($variables[$key])) {
$element = $variables[$key];
}
if (isset($element) && is_array($element) && !empty($element['#contextual_links'])) {
// Mark this element as potentially having contextual links attached to it.
$variables['attributes']['class'][] = 'contextual-region';
// Renders a contextual links placeholder unconditionally, thus not breaking
// the render cache. Although the empty placeholder is rendered for all
// users, contextual_page_attachments() only adds the asset library for
// users with the 'access contextual links' permission, thus preventing
// unnecessary HTTP requests for users without that permission.
$variables['title_suffix']['contextual_links'] = [
'#type' => 'contextual_links_placeholder',
'#id' => _contextual_links_to_id($element['#contextual_links']),
];
}
}
/**
* Implements hook_contextual_links_view_alter().
*
* @see \Drupal\contextual\Plugin\views\field\ContextualLinks::render()
*/
function contextual_contextual_links_view_alter(&$element, $items) {
if (isset($element['#contextual_links']['contextual'])) {
$encoded_links = $element['#contextual_links']['contextual']['metadata']['contextual-views-field-links'];
$element['#links'] = Json::decode(rawurldecode($encoded_links));
}
}
/**
* Serializes #contextual_links property value array to a string.
*
* Examples:
* - node:node=1:langcode=en
* - views_ui_edit:view=frontpage:location=page&view_name=frontpage&view_display_id=page_1&langcode=en
* - menu:menu=tools:langcode=en|block:block=bartik.tools:langcode=en
*
* So, expressed in a pattern:
* <group>:<route parameters>:<metadata>
*
* The route parameters and options are encoded as query strings.
*
* @param array $contextual_links
* The $element['#contextual_links'] value for some render element.
*
* @return string
* A serialized representation of a #contextual_links property value array for
* use in a data- attribute.
*/
function _contextual_links_to_id($contextual_links) {
$ids = [];
$langcode = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId();
foreach ($contextual_links as $group => $args) {
$route_parameters = UrlHelper::buildQuery($args['route_parameters']);
$args += ['metadata' => []];
// Add the current URL language to metadata so a different ID will be
// computed when URLs vary by language. This allows to store different
// language-aware contextual links on the client side.
$args['metadata'] += ['langcode' => $langcode];
$metadata = UrlHelper::buildQuery($args['metadata']);
$ids[] = "{$group}:{$route_parameters}:{$metadata}";
}
return implode('|', $ids);
}
/**
* Unserializes the result of _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 = [];
$contexts = explode('|', $id);
foreach ($contexts as $context) {
list($group, $route_parameters_raw, $metadata_raw) = explode(':', $context);
parse_str($route_parameters_raw, $route_parameters);
$metadata = [];
parse_str($metadata_raw, $metadata);
$contextual_links[$group] = [
'route_parameters' => $route_parameters,
'metadata' => $metadata,
];
}
return $contextual_links;
}

View file

@ -0,0 +1,2 @@
access contextual links:
title: 'Use 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

@ -0,0 +1,6 @@
contextual.render:
path: '/contextual/render'
defaults:
_controller: '\Drupal\contextual\ContextualController::render'
requirements:
_permission: 'access contextual links'

View file

@ -0,0 +1,19 @@
<?php
/**
* @file
* Provide views data for contextual.module.
*/
/**
* Implements hook_views_data_alter().
*/
function contextual_views_data_alter(&$data) {
$data['views']['contextual_links'] = [
'title' => t('Contextual Links'),
'help' => t('Display fields in a contextual links menu.'),
'field' => [
'id' => 'contextual_links',
],
];
}

View file

@ -0,0 +1,39 @@
/**
* @file
* Styling for contextual module icons.
*/
/**
* Toolbar tab icon.
*/
.toolbar-bar .toolbar-icon-edit:before {
background-image: url(../../../misc/icons/bebebe/pencil.svg);
}
.toolbar-bar .toolbar-icon-edit:active:before,
.toolbar-bar .toolbar-icon-edit.is-active:before {
background-image: url(../../../misc/icons/ffffff/pencil.svg);
}
/**
* Contextual trigger.
*/
.contextual .trigger {
background-image: url(../../../misc/icons/bebebe/pencil.svg);
background-position: center center;
background-repeat: no-repeat;
background-size: 16px 16px;
/* Override the .focusable height: auto */
height: 26px !important;
/* Override the .focusable height: auto */
width: 26px !important;
text-indent: -9999px;
}
.contextual .trigger:hover {
background-image: url(../../../misc/icons/787878/pencil.svg);
}
.contextual .trigger:focus {
background-image: url(../../../misc/icons/5181c6/pencil.svg);
outline: none;
}

View file

@ -0,0 +1,18 @@
/**
* @file
* Generic base styles for contextual module.
*/
.contextual-region {
position: relative;
}
.contextual .trigger:focus {
/* Override the .focusable position: static */
position: relative !important;
}
.contextual-links {
display: none;
}
.contextual.open .contextual-links {
display: block;
}

View file

@ -0,0 +1,112 @@
/**
* @file
* Styling for contextual module.
*/
/**
* Contextual links wrappers.
*/
.contextual {
position: absolute;
right: 0; /* LTR */
top: 6px;
z-index: 500;
}
[dir="rtl"] .contextual {
left: 0;
right: auto;
}
/**
* Contextual region.
*/
.contextual-region.focus {
outline: 1px dashed #d6d6d6;
outline-offset: 1px;
}
/**
* Contextual trigger.
*/
.contextual .trigger {
background-attachment: scroll;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 13px;
float: right; /* LTR */
margin: 0;
overflow: hidden;
padding: 0 2px;
position: relative;
right: 6px; /* LTR */
cursor: pointer;
}
[dir="rtl"] .contextual .trigger {
float: left;
right: auto;
left: 6px;
}
.contextual.open .trigger {
border: 1px solid #ccc;
border-bottom-color: transparent;
border-radius: 13px 13px 0 0;
box-shadow: none;
z-index: 2;
}
/**
* Contextual links.
*
* The following selectors are heavy to discourage theme overriding.
*/
.contextual-region .contextual .contextual-links {
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px 0 4px 4px; /* LTR */
clear: both;
float: right; /* LTR */
margin: 0;
padding: 0.25em 0;
position: relative;
right: 6px; /* LTR */
text-align: left; /* LTR */
top: -1px;
white-space: nowrap;
}
[dir="rtl"] .contextual-region .contextual .contextual-links {
border-radius: 0 4px 4px 4px;
float: left;
left: 6px;
right: auto;
text-align: right;
}
.contextual-region .contextual .contextual-links li {
background-color: #fff;
border: none;
list-style: none;
list-style-image: none;
margin: 0;
padding: 0;
line-height: 100%;
}
.contextual-region .contextual .contextual-links a {
background-color: #fff;
color: #333;
display: block;
font-family: sans-serif;
font-size: small;
line-height: 0.8em;
margin: 0.25em 0;
padding: 0.4em 0.6em;
}
.touchevents .contextual-region .contextual .contextual-links a {
font-size: large;
}
.contextual-region .contextual .contextual-links a,
.contextual-region .contextual .contextual-links a:hover {
text-decoration: none;
}
.no-touchevents .contextual-region .contextual .contextual-links li a:hover {
color: #000;
background: #f7fcff;
}

View file

@ -0,0 +1,24 @@
/**
* @file
* Styling for contextual module's toolbar tab.
*/
/* Tab appearance. */
.toolbar .toolbar-bar .contextual-toolbar-tab.toolbar-tab {
float: right; /* LTR */
}
[dir="rtl"] .toolbar .toolbar-bar .contextual-toolbar-tab.toolbar-tab {
float: left;
}
.toolbar .toolbar-bar .contextual-toolbar-tab .toolbar-item {
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%);
}
/* @todo get rid of this declaration by making toolbar.module's CSS less specific */
.toolbar .toolbar-bar .contextual-toolbar-tab.toolbar-tab.hidden {
display: none;
}

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

@ -0,0 +1,159 @@
/**
* 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) {
var options = $.extend(drupalSettings.contextual, {
strings: {
open: Drupal.t('Open'),
close: Drupal.t('Close')
}
});
var cachedPermissionsHash = storage.getItem('Drupal.contextual.permissionsHash');
var permissionsHash = drupalSettings.user.permissionsHash;
if (cachedPermissionsHash !== permissionsHash) {
if (typeof permissionsHash === 'string') {
_.chain(storage).keys().each(function (key) {
if (key.substring(0, 18) === 'Drupal.contextual.') {
storage.removeItem(key);
}
});
}
storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
}
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.html(html).addClass('contextual').prepend(Drupal.theme('contextualTrigger'));
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 ? '?' : '&';
this.setAttribute('href', url + glue + destination);
});
var model = new contextual.StateModel({
title: $region.find('h2').eq(0).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)));
contextual.collection.add(model);
$(document).trigger('drupalContextualLinkAdded', {
$el: $contextual,
$region: $region,
model: model
});
adjustIfNestedAndOverlapping($contextual);
}
Drupal.behaviors.contextual = {
attach: function attach(context) {
var $context = $(context);
var $placeholders = $context.find('[data-contextual-id]').once('contextual-render');
if ($placeholders.length === 0) {
return;
}
var ids = [];
$placeholders.each(function () {
ids.push({
id: $(this).attr('data-contextual-id'),
token: $(this).attr('data-contextual-token')
});
});
var uncachedIDs = [];
var uncachedTokens = [];
ids.forEach(function (contextualID) {
var html = storage.getItem('Drupal.contextual.' + contextualID.id);
if (html && html.length) {
window.setTimeout(function () {
initContextual($context.find('[data-contextual-id="' + contextualID.id + '"]'), html);
});
return;
}
uncachedIDs.push(contextualID.id);
uncachedTokens.push(contextualID.token);
});
if (uncachedIDs.length > 0) {
$.ajax({
url: Drupal.url('contextual/render'),
type: 'POST',
data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
dataType: 'json',
success: function success(results) {
_.each(results, function (html, contextualID) {
storage.setItem('Drupal.contextual.' + contextualID, html);
if (html.length > 0) {
$placeholders = $context.find('[data-contextual-id="' + contextualID + '"]');
for (var i = 0; i < $placeholders.length; i++) {
initContextual($placeholders.eq(i), html);
}
}
});
}
});
}
}
};
Drupal.contextual = {
views: [],
regionViews: []
};
Drupal.contextual.collection = new Backbone.Collection([], {
model: Drupal.contextual.StateModel
});
Drupal.theme.contextualTrigger = function () {
return '<button class="trigger visually-hidden focusable" type="button"></button>';
};
$(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

@ -0,0 +1,47 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, Backbone) {
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.')
};
function initContextualToolbar(context) {
if (!Drupal.contextual || !Drupal.contextual.collection) {
return;
}
var contextualToolbar = Drupal.contextualToolbar;
contextualToolbar.model = new contextualToolbar.StateModel({
isViewing: localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false'
}, {
contextualCollection: Drupal.contextual.collection
});
var viewOptions = {
el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'),
model: contextualToolbar.model,
strings: strings
};
new contextualToolbar.VisualView(viewOptions);
new contextualToolbar.AuralView(viewOptions);
}
Drupal.behaviors.contextualToolbar = {
attach: function attach(context) {
if ($('body').once('contextualToolbar-init').length) {
initContextualToolbar(context);
}
}
};
Drupal.contextualToolbar = {
model: null
};
})(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

@ -0,0 +1,51 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
Drupal.contextual.StateModel = Backbone.Model.extend({
defaults: {
title: '',
regionIsHovered: false,
hasFocus: false,
isOpen: false,
isLocked: false
},
toggleOpen: function toggleOpen() {
var newIsOpen = !this.get('isOpen');
this.set('isOpen', newIsOpen);
if (newIsOpen) {
this.focus();
}
return this;
},
close: function close() {
this.set('isOpen', false);
return this;
},
focus: function focus() {
this.set('hasFocus', true);
var cid = this.cid;
this.collection.each(function (model) {
if (model.cid !== cid) {
model.close().blur();
}
});
return this;
},
blur: function blur() {
if (!this.get('isOpen')) {
this.set('hasFocus', false);
}
return this;
}
});
})(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

@ -0,0 +1,44 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
Drupal.contextualToolbar.StateModel = Backbone.Model.extend({
defaults: {
isViewing: true,
isVisible: false,
contextualCount: 0,
tabbingContext: null
},
initialize: function initialize(attrs, options) {
this.listenTo(options.contextualCollection, 'reset remove add', this.countContextualLinks);
this.listenTo(options.contextualCollection, 'add', this.lockNewContextualLinks);
this.listenTo(this, 'change:contextualCount', this.updateVisibility);
this.listenTo(this, 'change:isViewing', function (model, isViewing) {
options.contextualCollection.each(function (contextualModel) {
contextualModel.set('isLocked', !isViewing);
});
});
},
countContextualLinks: function countContextualLinks(contextualModel, contextualCollection) {
this.set('contextualCount', contextualCollection.length);
},
lockNewContextualLinks: function lockNewContextualLinks(contextualModel, contextualCollection) {
if (!this.get('isViewing')) {
contextualModel.set('isLocked', true);
}
},
updateVisibility: function updateVisibility() {
this.set('isVisible', this.get('contextualCount') > 0);
}
});
})(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

@ -0,0 +1,62 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, Backbone, _) {
Drupal.contextualToolbar.AuralView = Backbone.View.extend({
announcedOnce: false,
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();
},
render: function render() {
this.$el.find('button').attr('aria-pressed', !this.model.get('isViewing'));
return this;
},
manageTabbing: function manageTabbing() {
var tabbingContext = this.model.get('tabbingContext');
if (tabbingContext) {
if (tabbingContext.active) {
Drupal.announce(this.options.strings.tabbingReleased);
}
tabbingContext.release();
}
if (!this.model.get('isViewing')) {
tabbingContext = Drupal.tabbingManager.constrain($('.contextual-toolbar-tab, .contextual'));
this.model.set('tabbingContext', tabbingContext);
this.announceTabbingConstraint();
this.announcedOnce = true;
}
},
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);
},
onKeypress: function onKeypress(event) {
if (!this.announcedOnce && event.keyCode === 9 && !this.model.get('isViewing')) {
this.announceTabbingConstraint();
this.announcedOnce = true;
}
if (event.keyCode === 27) {
this.model.set('isViewing', true);
}
}
});
})(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

@ -0,0 +1,43 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
Drupal.contextualToolbar.VisualView = Backbone.View.extend({
events: function events() {
var touchEndToClick = function touchEndToClick(event) {
event.preventDefault();
event.target.click();
};
return {
click: function click() {
this.model.set('isViewing', !this.model.get('isViewing'));
},
touchend: touchEndToClick
};
},
initialize: function initialize() {
this.listenTo(this.model, 'change', this.render);
this.listenTo(this.model, 'change:isViewing', this.persist);
},
render: function render() {
this.$el.toggleClass('hidden', !this.model.get('isVisible'));
this.$el.find('button').toggleClass('is-active', !this.model.get('isViewing'));
return this;
},
persist: function persist(model, isViewing) {
if (!isViewing) {
localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false');
} else {
localStorage.removeItem('Drupal.contextualToolbar.isViewing');
}
}
});
})(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

@ -0,0 +1,30 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
Drupal.contextual.AuralView = Backbone.View.extend({
initialize: function initialize(options) {
this.options = options;
this.listenTo(this.model, 'change', this.render);
this.$el.attr('role', 'form');
this.render();
},
render: function render() {
var isOpen = this.model.get('isOpen');
this.$el.find('.contextual-links').prop('hidden', !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);

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

@ -0,0 +1,32 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone) {
Drupal.contextual.KeyboardView = Backbone.View.extend({
events: {
'focus .trigger': 'focus',
'focus .contextual-links a': 'focus',
'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();
}, 150);
}
},
initialize: function initialize() {
this.timer = NaN;
},
focus: function focus() {
window.clearTimeout(this.timer);
this.model.focus();
}
});
})(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

@ -0,0 +1,34 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone, Modernizr) {
Drupal.contextual.RegionView = Backbone.View.extend({
events: function events() {
var mapping = {
mouseenter: function mouseenter() {
this.model.set('regionIsHovered', true);
},
mouseleave: function mouseleave() {
this.model.close().blur().set('regionIsHovered', false);
}
};
if (Modernizr.touchevents) {
mapping = {};
}
return mapping;
},
initialize: function initialize() {
this.listenTo(this.model, 'change:hasFocus', this.render);
},
render: function render() {
this.$el.toggleClass('focus', this.model.get('hasFocus'));
return this;
}
});
})(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

@ -0,0 +1,50 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone, Modernizr) {
Drupal.contextual.VisualView = Backbone.View.extend({
events: function events() {
var touchEndToClick = function touchEndToClick(event) {
event.preventDefault();
event.target.click();
};
var mapping = {
'click .trigger': function clickTrigger() {
this.model.toggleOpen();
},
'touchend .trigger': touchEndToClick,
'click .contextual-links a': function clickContextualLinksA() {
this.model.close().blur();
},
'touchend .contextual-links a': touchEndToClick
};
if (!Modernizr.touchevents) {
mapping.mouseenter = function () {
this.model.focus();
};
}
return mapping;
},
initialize: function initialize() {
this.listenTo(this.model, 'change', this.render);
},
render: function render() {
var isOpen = this.model.get('isOpen');
var isVisible = this.model.get('isLocked') || this.model.get('regionIsHovered') || isOpen;
this.$el.toggleClass('open', isOpen).find('.trigger').toggleClass('visually-hidden', !isVisible);
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

@ -0,0 +1,88 @@
<?php
namespace Drupal\contextual;
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;
/**
* Returns responses for Contextual module routes.
*/
class ContextualController implements ContainerInjectionInterface {
/**
* 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.
*
* Given a list of contextual links IDs, render them. Hence this must be
* robust to handle arbitrary input.
*
* @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');
if (!isset($ids)) {
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 $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->renderer->renderRoot($element);
}
return new JsonResponse($rendered);
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace Drupal\contextual\Element;
use Drupal\Component\Utility\Html;
use Drupal\Core\Render\Element\RenderElement;
use Drupal\Core\Url;
/**
* Provides a contextual_links element.
*
* @RenderElement("contextual_links")
*/
class ContextualLinks extends RenderElement {
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = get_class($this);
return [
'#pre_render' => [
[$class, 'preRenderLinks'],
],
'#theme' => 'links__contextual',
'#links' => [],
'#attributes' => ['class' => ['contextual-links']],
'#attached' => [
'library' => [
'contextual/drupal.contextual-links',
],
],
];
}
/**
* Pre-render callback: Builds a renderable array for contextual links.
*
* @param array $element
* A renderable array containing a #contextual_links property, which is a
* keyed array. Each key is the name of the group of contextual links to
* render (based on the 'group' key in the *.links.contextual.yml files for
* all enabled modules). The value contains an associative array containing
* the following keys:
* - route_parameters: The route parameters passed to the url generator.
* - metadata: Any additional data needed in order to alter the link.
* @code
* array('#contextual_links' => array(
* 'block' => array(
* 'route_parameters' => array('block' => 'system.menu-tools'),
* ),
* 'menu' => array(
* 'route_parameters' => array('menu' => 'tools'),
* ),
* ))
* @endcode
*
* @return array
* A renderable array representing contextual links.
*/
public static function preRenderLinks(array $element) {
// Retrieve contextual menu links.
$items = [];
$contextual_links_manager = static::contextualLinkManager();
foreach ($element['#contextual_links'] as $group => $args) {
$args += [
'route_parameters' => [],
'metadata' => [],
];
$items += $contextual_links_manager->getContextualLinksArrayByGroup($group, $args['route_parameters'], $args['metadata']);
}
// Transform contextual links into parameters suitable for links.html.twig.
$links = [];
foreach ($items as $class => $item) {
$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'] : [], $item['localized_options']),
];
}
$element['#links'] = $links;
// Allow modules to alter the renderable contextual links element.
static::moduleHandler()->alter('contextual_links_view', $element, $items);
// If there are no links, tell drupal_render() to abort rendering.
if (empty($element['#links'])) {
$element['#printed'] = TRUE;
}
return $element;
}
/**
* Wraps the contextual link manager.
*
* @return \Drupal\Core\Menu\ContextualLinkManager
*/
protected static function contextualLinkManager() {
return \Drupal::service('plugin.manager.menu.contextual_link');
}
/**
* Wraps the module handler.
*
* @return \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected static function moduleHandler() {
return \Drupal::moduleHandler();
}
}

View file

@ -0,0 +1,58 @@
<?php
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\Render\FormattableMarkup;
/**
* Provides a contextual_links_placeholder element.
*
* @RenderElement("contextual_links_placeholder")
*/
class ContextualLinksPlaceholder extends RenderElement {
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = get_class($this);
return [
'#pre_render' => [
[$class, 'preRenderPlaceholder'],
],
'#id' => NULL,
];
}
/**
* Pre-render callback: Renders a contextual links placeholder into #markup.
*
* Renders an empty (hence invisible) placeholder div with a data-attribute
* that contains an identifier ("contextual id"), which allows the JavaScript
* of the drupal.contextual-links library to dynamically render contextual
* links.
*
* @param array $element
* A structured array with #id containing a "contextual id".
*
* @return array
* The passed-in element with a contextual link placeholder in '#markup'.
*
* @see _contextual_links_to_id()
*/
public static function preRenderPlaceholder(array $element) {
$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

@ -0,0 +1,153 @@
<?php
namespace Drupal\contextual\Plugin\views\field;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RedirectDestinationTrait;
use Drupal\Core\Url;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
/**
* Provides a handler that adds contextual links.
*
* @ingroup views_field_handlers
*
* @ViewsField("contextual_links")
*/
class ContextualLinks extends FieldPluginBase {
use RedirectDestinationTrait;
/**
* {@inheritdoc}
*/
public function usesGroupBy() {
return FALSE;
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['fields'] = ['default' => []];
$options['destination'] = ['default' => 1];
return $options;
}
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
$all_fields = $this->view->display_handler->getFieldLabels();
// Offer to include only those fields that follow this one.
$field_options = array_slice($all_fields, 0, array_search($this->options['id'], array_keys($all_fields)));
$form['fields'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Fields'),
'#description' => $this->t('Fields to be included as contextual links.'),
'#options' => $field_options,
'#default_value' => $this->options['fields'],
];
$form['destination'] = [
'#type' => 'select',
'#title' => $this->t('Include destination'),
'#description' => $this->t('Include a "destination" parameter in the link to return the user to the original view upon completing the contextual action.'),
'#options' => [
'0' => $this->t('No'),
'1' => $this->t('Yes'),
],
'#default_value' => $this->options['destination'],
];
}
/**
* {@inheritdoc}
*/
public function preRender(&$values) {
// Add a row plugin css class for the contextual link.
$class = 'contextual-region';
if (!empty($this->view->style_plugin->options['row_class'])) {
$this->view->style_plugin->options['row_class'] .= " $class";
}
else {
$this->view->style_plugin->options['row_class'] = $class;
}
}
/**
* Overrides \Drupal\views\Plugin\views\field\FieldPluginBase::render().
*
* Renders the contextual fields.
*
* @param \Drupal\views\ResultRow $values
* The values retrieved from a single row of a view's query result.
*
* @see contextual_preprocess()
* @see contextual_contextual_links_view_alter()
*/
public function render(ResultRow $values) {
$links = [];
foreach ($this->options['fields'] as $field) {
$rendered_field = $this->view->style_plugin->getField($values->index, $field);
if (empty($rendered_field)) {
continue;
}
$title = $this->view->field[$field]->last_render_text;
$path = '';
if (!empty($this->view->field[$field]->options['alter']['path'])) {
$path = $this->view->field[$field]->options['alter']['path'];
}
elseif (!empty($this->view->field[$field]->options['alter']['url']) && $this->view->field[$field]->options['alter']['url'] instanceof Url) {
$path = $this->view->field[$field]->options['alter']['url']->toString();
}
if (!empty($title) && !empty($path)) {
// Make sure that tokens are replaced for this paths as well.
$tokens = $this->getRenderTokens([]);
$path = strip_tags(Html::decodeEntities(strtr($path, $tokens)));
$links[$field] = [
'href' => $path,
'title' => $title,
];
if (!empty($this->options['destination'])) {
$links[$field]['query'] = $this->getDestinationArray();
}
}
}
// Renders a contextual links placeholder.
if (!empty($links)) {
$contextual_links = [
'contextual' => [
'',
[],
[
'contextual-views-field-links' => UrlHelper::encodePath(Json::encode($links)),
],
],
];
$element = [
'#type' => 'contextual_links_placeholder',
'#id' => _contextual_links_to_id($contextual_links),
];
return \Drupal::service('renderer')->render($element);
}
else {
return '';
}
}
/**
* {@inheritdoc}
*/
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

@ -0,0 +1,267 @@
<?php
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\Tests\BrowserTestBase;
/**
* Tests if contextual links are showing on the front page depending on
* permissions.
*
* @group contextual
*/
class ContextualDynamicContextTest extends BrowserTestBase {
/**
* A user with permission to access contextual links and edit content.
*
* @var \Drupal\user\UserInterface
*/
protected $editorUser;
/**
* An authenticated user with permission to access contextual links.
*
* @var \Drupal\user\UserInterface
*/
protected $authenticatedUser;
/**
* A simulated anonymous user with access only to node content.
*
* @var \Drupal\user\UserInterface
*/
protected $anonymousUser;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['contextual', 'node', 'views', 'views_ui', 'language', 'menu_test'];
protected function setUp() {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
ConfigurableLanguage::createFromLangcode('it')->save();
$this->rebuildContainer();
$this->editorUser = $this->drupalCreateUser(['access content', 'access contextual links', 'edit any article content']);
$this->authenticatedUser = $this->drupalCreateUser(['access content', 'access contextual links']);
$this->anonymousUser = $this->drupalCreateUser(['access content']);
}
/**
* Tests contextual links with different permissions.
*
* Ensures that contextual link placeholders always exist, even if the user is
* not allowed to use contextual links.
*/
public function testDifferentPermissions() {
$this->drupalLogin($this->editorUser);
// Create three nodes in the following order:
// - An article, which should be user-editable.
// - A page, which should not be user-editable.
// - A second article, which should also be user-editable.
$node1 = $this->drupalCreateNode(['type' => 'article', 'promote' => 1]);
$node2 = $this->drupalCreateNode(['type' => 'page', 'promote' => 1]);
$node3 = $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.
$ids = [
'node:node=' . $node1->id() . ':changed=' . $node1->getChangedTime() . '&langcode=en',
'node:node=' . $node2->id() . ':changed=' . $node2->getChangedTime() . '&langcode=en',
'node:node=' . $node3->id() . ':changed=' . $node3->getChangedTime() . '&langcode=en',
'entity.view.edit_form:view=frontpage:location=page&name=frontpage&display_id=page_1&langcode=en',
];
// Editor user: can access contextual links and can edit articles.
$this->drupalGet('node');
for ($i = 0; $i < count($ids); $i++) {
$this->assertContextualLinkPlaceHolder($ids[$i]);
}
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(400, $response->getStatusCode());
$this->assertContains('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$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>');
$this->assertIdentical($json[$ids[3]], '');
// Verify that link language is properly handled.
$node3->addTranslation('it')->set('title', $this->randomString())->save();
$id = 'node:node=' . $node3->id() . ':changed=' . $node3->getChangedTime() . '&langcode=it';
$this->drupalGet('node', ['language' => ConfigurableLanguage::createFromLangcode('it')]);
$this->assertContextualLinkPlaceHolder($id);
// Authenticated user: can access contextual links, cannot edit articles.
$this->drupalLogin($this->authenticatedUser);
$this->drupalGet('node');
for ($i = 0; $i < count($ids); $i++) {
$this->assertContextualLinkPlaceHolder($ids[$i]);
}
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(400, $response->getStatusCode());
$this->assertContains('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$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]], '');
$this->assertIdentical($json[$ids[3]], '');
// Anonymous user: cannot access contextual links.
$this->drupalLogin($this->anonymousUser);
$this->drupalGet('node');
for ($i = 0; $i < count($ids); $i++) {
$this->assertNoContextualLinkPlaceHolder($ids[$i]);
}
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(403, $response->getStatusCode());
$this->renderContextualLinks($ids, 'node');
$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->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());
}
/**
* Asserts that a contextual link placeholder with the given id exists.
*
* @param string $id
* A contextual link id.
*/
protected function assertContextualLinkPlaceHolder($id) {
$this->assertSession()->elementAttributeContains(
'css',
'div[data-contextual-id="' . $id . '"]',
'data-contextual-token',
$this->createContextualIdToken($id)
);
}
/**
* Asserts that a contextual link placeholder with the given id does not exist.
*
* @param string $id
* A contextual link id.
*/
protected function assertNoContextualLinkPlaceHolder($id) {
$this->assertSession()->elementNotExists('css', 'div[data-contextual-id="' . $id . '"]');
}
/**
* Get server-rendered contextual links for the given contextual link ids.
*
* @param array $ids
* An array of contextual link ids.
* @param string $current_path
* The Drupal path for the page for which the contextual links are rendered.
*
* @return \Psr\Http\Message\ResponseInterface
* The response object.
*/
protected function renderContextualLinks($ids, $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

@ -0,0 +1,110 @@
<?php
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\user\Entity\Role;
/**
* Tests the UI for correct contextual links.
*
* @group contextual
*/
class ContextualLinksTest extends WebDriverTestBase {
use ContextualLinkClickTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['block', 'contextual'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalLogin($this->createUser(['access contextual links']));
$this->placeBlock('system_branding_block', ['id' => 'branding']);
}
/**
* Tests the visibility of contextual links.
*/
public function testContextualLinksVisibility() {
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertEmpty($contextualLinks);
// Ensure visibility remains correct after cached paged load.
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertEmpty($contextualLinks);
// Grant permissions to use contextual links on blocks.
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), [
'access contextual links',
'administer blocks',
]);
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertNotEmpty($contextualLinks);
// Ensure visibility remains correct after cached paged load.
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$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

@ -0,0 +1,130 @@
<?php
namespace Drupal\Tests\contextual\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests all edge cases of converting from #contextual_links to ids and vice
* versa.
*
* @group contextual
*/
class ContextualUnitTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['contextual'];
/**
* Provides testcases for testContextualLinksToId() and
*/
public function _contextual_links_id_testcases() {
// Test branch conditions:
// - one group.
// - one dynamic path argument.
// - no metadata.
$tests[] = [
'links' => [
'node' => [
'route_parameters' => [
'node' => '14031991',
],
'metadata' => ['langcode' => 'en'],
],
],
'id' => 'node:node=14031991:langcode=en',
];
// Test branch conditions:
// - one group.
// - multiple dynamic path arguments.
// - no metadata.
$tests[] = [
'links' => [
'foo' => [
'route_parameters' => [
'bar',
'key' => 'baz',
'qux',
],
'metadata' => ['langcode' => 'en'],
],
],
'id' => 'foo:0=bar&key=baz&1=qux:langcode=en',
];
// Test branch conditions:
// - one group.
// - one dynamic path argument.
// - metadata.
$tests[] = [
'links' => [
'views_ui_edit' => [
'route_parameters' => [
'view' => 'frontpage',
],
'metadata' => [
'location' => 'page',
'display' => 'page_1',
'langcode' => 'en',
],
],
],
'id' => 'views_ui_edit:view=frontpage:location=page&display=page_1&langcode=en',
];
// Test branch conditions:
// - multiple groups.
// - multiple dynamic path arguments.
$tests[] = [
'links' => [
'node' => [
'route_parameters' => [
'node' => '14031991',
],
'metadata' => ['langcode' => 'en'],
],
'foo' => [
'route_parameters' => [
'bar',
'key' => 'baz',
'qux',
],
'metadata' => ['langcode' => 'en'],
],
'edge' => [
'route_parameters' => ['20011988'],
'metadata' => ['langcode' => 'en'],
],
],
'id' => 'node:node=14031991:langcode=en|foo:0=bar&key=baz&1=qux:langcode=en|edge:0=20011988:langcode=en',
];
return $tests;
}
/**
* Tests _contextual_links_to_id().
*/
public function testContextualLinksToId() {
$tests = $this->_contextual_links_id_testcases();
foreach ($tests as $test) {
$this->assertIdentical(_contextual_links_to_id($test['links']), $test['id']);
}
}
/**
* Tests _contextual_id_to_links().
*/
public function testContextualIdToLinks() {
$tests = $this->_contextual_links_id_testcases();
foreach ($tests as $test) {
$this->assertIdentical(_contextual_id_to_links($test['id']), $test['links']);
}
}
}