first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
<?php
/**
* @file
* Hooks provided by Contextual module.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Alter a contextual links element before it is rendered.
*
* This hook is invoked by
* \Drupal\contextual\Element\ContextualLinks::preRenderLinks(). 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 \Drupal\contextual\Element\ContextualLinks::preRenderLinks()
*/
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,10 @@
name: 'Contextual Links'
type: module
description: 'Provides contextual links to directly access tasks related to page elements.'
package: Core
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,49 @@
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
# @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920
- core/internal.backbone
- core/once
- core/drupal.touchevents-test
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
# @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920
- core/internal.backbone
- core/once
- core/drupal.tabbingmanager
- core/drupal.announce

View File

@@ -0,0 +1,221 @@
<?php
/**
* @file
* Adds contextual links to perform actions related to elements on a page.
*/
use Drupal\Core\Url;
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 .= '<h2>' . t('About') . '</h2>';
$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/docs/8/core/modules/contextual']) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$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> installed, 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')) ? Url::fromRoute('help.page', ['name' => 'toolbar'])->toString() : '#']) . '</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 \Drupal\contextual\Element\ContextualLinksPlaceholder
* @see contextual_page_attachments()
* @see \Drupal\contextual\ContextualController::render()
*/
function contextual_preprocess(&$variables, $hook, $info) {
// 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'])) {
$variables['#cache']['contexts'][] = 'user.permissions';
if (\Drupal::currentUser()->hasPermission('access 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=olivero.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) {
[$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,15 @@
<?php
/**
* @file
* Post update functions for Contextual Links.
*/
/**
* Implements hook_removed_post_updates().
*/
function contextual_removed_post_updates() {
return [
'contextual_post_update_fixed_endpoint_and_markup' => '9.0.0',
];
}

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,49 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
/**
* @file
* Styling for contextual module icons.
*/
/**
* Toolbar tab icon.
*/
.toolbar-bar .toolbar-icon-edit::before {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23bebebe' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23bebebe' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23bebebe' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}
.toolbar-bar .toolbar-icon-edit:active::before,
.toolbar-bar .toolbar-icon-edit.is-active::before {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23ffffff' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23ffffff' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23ffffff' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}
/**
* Contextual trigger.
*/
.contextual .trigger {
/* Override the .focusable height: auto */
width: 26px !important;
/* Override the .focusable height: auto */
height: 26px !important;
text-indent: -9999px;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23bebebe' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23bebebe' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23bebebe' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: center center;
background-size: 16px 16px;
}
.contextual .trigger:hover {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23787878' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23787878' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23787878' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}
.contextual .trigger:focus {
outline: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%235181C6' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%235181C6' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%235181C6' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}

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 {
/* Override the .focusable height: auto */
width: 26px !important;
/* Override the .focusable height: auto */
height: 26px !important;
text-indent: -9999px;
background-image: url(../../../misc/icons/bebebe/pencil.svg);
background-repeat: no-repeat;
background-position: center center;
background-size: 16px 16px;
}
.contextual .trigger:hover {
background-image: url(../../../misc/icons/787878/pencil.svg);
}
.contextual .trigger:focus {
outline: none;
background-image: url(../../../misc/icons/5181c6/pencil.svg);
}

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,113 @@
/**
* @file
* Styling for contextual module.
*/
/**
* Contextual links wrappers.
*/
.contextual {
position: absolute;
z-index: 500;
top: 6px;
right: 0; /* LTR */
}
[dir="rtl"] .contextual {
right: auto;
left: 0;
}
/**
* Contextual region.
*/
.contextual-region.focus {
outline: 1px dashed #d6d6d6;
outline-offset: 1px;
}
/**
* Contextual trigger.
*/
.contextual .trigger {
position: relative;
right: 6px; /* LTR */
float: right; /* LTR */
overflow: hidden;
margin: 0;
padding: 0 2px;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 13px;
background-color: #fff;
background-attachment: scroll;
}
[dir="rtl"] .contextual .trigger {
right: auto;
left: 6px;
float: left;
}
.contextual.open .trigger {
z-index: 2;
border: 1px solid #ccc;
border-bottom-color: transparent;
border-radius: 13px 13px 0 0;
box-shadow: none;
}
/**
* Contextual links.
*
* The following selectors are heavy to discourage theme overriding.
*/
.contextual-region .contextual .contextual-links {
position: relative;
top: -1px;
right: 6px; /* LTR */
float: right; /* LTR */
clear: both;
margin: 0;
padding: 0.25em 0;
text-align: left; /* LTR */
white-space: nowrap;
border: 1px solid #ccc;
border-radius: 4px 0 4px 4px; /* LTR */
background-color: #fff;
}
[dir="rtl"] .contextual-region .contextual .contextual-links {
right: auto;
left: 6px;
float: left;
text-align: right;
border-radius: 0 4px 4px 4px;
}
.contextual-region .contextual .contextual-links li {
margin: 0;
padding: 0;
list-style: none;
list-style-image: none;
border: none;
background-color: #fff;
line-height: 100%;
}
.contextual-region .contextual .contextual-links a {
display: block;
margin: 0.25em 0;
padding: 0.4em 0.6em;
color: #333;
background-color: #fff;
font-family: sans-serif;
font-size: small;
font-weight: normal;
line-height: 0.8em;
}
.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,23 @@
/**
* @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: 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,19 @@
---
label: 'Using contextual links'
related:
- core.ui_components
- block.overview
---
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Use contextual links to access administrative tasks without navigating the administrative menu.{% endtrans %}</p>
<h2>{% trans %}What are contextual links?{% endtrans %}</h2>
<p>{% trans %}<em>Contextual links</em> give users with the <em>Use contextual links</em> permission quick access to administrative tasks related to areas of non-administrative pages. For example, if a page on your site displays a block, the block would have a contextual link that would allow users with permission to configure the block. If the block contains a menu or a view, it would also have a contextual link for editing the menu links or the view. Clicking a contextual link takes you to the related administrative page directly, without needing to navigate through the administrative menu system.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}Make sure that the core Contextual Links module is installed, and that you have a role with the <em>Use contextual links</em> permission. Optionally, make sure that a toolbar module is installed (either the core Toolbar module or a contributed module replacement).{% endtrans %}</li>
<li>{% trans %}Visit a non-administrative page on your site, such as the home page.{% endtrans %}</li>
<li>{% trans %}Locate a block or another area on the page that you want to edit or configure.{% endtrans %}</li>
<li>{% trans %}Make the contextual links button visible by hovering your mouse over that area in the page. In most themes, this button looks like a pencil and is placed in the upper right corner of the page area (upper left for right-to-left languages), and hovering will also temporarily outline the affected area. Alternatively, click the contextual links toggle button on the right end of the toolbar (left end for right-to-left languages), which will make all contextual link buttons on the page visible until it is clicked again.{% endtrans %}</li>
<li>{% trans %}While the contextual links button for the area of interest is visible, click the button to display the list of links for that area. Click a link in the list to visit the corresponding administrative page.{% endtrans %}</li>
<li>{% trans %}Complete your administrative task and save your settings, or cancel the action. You should be returned to the page you started from.{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,322 @@
/**
* @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.startsWith('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[0].style.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 + window.location.search),
)}`;
$contextual.find('.contextual-links a').each(function () {
const url = this.getAttribute('href');
const glue = url.includes('?') ? '&' : '?';
this.setAttribute('href', url + glue + destination);
});
let title = '';
const $regionHeading = $region.find('h2');
if ($regionHeading.length) {
title = $regionHeading[0].textContent.trim();
}
// Create a model and the appropriate views.
const model = new contextual.StateModel({
title,
});
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',
Drupal.deprecatedProperty({
target: {
$el: $contextual,
$region,
model,
},
deprecatedProperty: 'model',
message:
'The model property is deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no replacement.',
}),
);
// 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 = $(
once('contextual-render', '[data-contextual-id]', context),
);
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}"]:empty`)
.eq(0),
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
*
* @private
*/
Drupal.contextual = {
/**
* The {@link Drupal.contextual.View} instances associated with each list
* element of contextual links.
*
* @type {Array}
*
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
views: [],
/**
* The {@link Drupal.contextual.RegionView} instances associated with each
* contextual region element.
*
* @type {Array}
*
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
regionViews: [],
};
/**
* A Backbone.Collection of {@link Drupal.contextual.StateModel} instances.
*
* @type {Backbone.Collection}
*
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
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,87 @@
/**
* @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:
document.querySelector('body .contextual-region') === null ||
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 (once('contextualToolbar-init', 'body').length) {
initContextualToolbar(context);
}
},
};
/**
* Namespace for the contextual toolbar.
*
* @namespace
*
* @private
*/
Drupal.contextualToolbar = {
/**
* The {@link Drupal.contextualToolbar.StateModel} instance.
*
* @type {?Drupal.contextualToolbar.StateModel}
*
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is
* no replacement.
*/
model: null,
};
})(jQuery, Drupal, Backbone);

View File

@@ -0,0 +1,130 @@
/**
* @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
*
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
Drupal.contextual.StateModel = Backbone.Model.extend(
/** @lends Drupal.contextual.StateModel# */ {
/**
* @type {object}
*
* @prop {string} title
* @prop {boolean} regionIsHovered
* @prop {boolean} hasFocus
* @prop {boolean} isOpen
* @prop {boolean} 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 {boolean}
*/
regionIsHovered: false,
/**
* Represents if the contextual trigger or options have focus.
*
* @type {boolean}
*/
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 {boolean}
*/
isOpen: false,
/**
* When the model is locked, the trigger remains active.
*
* @type {boolean}
*/
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,126 @@
/**
* @file
* A Backbone Model for the state of Contextual module's edit toolbar tab.
*/
(function (Drupal, Backbone) {
/**
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
Drupal.contextualToolbar.StateModel = Backbone.Model.extend(
/** @lends Drupal.contextualToolbar.StateModel# */ {
/**
* @type {object}
*
* @prop {boolean} isViewing
* @prop {boolean} 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 {boolean}
*/
isViewing: true,
/**
* Indicates whether the toggle should be visible or hidden. Automatically
* calculated, depends on contextualCount.
*
* @type {boolean}
*/
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,122 @@
/**
* @file
* A Backbone View that provides the aural view of the edit mode toggle.
*/
(function ($, Drupal, Backbone, _) {
/**
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
Drupal.contextualToolbar.AuralView = Backbone.View.extend(
/** @lends Drupal.contextualToolbar.AuralView# */ {
/**
* Tracks whether the tabbing constraint announcement has been read once.
*
* @type {boolean}
*/
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,85 @@
/**
* @file
* A Backbone View that provides the visual view of the edit mode toggle.
*/
(function (Drupal, Backbone) {
/**
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
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 {boolean} 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,59 @@
/**
* @file
* A Backbone View that provides the aural view of a contextual link.
*/
(function (Drupal, Backbone) {
/**
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
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);
// 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.
const $trigger = this.$el.find('.trigger');
$trigger
.each((index, element) => {
element.textContent = 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,62 @@
/**
* @file
* A Backbone View that provides keyboard interaction for a contextual link.
*/
(function (Drupal, Backbone) {
/**
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
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,75 @@
/**
* @file
* A Backbone View that renders the visual view of a contextual region element.
*/
(function (Drupal, Backbone) {
/**
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
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() {
// Used for tracking the presence of touch events. When true, the
// mousemove and mouseenter event handlers are effectively disabled.
// This is used instead of preventDefault() on touchstart as some
// touchstart events are not cancelable.
let touchStart = false;
return {
touchstart() {
// Set to true so the mouseenter and mouseleave events that follow
// know to not execute any hover related logic.
touchStart = true;
},
mouseenter() {
if (!touchStart) {
this.model.set('regionIsHovered', true);
}
},
mouseleave() {
if (!touchStart) {
this.model.close().blur().set('regionIsHovered', false);
}
},
mousemove() {
// Because there are scenarios where there are both touchscreens
// and pointer devices, the touchStart flag should be set back to
// false after mouseenter and mouseleave complete. It will be set to
// true if another touchstart event occurs.
touchStart = false;
},
};
},
/**
* 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);

View File

@@ -0,0 +1,109 @@
/**
* @file
* A Backbone View that provides the visual view of a contextual link.
*/
(function (Drupal, Backbone) {
/**
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
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();
};
// Used for tracking the presence of touch events. When true, the
// mousemove and mouseenter event handlers are effectively disabled.
// This is used instead of preventDefault() on touchstart as some
// touchstart events are not cancelable.
let touchStart = false;
return {
touchstart() {
// Set to true so the mouseenter events that follows knows to not
// execute any hover related logic.
touchStart = true;
},
mouseenter() {
// We only want mouse hover events on non-touch.
if (!touchStart) {
this.model.focus();
}
},
mousemove() {
// Because there are scenarios where there are both touchscreens
// and pointer devices, the touchStart flag should be set back to
// false after mouseenter and mouseleave complete. It will be set to
// true if another touchstart event occurs.
touchStart = false;
},
'click .trigger': function () {
this.model.toggleOpen();
},
'touchend .trigger': touchEndToClick,
'click .contextual-links a': function () {
this.model.close().blur();
},
'touchend .contextual-links a': touchEndToClick,
};
},
/**
* 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);

View File

@@ -0,0 +1,90 @@
<?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.
*
* @internal
*
* @see contextual_preprocess()
*/
public function render(Request $request) {
if (!$request->request->has('ids')) {
throw new BadRequestHttpException('No contextual ids specified.');
}
$ids = $request->request->all('ids');
if (!$request->request->has('tokens')) {
throw new BadRequestHttpException('No contextual ID tokens specified.');
}
$tokens = $request->request->all('tokens');
$rendered = [];
foreach ($ids as $key => $id) {
if (!isset($tokens[$key]) || !hash_equals($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,119 @@
<?php
namespace Drupal\contextual\Element;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\SortArray;
use Drupal\Core\Render\Attribute\RenderElement;
use Drupal\Core\Render\Element\RenderElementBase;
use Drupal\Core\Url;
/**
* Provides a contextual_links element.
*/
#[RenderElement('contextual_links')]
class ContextualLinks extends RenderElementBase {
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = static::class;
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
* ['#contextual_links' => [
* 'block' => [
* 'route_parameters' => ['block' => 'system.menu-tools'],
* ],
* 'menu' => [
* 'route_parameters' => ['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']);
}
uasort($items, [SortArray::class, 'sortByWeightElement']);
// 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($item['route_name'] ?? '', $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::service('renderer')->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,59 @@
<?php
namespace Drupal\contextual\Element;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Site\Settings;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Render\Attribute\RenderElement;
use Drupal\Core\Render\Element\RenderElementBase;
use Drupal\Component\Render\FormattableMarkup;
/**
* Provides a contextual_links_placeholder element.
*/
#[RenderElement('contextual_links_placeholder')]
class ContextualLinksPlaceholder extends RenderElementBase {
/**
* {@inheritdoc}
*/
public function getInfo() {
$class = static::class;
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,
'data-drupal-ajax-container' => '',
]);
$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\Attribute\ViewsField;
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,12 @@
name: 'Contextual Test'
type: module
description: 'Provides test contextual links.'
package: Testing
# version: VERSION
dependencies:
- drupal:contextual
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

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,299 @@
<?php
declare(strict_types=1);
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 contextual link display on the front page based on permissions.
*
* @group contextual
*/
class ContextualDynamicContextTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* 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
*/
protected static $modules = [
'contextual',
'node',
'views',
'views_ui',
'language',
'menu_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
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(): void {
$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->assertStringContainsString('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$this->assertSame(200, $response->getStatusCode());
$json = Json::decode((string) $response->getBody());
$this->assertSame('<ul class="contextual-links"><li><a href="' . base_path() . 'node/1/edit">Edit</a></li></ul>', $json[$ids[0]]);
$this->assertSame('', $json[$ids[1]]);
$this->assertSame('<ul class="contextual-links"><li><a href="' . base_path() . 'node/3/edit">Edit</a></li></ul>', $json[$ids[2]]);
$this->assertSame('', $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->assertStringContainsString('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$this->assertSame(200, $response->getStatusCode());
$json = Json::decode((string) $response->getBody());
$this->assertSame('', $json[$ids[0]]);
$this->assertSame('', $json[$ids[1]]);
$this->assertSame('', $json[$ids[2]]);
$this->assertSame('', $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->assertSession()->assertEscaped("<script>alert('Welcome to the jungle!')</script>");
$this->assertSession()->responseContains('<li><a href="' . base_path() . 'menu-test-contextual/1/edit" class="use-ajax" data-dialog-type="modal" data-is-something>Edit menu - contextual</a></li>');
// Test contextual links respects the weight set in *.links.contextual.yml.
$firstLink = $this->assertSession()->elementExists('css', 'ul.contextual-links li:nth-of-type(1) a');
$secondLink = $this->assertSession()->elementExists('css', 'ul.contextual-links li:nth-of-type(2) a');
$this->assertEquals(base_path() . 'menu-test-contextual/1/edit', $firstLink->getAttribute('href'));
$this->assertEquals(base_path() . 'menu-test-contextual/1', $secondLink->getAttribute('href'));
}
/**
* Tests the contextual placeholder content is protected by a token.
*/
public function testTokenProtection(): void {
$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->assertStringContainsString('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->assertStringContainsString('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->assertStringContainsString('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.
*
* @internal
*/
protected function assertContextualLinkPlaceHolder(string $id): void {
$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.
*
* @internal
*/
protected function assertNoContextualLinkPlaceHolder(string $id): void {
$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,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for contextual.
*
* @group contextual
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
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,155 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\Core\Url;
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}
*/
protected static $modules = ['block', 'contextual'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
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(): void {
$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);
// Confirm touchevents detection is loaded with Contextual Links
$this->assertSession()->elementExists('css', 'html.no-touchevents');
// Ensure visibility remains correct after cached paged load.
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertNotEmpty($contextualLinks);
}
/**
* Tests clicking contextual links.
*/
public function testContextualLinksClick(): void {
$this->container->get('module_installer')->install(['contextual_test']);
// Test clicking contextual link without toolbar.
$this->drupalGet('user');
$this->clickContextualLink('#block-branding', 'Test Link');
$this->assertSession()->pageTextContains('Everything is contextual!');
// Test click a contextual link that uses ajax.
$this->drupalGet('user');
$current_page_string = 'NOT_RELOADED_IF_ON_PAGE';
$this->getSession()->executeScript('document.body.appendChild(document.createTextNode("' . $current_page_string . '"));');
// Move the pointer over the branding block so the contextual link appears
// as it would with a real user interaction. Otherwise clickContextualLink()
// does not open the dialog in a manner that is opener-aware, and it isn't
// possible to reliably test focus management.
$driver_session = $this->getSession()->getDriver()->getWebDriverSession();
$element = $driver_session->element('css selector', '#block-branding');
$driver_session->moveto(['element' => $element->getID()]);
$this->clickContextualLink('#block-branding', 'Test Link with Ajax', FALSE);
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '#drupal-modal'));
$this->assertSession()->elementContains('css', '#drupal-modal', 'Everything is contextual!');
$this->getSession()->executeScript('document.querySelector("#block-branding .trigger").addEventListener("focus", (e) => e.target.classList.add("i-am-focused"))');
$this->getSession()->getPage()->pressButton('Close');
$this->assertSession()->assertNoElementAfterWait('css', 'ui.dialog');
// When the dialog is closed, the opening contextual link is now inside a
// collapsed container, so focus should be routed to the contextual link
// toggle button.
$this->assertNotNull($this->assertSession()->waitForElement('css', '.trigger.i-am-focused'), $this->getSession()->getPage()->find('css', '#block-branding')->getOuterHtml());
$this->assertJsCondition('document.activeElement === document.querySelector("#block-branding button.trigger")', 10000, 'Focus should be on the contextual trigger, but instead is at ' . $this->getSession()->evaluateScript('document.activeElement.outerHTML'));
// 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()->assertExpectedAjaxRequest(1);
// 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!');
}
/**
* Tests the contextual links destination.
*/
public function testContextualLinksDestination(): void {
$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']);
}
/**
* Tests the contextual links destination with query.
*/
public function testContextualLinksDestinationWithQuery(): void {
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), [
'access contextual links',
'administer blocks',
]);
$this->drupalGet('admin/structure/block', ['query' => ['foo' => 'bar']]);
$this->assertSession()->waitForElement('css', '.contextual button');
$expected_destination_value = Url::fromRoute('block.admin_display')->toString();
$contextual_link_url_parsed = parse_url($this->getSession()->getPage()->findLink('Configure block')->getAttribute('href'));
$this->assertEquals("destination=$expected_destination_value%3Ffoo%3Dbar", $contextual_link_url_parsed['query']);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the UI for correct contextual links.
*
* @group contextual
*/
class DuplicateContextualLinksTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'block',
'contextual',
'node',
'views',
'views_ui',
'contextual_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the contextual links with same id.
*/
public function testSameContextualLinks(): void {
$this->drupalPlaceBlock('views_block:contextual_recent-block_1', ['id' => 'first']);
$this->drupalPlaceBlock('views_block:contextual_recent-block_1', ['id' => 'second']);
$this->drupalCreateContentType(['type' => 'page']);
$this->drupalCreateNode();
$this->drupalLogin($this->drupalCreateUser([
'access content',
'access contextual links',
'administer nodes',
'administer blocks',
'administer views',
'edit any page content',
]));
// Ensure same contextual links work correct with fresh and cached page.
foreach (['fresh', 'cached'] as $state) {
$this->drupalGet('user');
$contextual_id = '[data-contextual-id^="node:node=1"]';
$this->assertJsCondition("(typeof jQuery !== 'undefined' && jQuery('[data-contextual-id]:empty').length === 0)");
$this->getSession()->executeScript("jQuery('#block-first $contextual_id .trigger').trigger('click');");
$contextual_links = $this->assertSession()->waitForElementVisible('css', "#block-first $contextual_id .contextual-links");
$this->assertTrue($contextual_links->isVisible(), "Contextual links are visible with $state page.");
}
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
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}
*/
protected static $modules = [
'node',
'block',
'user',
'system',
'breakpoint',
'toolbar',
'contextual',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The administration theme name.
*
* @var string
*/
protected $adminTheme = 'claro';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
\Drupal::service('theme_installer')->install([$this->adminTheme]);
\Drupal::configFactory()
->getEditable('system.theme')
->set('admin', $this->adminTheme)
->save();
$this->drupalLogin($this->createUser([
'administer blocks',
'access contextual links',
'access toolbar',
'view the administration theme',
]));
$this->placeBlock('system_powered_by_block', ['id' => 'powered']);
}
/**
* Tests enabling and disabling edit mode.
*/
public function testEditModeEnableDisable(): void {
$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.
if ($page_get_count === 0) {
$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());
// Test while Edit Mode is enabled it doesn't interfere with pages with
// no contextual links.
$this->drupalGet('admin/structure/block');
$web_assert->elementContains('css', 'h1.page-title', 'Block layout');
$this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]')));
$this->assertGreaterThan(0, $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.
*
* @internal
*/
protected function assertAnnounceEditMode(): void {
$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.
*
* @internal
*/
protected function assertAnnounceLeaveEditMode(): void {
$web_assert = $this->assertSession();
$page = $this->getSession()->getPage();
// Wait till all the contextual links are hidden.
$page->waitFor(1, function () use ($page) {
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(window.tabbable.tabbable(document.body)).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,133 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests edge cases for converting between contextual links and IDs.
*
* @group contextual
*/
class ContextualUnitTest extends KernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['contextual'];
/**
* Provides test cases for both test functions.
*
* Used in testContextualLinksToId() and testContextualIdToLinks().
*
* @return array[]
* Test cases.
*/
public static function contextualLinksDataProvider(): array {
$tests['one group, one dynamic path argument, no metadata'] = [
[
'node' => [
'route_parameters' => [
'node' => '14031991',
],
'metadata' => ['langcode' => 'en'],
],
],
'node:node=14031991:langcode=en',
];
$tests['one group, multiple dynamic path arguments, no metadata'] = [
[
'foo' => [
'route_parameters' => [
0 => 'bar',
'key' => 'baz',
1 => 'qux',
],
'metadata' => ['langcode' => 'en'],
],
],
'foo:0=bar&key=baz&1=qux:langcode=en',
];
$tests['one group, one dynamic path argument, metadata'] = [
[
'views_ui_edit' => [
'route_parameters' => [
'view' => 'frontpage',
],
'metadata' => [
'location' => 'page',
'display' => 'page_1',
'langcode' => 'en',
],
],
],
'views_ui_edit:view=frontpage:location=page&display=page_1&langcode=en',
];
$tests['multiple groups, multiple dynamic path arguments'] = [
[
'node' => [
'route_parameters' => [
'node' => '14031991',
],
'metadata' => ['langcode' => 'en'],
],
'foo' => [
'route_parameters' => [
0 => 'bar',
'key' => 'baz',
1 => 'qux',
],
'metadata' => ['langcode' => 'en'],
],
'edge' => [
'route_parameters' => ['20011988'],
'metadata' => ['langcode' => 'en'],
],
],
'node:node=14031991:langcode=en|foo:0=bar&key=baz&1=qux:langcode=en|edge:0=20011988:langcode=en',
];
return $tests;
}
/**
* Tests the conversion from contextual links to IDs.
*
* @param array $links
* The #contextual_links property value array.
* @param string $id
* The serialized representation of the passed links.
*
* @covers ::_contextual_links_to_id
*
* @dataProvider contextualLinksDataProvider
*/
public function testContextualLinksToId(array $links, string $id): void {
$this->assertSame($id, _contextual_links_to_id($links));
}
/**
* Tests the conversion from contextual ID to links.
*
* @param array $links
* The #contextual_links property value array.
* @param string $id
* The serialized representation of the passed links.
*
* @covers ::_contextual_id_to_links
*
* @dataProvider contextualLinksDataProvider
*/
public function testContextualIdToLinks(array $links, string $id): void {
$this->assertSame($links, _contextual_id_to_links($id));
}
}