3 * Attaches behaviors for the Contextual module.
6 (function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) {
7 const options = $.extend(drupalSettings.contextual,
8 // Merge strings on top of drupalSettings so that they are not mutable.
11 open: Drupal.t('Open'),
12 close: Drupal.t('Close'),
17 // Clear the cached contextual links whenever the current user's set of
18 // permissions changes.
19 const cachedPermissionsHash = storage.getItem('Drupal.contextual.permissionsHash');
20 const permissionsHash = drupalSettings.user.permissionsHash;
21 if (cachedPermissionsHash !== permissionsHash) {
22 if (typeof permissionsHash === 'string') {
23 _.chain(storage).keys().each((key) => {
24 if (key.substring(0, 18) === 'Drupal.contextual.') {
25 storage.removeItem(key);
29 storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
33 * Initializes a contextual link: updates its DOM, sets up model and views.
35 * @param {jQuery} $contextual
36 * A contextual links placeholder DOM element, containing the actual
37 * contextual links as rendered by the server.
38 * @param {string} html
39 * The server-side rendered HTML for this contextual link.
41 function initContextual($contextual, html) {
42 const $region = $contextual.closest('.contextual-region');
43 const contextual = Drupal.contextual;
46 // Update the placeholder to contain its rendered contextual links.
48 // Use the placeholder as a wrapper with a specific class to provide
49 // positioning and behavior attachment context.
50 .addClass('contextual')
51 // Ensure a trigger element exists before the actual contextual links.
52 .prepend(Drupal.theme('contextualTrigger'));
54 // Set the destination parameter on each of the contextual links.
55 const destination = `destination=${Drupal.encodePath(drupalSettings.path.currentPath)}`;
56 $contextual.find('.contextual-links a').each(function () {
57 const url = this.getAttribute('href');
58 const glue = (url.indexOf('?') === -1) ? '?' : '&';
59 this.setAttribute('href', url + glue + destination);
62 // Create a model and the appropriate views.
63 const model = new contextual.StateModel({
64 title: $region.find('h2').eq(0).text().trim(),
66 const viewOptions = $.extend({ el: $contextual, model }, options);
67 contextual.views.push({
68 visual: new contextual.VisualView(viewOptions),
69 aural: new contextual.AuralView(viewOptions),
70 keyboard: new contextual.KeyboardView(viewOptions),
72 contextual.regionViews.push(new contextual.RegionView(
73 $.extend({ el: $region, model }, options)),
76 // Add the model to the collection. This must happen after the views have
77 // been associated with it, otherwise collection change event handlers can't
78 // trigger the model change event handler in its views.
79 contextual.collection.add(model);
81 // Let other JavaScript react to the adding of a new contextual link.
82 $(document).trigger('drupalContextualLinkAdded', {
88 // Fix visual collisions between contextual link triggers.
89 adjustIfNestedAndOverlapping($contextual);
93 * Determines if a contextual link is nested & overlapping, if so: adjusts it.
95 * This only deals with two levels of nesting; deeper levels are not touched.
97 * @param {jQuery} $contextual
98 * A contextual links placeholder DOM element, containing the actual
99 * contextual links as rendered by the server.
101 function adjustIfNestedAndOverlapping($contextual) {
102 const $contextuals = $contextual
103 // @todo confirm that .closest() is not sufficient
104 .parents('.contextual-region').eq(-1)
105 .find('.contextual');
107 // Early-return when there's no nesting.
108 if ($contextuals.length <= 1) {
112 // If the two contextual links overlap, then we move the second one.
113 const firstTop = $contextuals.eq(0).offset().top;
114 const secondTop = $contextuals.eq(1).offset().top;
115 if (firstTop === secondTop) {
116 const $nestedContextual = $contextuals.eq(1);
118 // Retrieve height of nested contextual link.
120 const $trigger = $nestedContextual.find('.trigger');
121 // Elements with the .visually-hidden class have no dimensions, so this
122 // class must be temporarily removed to the calculate the height.
123 $trigger.removeClass('visually-hidden');
124 height = $nestedContextual.height();
125 $trigger.addClass('visually-hidden');
127 // Adjust nested contextual link's position.
128 $nestedContextual.css({ top: $nestedContextual.position().top + height });
133 * Attaches outline behavior for regions associated with contextual links.
136 * Contextual triggers an event that can be used by other scripts.
137 * - drupalContextualLinkAdded: Triggered when a contextual link is added.
139 * @type {Drupal~behavior}
141 * @prop {Drupal~behaviorAttach} attach
142 * Attaches the outline behavior to the right context.
144 Drupal.behaviors.contextual = {
146 const $context = $(context);
148 // Find all contextual links placeholders, if any.
149 let $placeholders = $context.find('[data-contextual-id]').once('contextual-render');
150 if ($placeholders.length === 0) {
154 // Collect the IDs for all contextual links placeholders.
156 $placeholders.each(function () {
157 ids.push($(this).attr('data-contextual-id'));
160 // Update all contextual links placeholders whose HTML is cached.
161 const uncachedIDs = _.filter(ids, (contextualID) => {
162 const html = storage.getItem(`Drupal.contextual.${contextualID}`);
163 if (html && html.length) {
164 // Initialize after the current execution cycle, to make the AJAX
165 // request for retrieving the uncached contextual links as soon as
166 // possible, but also to ensure that other Drupal behaviors have had
167 // the chance to set up an event listener on the Backbone collection
168 // Drupal.contextual.collection.
169 window.setTimeout(() => {
170 initContextual($context.find(`[data-contextual-id="${contextualID}"]`), html);
177 // Perform an AJAX request to let the server render the contextual links
178 // for each of the placeholders.
179 if (uncachedIDs.length > 0) {
181 url: Drupal.url('contextual/render'),
183 data: { 'ids[]': uncachedIDs },
186 _.each(results, (html, contextualID) => {
187 // Store the metadata.
188 storage.setItem(`Drupal.contextual.${contextualID}`, html);
189 // If the rendered contextual links are empty, then the current
190 // user does not have permission to access the associated links:
191 // don't render anything.
192 if (html.length > 0) {
193 // Update the placeholders to contain its rendered contextual
194 // links. Usually there will only be one placeholder, but it's
195 // possible for multiple identical placeholders exist on the
196 // page (probably because the same content appears more than
198 $placeholders = $context.find(`[data-contextual-id="${contextualID}"]`);
200 // Initialize the contextual links.
201 for (let i = 0; i < $placeholders.length; i++) {
202 initContextual($placeholders.eq(i), html);
213 * Namespace for contextual related functionality.
217 Drupal.contextual = {
220 * The {@link Drupal.contextual.View} instances associated with each list
221 * element of contextual links.
228 * The {@link Drupal.contextual.RegionView} instances associated with each
229 * contextual region element.
237 * A Backbone.Collection of {@link Drupal.contextual.StateModel} instances.
239 * @type {Backbone.Collection}
241 Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.StateModel });
244 * A trigger is an interactive element often bound to a click handler.
247 * A string representing a DOM fragment.
249 Drupal.theme.contextualTrigger = function () {
250 return '<button class="trigger visually-hidden focusable" type="button"></button>';
252 }(jQuery, Drupal, drupalSettings, _, Backbone, window.JSON, window.sessionStorage));