diff options
Diffstat (limited to 'core/modules/contextual/js/contextual.js')
-rw-r--r-- | core/modules/contextual/js/contextual.js | 351 |
1 files changed, 283 insertions, 68 deletions
diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js index 87ccaa52dffe..5a852e28fbac 100644 --- a/core/modules/contextual/js/contextual.js +++ b/core/modules/contextual/js/contextual.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module. */ -(function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) { +(function ($, Drupal, drupalSettings, JSON, storage) { const options = $.extend( drupalSettings.contextual, // Merge strings on top of drupalSettings so that they are not mutable. @@ -14,22 +14,19 @@ }, }, ); - // 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; + const { permissionsHash } = drupalSettings.user; if (cachedPermissionsHash !== permissionsHash) { if (typeof permissionsHash === 'string') { - _.chain(storage) - .keys() - .each((key) => { - if (key.startsWith('Drupal.contextual.')) { - storage.removeItem(key); - } - }); + Object.keys(storage).forEach((key) => { + if (key.startsWith('Drupal.contextual.')) { + storage.removeItem(key); + } + }); } storage.setItem('Drupal.contextual.permissionsHash', permissionsHash); } @@ -87,7 +84,7 @@ */ function initContextual($contextual, html) { const $region = $contextual.closest('.contextual-region'); - const contextual = Drupal.contextual; + const { contextual } = Drupal; $contextual // Update the placeholder to contain its rendered contextual links. @@ -107,46 +104,18 @@ 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)), + options.title = title; + const contextualModelView = new Drupal.contextual.ContextualModelView( + $contextual, + $region, + 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.', - }), - ); - + contextual.instances.push(contextualModelView); // Fix visual collisions between contextual link triggers. adjustIfNestedAndOverlapping($contextual); } @@ -192,7 +161,7 @@ // 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 + // the chance to set up an event listener on the collection // Drupal.contextual.collection. window.setTimeout(() => { initContextual( @@ -217,7 +186,7 @@ data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens }, dataType: 'json', success(results) { - _.each(results, (html, contextualID) => { + Object.entries(results).forEach(([contextualID, html]) => { // Store the metadata. storage.setItem(`Drupal.contextual.${contextualID}`, html); // If the rendered contextual links are empty, then the current @@ -274,19 +243,273 @@ * replacement. */ regionViews: [], - }; + instances: new Proxy([], { + set: function set(obj, prop, value) { + obj[prop] = value; + window.dispatchEvent(new Event('contextual-instances-added')); + return true; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + window.dispatchEvent(new Event('contextual-instances-removed')); + } + }, + }), - /** - * 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, - }); + /** + * Models the state of a contextual link's trigger, list & region. + */ + ContextualModelView: class { + constructor($contextual, $region, options) { + this.title = options.title || ''; + this.regionIsHovered = false; + this._hasFocus = false; + this._isOpen = false; + this._isLocked = false; + this.strings = options.strings; + this.timer = NaN; + this.modelId = btoa(Math.random()).substring(0, 12); + this.$region = $region; + this.$contextual = $contextual; + + if (!document.body.classList.contains('touchevents')) { + this.$region.on({ + mouseenter: () => { + this.regionIsHovered = true; + }, + mouseleave: () => { + this.close().blur(); + this.regionIsHovered = false; + }, + 'mouseleave mouseenter': () => this.render(), + }); + this.$contextual.on('mouseenter', () => { + this.focus(); + this.render(); + }); + } + + this.$contextual.on( + { + click: () => { + this.toggleOpen(); + }, + touchend: () => { + Drupal.contextual.ContextualModelView.touchEndToClick(); + }, + focus: () => { + this.focus(); + }, + blur: () => { + this.blur(); + }, + 'click blur touchend focus': () => this.render(), + }, + '.trigger', + ); + + this.$contextual.on( + { + click: () => { + this.close().blur(); + }, + touchend: (event) => { + Drupal.contextual.ContextualModelView.touchEndToClick(event); + }, + focus: () => { + this.focus(); + }, + blur: () => { + this.waitCloseThenBlur(); + }, + 'click blur touchend focus': () => this.render(), + }, + '.contextual-links a', + ); + + this.render(); + + // Let other JavaScript react to the adding of a new contextual link. + $(document).trigger('drupalContextualLinkAdded', { + $el: $contextual, + $region, + model: this, + }); + } + + /** + * Updates the rendered representation of the current contextual links. + */ + render() { + const { isOpen } = this; + const isVisible = this.isLocked || this.regionIsHovered || isOpen; + this.$region.toggleClass('focus', this.hasFocus); + this.$contextual + .toggleClass('open', isOpen) + // Update the visibility of the trigger. + .find('.trigger') + .toggleClass('visually-hidden', !isVisible); + + this.$contextual.find('.contextual-links').prop('hidden', !isOpen); + const trigger = this.$contextual.find('.trigger').get(0); + trigger.textContent = Drupal.t('@action @title configuration options', { + '@action': !isOpen ? this.strings.open : this.strings.close, + '@title': this.title, + }); + trigger.setAttribute('aria-pressed', isOpen); + } + + /** + * Prevents delay and simulated mouse events. + * + * @param {jQuery.Event} event the touch end event. + */ + static touchEndToClick(event) { + event.preventDefault(); + event.target.click(); + } + + /** + * Set up a timeout to allow a user to tab between the trigger and the + * contextual links without the menu dismissing. + */ + waitCloseThenBlur() { + this.timer = window.setTimeout(() => { + this.isOpen = false; + this.hasFocus = false; + this.render(); + }, 150); + } + + /** + * Opens or closes the contextual link. + * + * If it is opened, then also give focus. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + toggleOpen() { + const newIsOpen = !this.isOpen; + this.isOpen = newIsOpen; + if (newIsOpen) { + this.focus(); + } + return this; + } + + /** + * Gives focus to this contextual link. + * + * Also closes + removes focus from every other contextual link. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + focus() { + const { modelId } = this; + Drupal.contextual.instances.forEach((model) => { + if (model.modelId !== modelId) { + model.close().blur(); + } + }); + window.clearTimeout(this.timer); + this.hasFocus = true; + return this; + } + + /** + * Removes focus from this contextual link, unless it is open. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + blur() { + if (!this.isOpen) { + this.hasFocus = false; + } + 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.ContextualModelView} + * The current contextual model view. + */ + close() { + this.isOpen = false; + return this; + } + + /** + * Gets the current focus state. + * + * @return {boolean} the focus state. + */ + get hasFocus() { + return this._hasFocus; + } + + /** + * Sets the current focus state. + * + * @param {boolean} value - new focus state + */ + set hasFocus(value) { + this._hasFocus = value; + this.$region.toggleClass('focus', this._hasFocus); + } + + /** + * Gets the current open state. + * + * @return {boolean} the open state. + */ + get isOpen() { + return this._isOpen; + } + + /** + * Sets the current open state. + * + * @param {boolean} value - new open state + */ + set isOpen(value) { + this._isOpen = value; + // Nested contextual region handling: hide any nested contextual triggers. + this.$region + .closest('.contextual-region') + .find('.contextual .trigger:not(:first)') + .toggle(!this.isOpen); + } + + /** + * Gets the current locked state. + * + * @return {boolean} the locked state. + */ + get isLocked() { + return this._isLocked; + } + + /** + * Sets the current locked state. + * + * @param {boolean} value - new locked state + */ + set isLocked(value) { + if (value !== this._isLocked) { + this._isLocked = value; + this.render(); + } + } + }, + }; /** * A trigger is an interactive element often bound to a click handler. @@ -311,12 +534,4 @@ $(document).on('drupalContextualLinkAdded', (event, data) => { Drupal.ajax.bindAjaxLinks(data.$el[0]); }); -})( - jQuery, - Drupal, - drupalSettings, - _, - Backbone, - window.JSON, - window.sessionStorage, -); +})(jQuery, Drupal, drupalSettings, window.JSON, window.sessionStorage); |