Problem/Motivation

It was decided that core use of BackboneJS will be replaced with VanillaJS equivalents: #3145958: [META] Re-evaluate use of Backbone.js in core

This issue's scope to to replace contextual links; use of Backbone with Vanilla JS. No functionality should change, just the JS used to achieve it.

Steps to reproduce

Proposed resolution

Remaining tasks

User interface changes

API changes

Existing API
Drupal Event Drupal.contextual Drupal.contextualToolbar
drupalContextualLinkAdded
  $el => jQuery
  $region => jQuery
  model => StateModel
AuralView
KeyboardView
RegionView
VisualView
StateModel (Backbone model +):
  title
  regionIsHovered
  hasFocus
  isOpen
  isLocked
  toggleOpen()
  close()
  focus()
  blur()
  
collection: Backbone collection of
  models: array of StateModel
regionViews: array of RegionView
views: array of objects:
  aural => AuralView
  keyboard => KeyboardView
  visual => VisualView

events bound on backbone models:
  change (from the *Views models, triggers a renrender)
  change:hasFocus (from the regionView model)
  
AuralView (not exposed)
VisualView (not exposed) 
StateModel (Backbone model +):
  isViewing
  isVisible
  contextualCount
  tabbingContext
  countContextualLinks()
  lockNewContextualLinks()
  updateVisibility()
  
model => StateModel

events bound on model:
  change
  change:contextualCount
  change:isViewing

events bound on collection:
  add
  remove
  reset
Backbone View Backbone Model Backbone Collection
el
cid
$el
prototype:
  $
  bind
  delegate
  delegateEvents
  initialize
  listenTo
  listenToOnce
  off
  on
  once
  preinitialize
  remove
  render
  setElement
  stopListening
  tagName
  trigger
  unbind
  undelegate
  undelegateEvents
cid
attributes
changed
prototype:
  bind
  chain
  changed
  changedAttributes
  cidPrefix
  clear
  clone
  destroy
  escape
  fetch
  get
  has
  hasChanged
  idAttribute
  initialize
  invert
  isEmpty
  isNew
  isValid
  keys
  listenTo
  listenToOnce
  matches
  off
  omit
  on
  once
  pairs
  parse
  pick
  preinitialize
  previous
  previousAttributes
  save
  set
  stopListening
  sync
  toJSON
  trigger
  unbind
  unset
  url
  validationError
  values
length
models
prototype: 
  add
  all
  any
  at
  bind
  chain
  clone
  collect
  contains
  countBy
  create
  detect
  difference
  drop
  each
  entries
  every
  fetch
  filter
  find
  findIndex
  findLastIndex
  findWhere
  first
  foldl
  foldr
  forEach
  get
  groupBy
  has
  head
  include
  includes
  indexBy
  indexOf
  initial
  initialize
  inject
  invoke
  isEmpty
  keys
  last
  lastIndexOf
  listenTo
  listenToOnce
  map
  max
  min
  model
  modelId
  off
  on
  once
  parse
  partition
  pluck
  pop
  preinitialize
  push
  reduce
  reduceRight
  reject
  remove
  reset
  rest
  sample
  select
  set
  shift
  shuffle
  size
  slice
  some
  sort
  sortBy
  stopListening
  sync
  tail
  take
  toArray
  toJSON
  trigger
  unbind
  unshift
  values
  where
  without
New API
Drupal Event Drupal.contextual Drupal.contextualToolbar
drupalContextualLinkAdded
  $el => jQuery
  $region => jQuery
  contextualModelView
instances: array of ContextualModelView
ContextualModelView
  $contextual
  $region
  modelId
  regionIsHovered
  strings
  timer
  title
  hasFocus
  isLocked
  isOpen


model: instance of ContextualToolbarModelView
ContextualToolbarModelView
  $el
  strings
  tabbingContext
  isViewing

The *ModelView objects take care of rendering things at the appropriate time so there are no events published on the outside for other code to listen to. It's fine since contrib doesn't extend this.

Data model changes

Release notes snippet

Issue fork drupal-3203920

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

bnjmnm created an issue. See original summary.

claudiu.cristea’s picture

I wonder if we can’t replace it with pure CSS, see https://css-tricks.com/solved-with-css-dropdown-menus/

droplet’s picture

#2,

the dropdown will or should be replaced with #1899236: Add new Splitbutton render element to eventually replace Dropbutton

I think we should do simple conversion first and tweaks later (easier review)

claudiu.cristea’s picture

@droplet, it seems that splitbutton is still based on JS.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

kim.pepper’s picture

Issue tags: +#pnx-sprint
jptaranto’s picture

A CSS only solution wouldn't be accessible.

larowlan’s picture

Status: Active » Needs review

Looks like there's a merge request here for review

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

bnjmnm’s picture

Could not find any contrib JS customization of contextual links

Summary of what Backbone is doing:

/* COLLECTIONS */
# Drupal.contextual.collection
  A collection of multiple Contextual Link model instances that correspond to the contextual links on the page.

/* TOOLBAR MODELS */
Contextual Toolbar Model
# isViewing: boolean, If toggled in "edit". (when all contextual links are visible)
# isVisible: boolean, determines if toggle should be visible.
    will be false if no contextual links are present
# contextualCount: number of contextual links on page
# tabbingContext: tabbingContext object of tabbable elements when
    edit mode enabled
# countContextualLinks() ON 'reset remove add' to Drupal.contextual.collection
  - sets 'contextualCount' to the collection size
# lockNewContextualLinks() ON 'add' to Drupal.contextual.collection
  - If in edit mode, newly added contextual links will be locked visible
# updateVisibility() ON count change in Drupal.contextual.collection
  - If the collection count is zero, hide the edit toggle

/* TOOLBAR VIEWS */
Toolbar AuralView
# announcedOnce: boolean, true after tabbing constraint is announced once
# render() ON any change to Contextual Toolbar Model
  -  the toggle's `aria-pressed` attribute is changed if needed
# manageTabbing() ON init and 'change:isViewing'
  - In edit mode, Limits tabbing to the contextual links and edit mode toolbar tab.
# announceTabbingConstraint() ON a new tabbing constraint implemented
  - Screenreader announces tabbing constraint incl. number of links
# onKeyPress() ON esc, tab
  - escape sets 'isViewing' to true
  - tab and calls announceTabbingConstraint() if announcedOnce false and isViewing FALSE.
 
Toolbar VisualView
# render()
  - toggles $el's 'hidden' class based on model's 'isVisible'
  - toggles $el child button 'is-active' classed based on model's 'isViewing'
# persist(model, isViewing)  
  - updates localStorage with 'isViewing' status.

/* CONTEXTUAL LINK MODELS */
Contextual Model
# title: title of the entity to which the contextual links apply
# regionIsHovered: boolean if the contextual region is hovered
# hasFocus: boolean if the contextual trigger or options have focus
# isOpen: boolean are the list of contextual options visible
# isLocked: boolean if true the trigger remains active
# toggleOpen()
  - Toggles open + if just opened, focuses the link
# close()
  - sets 'isOpen' to false;
# focus()
    - sets 'hasFocus' to true, and closes+blurs all other contextual links.
# blur()
    - blurs the contextual link unless the options list is open.
  
/* CONTEXTUAL LINK VIEWS */
Contextual AuralView
# render() ON contextual link model change
  - toggles ``.contextual-links` 'hidden' attribute based on 'isOpen'
  - updates 'aria-pressed' if needed
  - updates hidden text "[open/close] @title configuration options"  
  
Contextual KeyboardView
# initialize
  - initializes timer that delay's hiding links on blur
# focus() ON focus of the link or any child items.
  - clears blur timer to prevent contextual list from hiding since an item inside it is focused  
  
Contextual RegionView
# render() ON   'change:hasFocus'
 - toggle 'focus' class on $el based on model's 'hasFocus' property
 
Contextual VisualView 
# render() ON any model change
 - toggle 'open' class on $el based on 'isOpen'
 - toggle 'visually-hidden' class on $el.find('.trigger') based on 'isVisible'
 - IF 'isOpen' changed, find $el's corresponding `.contextual .trigger:not(:first)`
      and toggle visibility based on 'isOpen'
bnjmnm’s picture

StatusFileSize
new65.52 KB

Gitlab isnt letting me create 9.4 branches at the moment, so here's a 9.4 reroll as a patch.

Status: Needs review » Needs work

The last submitted patch, 12: 3203920-12-reroll.patch, failed testing. View results

nod_’s picture

opened a new issue to not have to deal with missing branches and rebasing 500+ commits #3275400: Contextual backbone removal.

I'll probably post the patches here once it's good to go.

nod_’s picture

Issue summary: View changes

updated IS with API changes

nod_’s picture

Issue summary: View changes
nod_’s picture

Component: javascript » contextual.module
Status: Needs work » Needs review
StatusFileSize
new80.76 KB

Code from #3275400: Contextual backbone removal that I'll close.

larowlan’s picture

StatusFileSize
new81.07 KB

This didn't apply anymore after, straight re-roll, reviewing the code locally

larowlan’s picture

Part way done reviewing, will continue tomorrow

  1. +++ b/core/modules/contextual/js/contextual.es6.js
    @@ -3,7 +3,7 @@
       const options = $.extend(
         drupalSettings.contextual,
    

    should we use Object.assign here while we're touching this file or would you prefer to leave that to one of the jquery eslint issues?

  2. +++ b/core/modules/contextual/js/contextual.es6.js
    @@ -86,8 +85,6 @@
       function initContextual($contextual, html) {
         const $region = $contextual.closest('.contextual-region');
    

    the comment on this method still refers to 'sets up model and views' so we should be able to reword that now

  3. +++ b/core/modules/contextual/js/contextual.es6.js
    @@ -113,29 +110,21 @@
         // Create a model and the appropriate views.
    

    similarly here, its just a 'model view' now rather than two things

  4. +++ b/core/modules/contextual/js/contextual.es6.js
    @@ -156,59 +145,58 @@
    +                $context.find(`[data-contextual-id="${id}"]:empty`).eq(0),
    

    should we replace this with querySelector whilst we're here?

  5. +++ b/core/modules/contextual/js/contextual.es6.js
    @@ -220,14 +208,18 @@
    +                  .forEach(($placeholder) => {
    +                    initContextual($placeholder, html);
    

    should we change the signature of initContextual now to not require a jQuery object as the argument?

    there's going to be some BC breaks here we can't avoid already, so should we bundle that change in here too and give us a way to remove jQuery without a further break?

    we can still init it as a jQuery object inside the function to avoid moving too much around in this issue

  6. +++ b/core/modules/contextual/js/contextualModelView.es6.js
    @@ -0,0 +1,245 @@
    +      $(document).trigger('drupalContextualLinkAdded', {
    +        $el: $contextual,
    +        $region,
    +        model: this,
    

    we fire this here and in initContextual, (which calls this), which seems wrong - should we only fire it once?

  7. +++ b/core/modules/contextual/js/contextualModelView.es6.js
    @@ -0,0 +1,245 @@
    +      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);
    +      this.$contextual
    +        .find('.trigger')
    +        .text(
    +          Drupal.t('@action @title configuration options', {
    +            '@action': !isOpen ? this.strings.open : this.strings.close,
    +            '@title': this.title,
    +          }),
    +        )
    +        .attr('aria-pressed', isOpen);
    

    there's a lot of new jQuery here, should we be adding new jQuery code?

    All of this can be done with vanilla js

Status: Needs review » Needs work

The last submitted patch, 18: 3203920-17.patch, failed testing. View results

larowlan’s picture

Status: Needs work » Needs review

second half of review, onto manual testing now

  1. +++ b/core/modules/contextual/js/contextualModelView.es6.js
    @@ -0,0 +1,245 @@
    +      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.
    

    We call this a lot, should we keep a hash of the properties that can change and only do the work if they've actually changed?

  2. +++ b/core/modules/contextual/js/toolbar/contextualToolbarModelView.es6.js
    @@ -0,0 +1,145 @@
    +        touchend: (event) => {
    +          event.preventDefault();
    +          event.target.click();
    +        },
    

    this is the same event body as on contextualmodelview, should we make it a static and use the same thing in both places?

  3. +++ b/core/modules/contextual/js/toolbar/contextualToolbarModelView.es6.js
    @@ -0,0 +1,145 @@
    +      if (!this.announcedOnce && event.keyCode === 9 && !this.isViewing) {
    ...
    +      if (event.keyCode === 27) {
    

    event.keyCode is deprecated, so should we use event.code now or do we still care about IE11 (for now)

  4. +++ b/core/modules/contextual/js/toolbar/contextualToolbarModelView.es6.js
    @@ -0,0 +1,145 @@
    +      const button = this.$el[0].querySelector('button');
    

    odd mix of vanilla and jquery here?

  5. +++ b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php
    @@ -56,7 +56,7 @@ public function testEditModeEnableDisable() {
    -    for ($page_get_count = 0; $page_get_count < 2; $page_get_count++) {
    +    for ($page_get_count = 0; $page_get_count < 1; $page_get_count++) {
    

    why was this changed needed?

larowlan’s picture

Manually tested as follows:

* Install umami
* Navigate to recipe
* Tab to edit button in toolbar
* Activate edit mode
* Confirm tabbing constrained to contextual links
* Confirm menu can be opened via keyboard
* Confirm links work via keyboard
* Confirm Esc key exits tabbing constraint
* Confirm works via mouse/hover
* Confirms menu can be opened and links can be clicked

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

longwave’s picture

Status: Needs review » Needs work
Issue tags: +Needs reroll

Needs a reroll - should this be targeted at 10.0.x now, given that we can't remove the deprecated code until then anyway?

longwave’s picture

Issue tags: +Deprecation Removal
vsujeetkumar’s picture

StatusFileSize
new82.64 KB
new44.01 KB

Re-roll patch created for 9.5.x, Please have a look.

vsujeetkumar’s picture

Status: Needs work » Needs review
tvb’s picture

Issue tags: -Needs reroll

Patch from #26 could be applied.

Version: 9.5.x-dev » 10.1.x-dev

Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

smustgrave’s picture

Status: Needs review » Needs work
Issue tags: +Needs reroll, +Needs Review Queue Initiative

This issue is being reviewed by the kind folks in Slack, #need-reveiw-queue. We are working to keep the size of Needs Review queue [2700+ issues] to around 200, following Review a patch or merge require as a guide.

D10 version needed
At this time we would need a D10.1.x patch or MR for this issue.

Careful reroll
The patch can be tricky to reroll. When rerolling this issue please make sure all the parts of the patch are kept (new files, new tests, all the changes to the different parts of the file, changes to es6 files ported to .js files, etc.)

_utsavsharma’s picture

StatusFileSize
new38.51 KB

Tried to re-roll for 10.1.x.
Created new files as well as deleted the ones which were deleted in #26.
Please review.

bnjmnm’s picture

#31 is missing ModelView and ToolbarModelView. Try another reroll but have it come from an earlier patch (whatever patch you reroll from should be over 80kb)

sahil.goyal’s picture

StatusFileSize
new45.07 KB
new12.9 KB

As per the comment #30 a reroll is required but in comment 31 can be seen that few files are missing as said in #32 so i updating the reroll patch and attaching the intra_diff file along with the patch, please review if needed something to modified please suggest and move back as needed.

sahil.goyal’s picture

Status: Needs work » Needs review
smustgrave’s picture

Status: Needs review » Needs work

Errors in patch

karishmaamin’s picture

StatusFileSize
new45.45 KB
new5.1 KB

Fixed some of indentation error. Keeping it Needs work to fix PHPStan error

nikhil_110’s picture

StatusFileSize
new953 bytes
new45.14 KB

Re-roll patch #36 and Try to fix Cs issue

nitin shrivastava’s picture

StatusFileSize
new45.02 KB
new40.42 KB

fix command failures #37

prem suthar’s picture

StatusFileSize
new44.77 KB

Try To Fix the #38 Patch.

Version: 10.1.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

vsujeetkumar’s picture

Issue tags: -JavaScript +JavaScript

@Prem Suthar, Interdiff is missing in #39, We should add that along with the patch. Its help to the reviewers to find the difference and what are the changes we have made from last patch. Also some Tests fails because some of the files are missing in the patch. Please compare with #26.

Re-roll patch needed for 11.x.

vsujeetkumar’s picture

StatusFileSize
new51.54 KB

Re-roll patch created for 11.x, We have added some missing JS files according to #26 and not include es6.js files with reference of this document. Keeps as is "Needs work" because we have some unaddressed comments above.

catch’s picture

Priority: Normal » Major

#3484850: [PP-1] [meta] Deprecate Toolbar module will be unblocked soon, which will leave this as the last remaining usage of backbone in Drupal core. It would be great to be able to remove the backbone library from Drupal 12. Bumping this to major. Looks like the next step here is converting the patch to an MR.

larowlan’s picture

Status: Needs work » Needs review
Issue tags: +DrupalSouth, +11.2.0 release priority

Rebased the MR off 11.x

larowlan’s picture

smustgrave’s picture

Status: Needs review » Needs work

Fixed the pipeline so it would run but appears to have broken a few tests.

larowlan’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll

Fixed the bad reroll which could have been me but also could have been a previous iteration.
Seems to be working locally in manual testing.

larowlan’s picture

Assigned: Unassigned » larowlan
Status: Needs review » Needs work

Failing tests look valid

larowlan’s picture

Status: Needs work » Needs review

There were some underscore references left behind. Got rid of those because they're not needed in 2025 - we have native methods

Spot checked some tests locally and its looking good

nicxvan’s picture

Status: Needs review » Needs work

There is a functional JS failure that looks relevant, I can't rerun it since a core committer created the branch.

larowlan’s picture

Yep it's a real fail
Closing the contextual link list used to place focus on the trigger but that isn't happening now.
Tried a few things but they all end up in a loop
Will revisit this week

larowlan’s picture

Dug some more into the final fail
The issue is that when the dialog is opened from the contextual link that uses ajax and a dlalog, when jQueryUI attempts this code

this.opener = $( this.document[ 0 ].activeElement );

we've already blurred the link and hence there's no focussed element to use as the opener, which means we can't return the focus.

Any ideas @bnjmnm?

larowlan’s picture

Realized that the order events was being attached were different so re-ordered to ensure that ajax events are added first.
Also worked to reduce the amount of re-renders.
Then also tried using a targeted event (e.g. click.contextualLinks)

None of those helped

nod_’s picture

Tried to go back a few commits and fixed a problem with hovering out of a region and contextual links not closing, not sure it's the same issue as #51 but it was broken before and now it's not. Can you let me know how to reproduce the failure with dialog?

opened a new MR to not erase the work in case it's a different bug.

Also all the comments have been removed, would be good to keep them around, they help with understanding what's going on

nod_’s picture

could reproduce the problem with ajax using contextual_test module.

It seems it's the event delegation that used to be in backbone that was making things work. Somewhere it was switched to direct event binding and because there is so much re-rendering the event listeners were trashed and it caused issues apparently.

nod_’s picture

Status: Needs work » Needs review
nod_’s picture

Status: Needs review » Needs work

MR is green, need the comments added back for the various function we copy/pasted.

larowlan changed the visibility of the branch 3203920-rebased to hidden.

larowlan’s picture

Status: Needs work » Needs review

Added the docs back

larowlan’s picture

Assigned: larowlan » Unassigned

This is green and should be pretty close, thanks @nod_ for the last fix

nicxvan’s picture

I can't content on the mr for some reason.

Some comments could still use cleanup:

// This uses proxy to look for added instances. Backbone also listened for
// reset and remove, which I'm unsure how do accomplish.

Otherwise looks good, JS is not my forte though.

larowlan’s picture

Addressed feedback, ready for review again

nicxvan’s picture

Status: Needs review » Reviewed & tested by the community

Ok, I think this is ready.

I tested manually both in 11.x and this branch locally.
I tested block and view contextual links and tested with both mouse and keyboard. As far as I can tell both act exactly the same.

I searched for references to backbone, the only references remaining are for toolbar. There are no more references in contextual.

Javascript is not my forte as mentioned, but I am familiar with it, reviewing the code directly I see nothing that looks amiss.

As far as I can tell both larowlan and _nod have worked on this to a non trivial extent.

The only thing I didn't check that may be worth it is performance. I'm not super familiar with JS performance testing so I'll leave that up to a committer to determine if it's necessary.

_nod's comment in 56 make me think the change has been addressed as far as I can tell.

I added a CR too.

  • nod_ committed d3815233 on 11.x
    Issue #3203920 by larowlan, nod_, bnjmnm, vsujeetkumar, smustgrave,...
nod_’s picture

Status: Reviewed & tested by the community » Fixed

Committed d381523 and pushed to 11.x. Thanks!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.