diff options
Diffstat (limited to 'core/modules/contextual')
16 files changed, 512 insertions, 922 deletions
diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml index bfc1c996c98f..6798d02d9070 100644 --- a/core/modules/contextual/contextual.libraries.yml +++ b/core/modules/contextual/contextual.libraries.yml @@ -1,16 +1,9 @@ drupal.contextual-links: version: VERSION js: + js/contextualModelView.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: {} @@ -22,28 +15,21 @@ drupal.contextual-links: - 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/toolbar/contextualToolbarModelView.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 + - contextual/drupal.contextual-links - 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 diff --git a/core/modules/contextual/css/contextual.theme.css b/core/modules/contextual/css/contextual.theme.css index 06a6728be396..55a83d5ca12a 100644 --- a/core/modules/contextual/css/contextual.theme.css +++ b/core/modules/contextual/css/contextual.theme.css @@ -17,6 +17,10 @@ left: 0; } +.contextual.open { + z-index: 501; +} + /** * Contextual region. */ diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js index 87ccaa52dffe..f1008eabe07b 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)), - ); - - // 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.', - }), + options.title = title; + const contextualModelView = new Drupal.contextual.ContextualModelView( + $contextual, + $region, + options, ); - + 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,21 +243,23 @@ * 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')); + } + }, + }), + ContextualModelView: {}, }; /** - * 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} @@ -311,12 +282,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); diff --git a/core/modules/contextual/js/contextual.toolbar.js b/core/modules/contextual/js/contextual.toolbar.js index 8fc206cc2c3b..c94d0df414c9 100644 --- a/core/modules/contextual/js/contextual.toolbar.js +++ b/core/modules/contextual/js/contextual.toolbar.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module's edit toolbar tab. */ -(function ($, Drupal, Backbone) { +(function ($, Drupal) { const strings = { tabbingReleased: Drupal.t( 'Tabbing is no longer constrained by the Contextual module.', @@ -21,33 +21,19 @@ * A contextual links DOM element as rendered by the server. */ function initContextualToolbar(context) { - if (!Drupal.contextual || !Drupal.contextual.collection) { + if (!Drupal.contextual || !Drupal.contextual.instances) { 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 { contextualToolbar } = Drupal; const viewOptions = { el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'), - model: contextualToolbar.model, strings, }; - new contextualToolbar.VisualView(viewOptions); - new contextualToolbar.AuralView(viewOptions); + contextualToolbar.model = new Drupal.contextual.ContextualToolbarModelView( + viewOptions, + ); } /** @@ -75,13 +61,10 @@ */ Drupal.contextualToolbar = { /** - * The {@link Drupal.contextualToolbar.StateModel} instance. - * - * @type {?Drupal.contextualToolbar.StateModel} + * The {@link Drupal.contextual.ContextualToolbarModelView} instance. * - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is - * no replacement. + * @type {?Drupal.contextual.ContextualToolbarModelView} */ model: null, }; -})(jQuery, Drupal, Backbone); +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/contextualModelView.js b/core/modules/contextual/js/contextualModelView.js new file mode 100644 index 000000000000..4488045e2236 --- /dev/null +++ b/core/modules/contextual/js/contextualModelView.js @@ -0,0 +1,254 @@ +(($, Drupal) => { + /** + * Models the state of a contextual link's trigger, list & region. + */ + Drupal.contextual.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(); + } + } + }; +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/models/StateModel.js b/core/modules/contextual/js/models/StateModel.js deleted file mode 100644 index 622f897917f5..000000000000 --- a/core/modules/contextual/js/models/StateModel.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @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); diff --git a/core/modules/contextual/js/toolbar/contextualToolbarModelView.js b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js new file mode 100644 index 000000000000..6c6db5fe70cd --- /dev/null +++ b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js @@ -0,0 +1,175 @@ +(($, Drupal) => { + Drupal.contextual.ContextualToolbarModelView = class { + constructor(options) { + this.strings = options.strings; + this.isVisible = false; + this._contextualCount = Drupal.contextual.instances.count; + this.tabbingContext = null; + this._isViewing = + localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false'; + this.$el = options.el; + + window.addEventListener('contextual-instances-added', () => + this.lockNewContextualLinks(), + ); + window.addEventListener('contextual-instances-removed', () => { + this.contextualCount = Drupal.contextual.instances.count; + }); + + this.$el.on({ + click: () => { + this.isViewing = !this.isViewing; + }, + touchend: (event) => { + event.preventDefault(); + event.target.click(); + }, + 'click touchend': () => this.render(), + }); + + $(document).on('keyup', (event) => this.onKeypress(event)); + this.manageTabbing(true); + this.render(); + } + + /** + * 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.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.isViewing = true; + } + } + + /** + * Updates the rendered representation of the current toolbar model view. + */ + render() { + this.$el[0].classList.toggle('hidden', this.isVisible); + const button = this.$el[0].querySelector('button'); + button.classList.toggle('is-active', !this.isViewing); + button.setAttribute('aria-pressed', !this.isViewing); + this.contextualCount = Drupal.contextual.instances.count; + } + + /** + * Automatically updates visibility of the view/edit mode toggle. + */ + updateVisibility() { + this.isVisible = this.get('contextualCount') > 0; + } + + /** + * Lock newly added contextual links if edit mode is enabled. + */ + lockNewContextualLinks() { + Drupal.contextual.instances.forEach((model) => { + model.isLocked = !this.isViewing; + }); + this.contextualCount = Drupal.contextual.instances.count; + } + + /** + * Limits tabbing to the contextual links and edit mode toolbar tab. + * + * @param {boolean} init - true to initialize tabbing. + */ + manageTabbing(init = false) { + let { tabbingContext } = this; + // Always release an existing tabbing context. + if (tabbingContext && !init) { + // Only announce release when the context was active. + if (tabbingContext.active) { + Drupal.announce(this.strings.tabbingReleased); + } + tabbingContext.release(); + this.tabbingContext = null; + } + // Create a new tabbing context when edit mode is enabled. + if (!this.isViewing) { + tabbingContext = Drupal.tabbingManager.constrain( + $('.contextual-toolbar-tab, .contextual'), + ); + this.tabbingContext = tabbingContext; + this.announceTabbingConstraint(); + this.announcedOnce = true; + } + } + + /** + * Announces the current tabbing constraint. + */ + announceTabbingConstraint() { + const { strings } = this; + Drupal.announce( + Drupal.formatString(strings.tabbingConstrained, { + '@contextualsCount': Drupal.formatPlural( + Drupal.contextual.instances.length, + '@count contextual link', + '@count contextual links', + ), + }) + strings.pressEsc, + ); + } + + /** + * Gets the current viewing state. + * + * @return {boolean} the viewing state. + */ + get isViewing() { + return this._isViewing; + } + + /** + * Sets the current viewing state. + * + * @param {boolean} value - new viewing state + */ + set isViewing(value) { + this._isViewing = value; + localStorage[!value ? 'setItem' : 'removeItem']( + 'Drupal.contextualToolbar.isViewing', + 'false', + ); + + Drupal.contextual.instances.forEach((model) => { + model.isLocked = !this.isViewing; + }); + this.manageTabbing(); + } + + /** + * Gets the current contextual links count. + * + * @return {integer} the current contextual links count. + */ + get contextualCount() { + return this._contextualCount; + } + + /** + * Sets the current contextual links count. + * + * @param {integer} value - new contextual links count. + */ + set contextualCount(value) { + if (value !== this._contextualCount) { + this._contextualCount = value; + this.updateVisibility(); + } + } + }; +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/toolbar/models/StateModel.js b/core/modules/contextual/js/toolbar/models/StateModel.js deleted file mode 100644 index 88f66193f9f3..000000000000 --- a/core/modules/contextual/js/toolbar/models/StateModel.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @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); diff --git a/core/modules/contextual/js/toolbar/views/AuralView.js b/core/modules/contextual/js/toolbar/views/AuralView.js deleted file mode 100644 index 2bcf9cdcca0f..000000000000 --- a/core/modules/contextual/js/toolbar/views/AuralView.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @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, _); diff --git a/core/modules/contextual/js/toolbar/views/VisualView.js b/core/modules/contextual/js/toolbar/views/VisualView.js deleted file mode 100644 index 10d8dff2deaa..000000000000 --- a/core/modules/contextual/js/toolbar/views/VisualView.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @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); diff --git a/core/modules/contextual/js/views/AuralView.js b/core/modules/contextual/js/views/AuralView.js deleted file mode 100644 index 62287c1bf118..000000000000 --- a/core/modules/contextual/js/views/AuralView.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @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); diff --git a/core/modules/contextual/js/views/KeyboardView.js b/core/modules/contextual/js/views/KeyboardView.js deleted file mode 100644 index 2a3d144bea07..000000000000 --- a/core/modules/contextual/js/views/KeyboardView.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @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); diff --git a/core/modules/contextual/js/views/RegionView.js b/core/modules/contextual/js/views/RegionView.js deleted file mode 100644 index 349428301d81..000000000000 --- a/core/modules/contextual/js/views/RegionView.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @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); diff --git a/core/modules/contextual/js/views/VisualView.js b/core/modules/contextual/js/views/VisualView.js deleted file mode 100644 index fcd932b1faf4..000000000000 --- a/core/modules/contextual/js/views/VisualView.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @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); diff --git a/core/modules/contextual/src/Hook/ContextualThemeHooks.php b/core/modules/contextual/src/Hook/ContextualThemeHooks.php index 760a42c97854..7d873196b431 100644 --- a/core/modules/contextual/src/Hook/ContextualThemeHooks.php +++ b/core/modules/contextual/src/Hook/ContextualThemeHooks.php @@ -2,7 +2,7 @@ namespace Drupal\contextual\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; /** @@ -21,7 +21,7 @@ class ContextualThemeHooks { * @see contextual_page_attachments() * @see \Drupal\contextual\ContextualController::render() */ - #[Preprocess] + #[Hook('preprocess')] public function preprocess(&$variables, $hook, $info): void { // Determine the primary theme function argument. if (!empty($info['variables'])) { diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php index 75e56b5f76b2..1d4fa243c492 100644 --- a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php +++ b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php @@ -73,47 +73,40 @@ class EditModeTest extends WebDriverTestBase { $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()); - } - + $this->drupalGet('user'); + $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]')); + + // After the page loaded we need to additionally wait until the settings + // tray Ajax activity is done. + $web_assert->assertWaitOnAjaxRequest(); + + $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()); } /** |