Use webcomponents for dropbutton

Created on 3 December 2023, 7 months ago
Updated 29 May 2024, 20 days ago

Problem/Motivation

This is a preliminary test for using webcomponents to simplify Drupal JS. with webcomponents we don't need behaviors or once, see code.

Steps to reproduce

Before (5.1ms to init 39 dropbutton on the /admin/content page)

<div class="dropbutton-wrapper dropbutton-multiple" data-drupal-ajax-container="" data-once="dropbutton">
<div class="dropbutton-widget">
<!-- ul, li, li, li, li -->
</div>
</div>
 function DropButton(dropbutton, settings) {
    // Merge defaults with settings.
    const options = $.extend(
      { title: Drupal.t('List additional actions') },
      settings,
    );
    const $dropbutton = $(dropbutton);

    /**
     * @type {jQuery}
     */
    this.$dropbutton = $dropbutton;

    /**
     * @type {jQuery}
     */
    this.$list = $dropbutton.find('.dropbutton');

    /**
     * Find actions and mark them.
     *
     * @type {jQuery}
     */
    this.$actions = this.$list.find('li').addClass('dropbutton-action');

    // Add the special dropdown only if there are hidden actions.
    if (this.$actions.length > 1) {
      // Identify the first element of the collection.
      const $primary = this.$actions.slice(0, 1);
      // Identify the secondary actions.
      const $secondary = this.$actions.slice(1);
      $secondary.addClass('secondary-action');
      // Add toggle link.
      $primary.after(Drupal.theme('dropbuttonToggle', options));
      // Bind mouse events.
      this.$dropbutton.addClass('dropbutton-multiple').on({
        /**
         * Adds a timeout to close the dropdown on mouseleave.
         *
         * @ignore
         */
        'mouseleave.dropbutton': this.hoverOut.bind(this),

        /**
         * Clears timeout when mouseout of the dropdown.
         *
         * @ignore
         */
        'mouseenter.dropbutton': this.hoverIn.bind(this),

        /**
         * Similar to mouseleave/mouseenter, but for keyboard navigation.
         *
         * @ignore
         */
        'focusout.dropbutton': this.focusOut.bind(this),

        /**
         * @ignore
         */
        'focusin.dropbutton': this.focusIn.bind(this),
      });
    } else {
      this.$dropbutton.addClass('dropbutton-single');
    }
  }


  /**
   * Process elements with the .dropbutton class on page load.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Attaches dropButton behaviors.
   */
  Drupal.behaviors.dropButton = {
    attach(context, settings) {
      const dropbuttons = once('dropbutton', '.dropbutton-wrapper', context);
      if (dropbuttons.length) {
        // Adds the delegated handler that will toggle dropdowns on click.
        const body = once('dropbutton-click', 'body');
        if (body.length) {
          $(body).on('click', '.dropbutton-toggle', dropbuttonClickHandler);
        }
        // Initialize all buttons.
        dropbuttons.forEach((dropbutton) => {
          DropButton.dropbuttons.push(
            new DropButton(dropbutton, settings.dropbutton),
          );
        });
      }
    },
  };

Proposed resolution

After (2.5ms to init 39 dropbutton on the /admin/content page)

<drupal-dropbutton class="dropbutton-wrapper dropbutton-multiple" data-drupal-ajax-container="">
<div class="dropbutton-widget">
<!-- ul, li, li, li, li -->
</div>
</drupal-dropbutton>
class DrupalDropbutton extends HTMLElement {
  static {
    customElements.define('drupal-dropbutton', this);
  }

  connectedCallback() {
    const settings = drupalSettings?.dropbutton;
    // Merge defaults with settings.
    const options = Object.assign(
      { title: Drupal.t('List additional actions') },
      settings,
    );

    const actions = Array.from(this.querySelectorAll('.dropbutton li'));

    // Add the special dropdown only if there are hidden actions.
    if (actions.length > 1) {
      // Identify the first element of the collection.
      const primary = actions[0];

      this.classList.add('dropbutton-multiple');
      actions.forEach((li) => li.classList.add('dropbutton-action', 'secondary-action'));
      primary.classList.remove('secondary-action');
      // Add toggle link.
      primary.insertAdjacentHTML("afterend", this.dropbuttonToggle(options));
      const toggle = this.querySelector('.dropbutton-toggle');

      toggle.addEventListener('click', this);
      this.addEventListener('mouseleave', this);
      this.addEventListener('mouseenter', this);
      this.addEventListener('focusout', this);
      this.addEventListener('focusin', this);
    } else {
      this.classList.add('dropbutton-single');
    }
  }

  handleEvent(event) {
    switch (event.type) {
      case 'mouseleave': this.hoverOut(); break;
      case 'mouseenter': this.hoverIn(); break;
      case 'focusout': this.hoverOut(); break;
      case 'focusin': this.hoverIn(); break;
      case 'click':
        event.preventDefault();
        this.classList.toggle('open');
        break;
    }
  }

  dropbuttonToggle(options) {
    if (Drupal?.theme?.dropbuttonToggle) {
      return Drupal.theme.dropbuttonToggle(options);
    }
    return `<li class="dropbutton-toggle"><button type="button"><span class="dropbutton-arrow"><span class="visually-hidden">${options.title}</span></span></button></li>`;
  }

}



Remaining tasks

User interface changes

API changes

Data model changes

Release notes snippet

📌 Task
Status

Needs work

Version

11.0 🔥

Component
Javascript 

Last updated less than a minute ago

Created by

🇫🇷France nod_ Lille

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

  • Issue created by @nod_
  • Merge request !5662Resolve #3405822 "Use webcomponents for" → (Open) created by nod_
  • 🇫🇷France nod_ Lille

    At this point I'm looking for feeback on the overall approach. Chose dropbutton because it's self contained, and it's not related to forms. Other webcomponents that could be interesting: autocomplete, marchine name, vertical tabs, time diff, etc. anything self-contained that uses behaviors for init.

    This makes dropbutton jquery-free as a side effect.

    This makes core use the defer attribute, which will prevent a few things from working regarding aggregation at the moment, see 🐛 JavaScript aggregation should account for "async" and "defer" attributes Needs work .

  • Status changed to Needs review 7 months ago
  • 🇫🇷France nod_ Lille
  • 🇫🇷France andypost

    Curious how accessible this to screen readers for example?

  • 🇫🇷France nod_ Lille

    Accessibility is not an issue with this one. We don't use the shadow dom, the markup is stricly the same as before inside, and the drupal-dropbutton replaces a div which has no special meaning either.

  • 🇮🇪Ireland markconroy

    I think this is a great idea. And I love the potential for our custom components to be used in other setups, such as if we had a custom menu component, I can imagine other developers using it in their personal projects or other CMSs learning from it, using it, or adapting it to make it suit their. use case.

  • 🇺🇸United States brianperry

    Excited about this! So called 'html web components' (components that add behaviors to mostly existing markup and don't use the shadow dom) really seem to be catching on. Issues like this will also be super useful to demonstrate how to take advantage of existing Drupal js functionality inside a custom element. Or even better, to demonstrate the js features we don't actually need anymore.

  • 🇺🇸United States cosmicdreams Minneapolis/St. Paul

    Thanks @brianperry for pointing me to this issue.

    In case it comes up. Yes, web components can be accessible.
    https://www.erikkroes.nl/blog/accessibility/the-guide-to-accessible-web-...

    That's what we can do today. If the Accessibility Object Model ever lands then we'll have low-level capability to do even more.

  • 🇧🇪Belgium Wim Leers Ghent 🇧🇪🇪🇺

    I can't believe how simple this MR looks 😳 🤩

    This makes dropbutton jquery-free as a side effect.

    👏

    I actually like the idea of core staking a claim to own the drupal- namespace for custom elements.

    +1

  • 🇫🇷France nod_ Lille

    Need help with the test failures here.

    First is the test testAdvancedCaching I do not have a clue why this happens, fails with

     Caused by
     ErrorException: Method "IteratorAggregate::getIterator()" might add "\Traversable" as a native return type declaration in the future. Do the same in implementation "Drupal\Core\Entity\ContentEntityBase" now to avoid errors or add an explicit @return annotation to suppress this message.
    

    And the rest of the failures happens because selenium doesn't seem to think that our custom element can be interacted with. Haven't found out why yet.

  • 🇧🇪Belgium Wim Leers Ghent 🇧🇪🇪🇺

    How could a Kernel test fail due to front-end changes?! 🤯

    Ah, quoting \Drupal\Tests\views\Kernel\Plugin\RowRenderCacheTest::doTestRenderedOutput():

          $expected = $access ? '  <div class="dropbutton-wrapper" data-drupal-ajax-container><div class="dropbutton-widget"><ul class="dropbutton">' .
            '<li><a href="' . $node_url . '/edit?destination=/" hreflang="en">Edit</a></li>' .
            '<li><a href="' . $node_url . '/delete?destination=/" class="use-ajax" data-dialog-type="modal" data-dialog-options="' . Html::escape(Json::encode(['width' => 880])) . '" hreflang="en">Delete</a></li>' .
            '</ul></div></div>' : '';
    

    You'll need to adjust those expectations 😅

  • 🇫🇷France nod_ Lille

    thanks! fixed that one.

    Hopefully the selenium one won't be too bad…

  • 🇧🇪Belgium Wim Leers Ghent 🇧🇪🇪🇺

    I bet it's something about the selectors used in the test 🤓

  • Status changed to Needs work 6 months ago
  • 🇺🇸United States smustgrave

    Have not reviewed.

    Seems to have some test failures though.

  • Pipeline finished with Failed
    2 months ago
    Total: 1020s
    #149575
  • 🇫🇷France nod_ Lille

    selenium not happy with webcomponents :/

  • 🇫🇷France pdureau Paris

    Such an interesting issue! Replacing all Drupal behaviours by WebComponents looks exciting: one less Drupalism & better performance.

    Is it possible to do the same with "normal" markup instead of a custom element? Leveraging the is attribute and using the web component only as an (efficient) behaviour bag:

    <div is="drupal-dropbutton" class="dropbutton-wrapper dropbutton-multiple">
    <div class="dropbutton-widget">
    <!-- ul, li, li, li, li -->
    </div>
    </div>

    Advantages:

    • faster to convert the renderable once the JS is updated, just replace data- attributes by the is attribute
    • even faster rendering because server-side goodness
    • no accessibility issues because we are back to good old markup
    • Selenium may prefer this one :)

    Unfortunately, Safari would need a polyfill for now.

Production build 0.69.0 2024