Drupal 11: Creating A Tabbed Interface With HTMX

This is part three of a series of articles looking at HTMX in Drupal. Last time I looked at using HTMX to run a "load more" feature on a Drupal page. Before moving onto looking at forms I thought a final example of using HTMX and controllers to achieve an action.

One of the key examples that helped me understand HTMX was when it was used to create a tabbed interface, without reloading the page. This was quite simple to recreate in Drupal and can be done in a single controller.

In this article we will be creating a tabbed interface in Drupal, where HTMX is used to power loading the data in a tab like interface without reloading the page.

All of the code contained in this article can be found in the Drupal HTMX examples project on GitHub, but here we will go through what the code does and what actions it performs to generate content.   

The first task is to create the route for our controller.

The Route

The route we create here just points to an action in a controller.

drupal_htmx_examples_tabbed:
  path: '/drupal-htmx-examples/tabbed'
  defaults:
    _title: 'HTMX Tabbed'
    _controller: '\Drupal\drupal_htmx_examples\Controller\TabbedController::action'
  requirements:
    _permission: 'access content'

When the user (assuming they have the access content permission) visits the path /drupal-htmx-examples/tabbed then they will trigger the action() method in the controller.

Let's build the controller that this route points to.

The Controller

This route points at a controller, and because we have a single endpoint here we need to use the Drupal\Core\Htmx\HtmxRequestInfoTrait trait. In order to make use of this trait we need to inject the request_stack service and create a method called getRequest(), which will return the current request from the request stack.

In addition to this trait, I am going to respond to the HTMX request using the Drupal\Core\Render\MainContent\HtmxRenderer object. This will allow us to return HTMX friendly markup from the response without needing to add the _wrapper_format argument to the links. Using this technique isn't always necessary, but this example will show how it is possible to do this if you need to.

To be able to use the HtmxRender object we need to include the main_content_renderer.htmx service, which is a usable HtmxRender object. In addition to this we also need a current_route_match service, which is required to call the renderResponse() method of the object and return the content.

The initial skeleton of the controller has a few services so that we have everything we need for the rest of the functionality.

<?php

namespace Drupal\drupal_htmx_examples\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Controller to show a tabbed region on the page using HTMX.
 */
class TabbedController extends ControllerBase {

  use HtmxRequestInfoTrait;

  /**
   * The request stack service.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * The HTMX Renderer service.
   *
   * @var \Drupal\Core\Render\MainContent\HtmxRenderer
   */
  protected $htmxRenderer;

  /**
   * The route match service.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $currentRouteMatch;

  /**
   * The database service.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $database;

  public static function create(ContainerInterface $container) {
    $instance = new self();
    $instance->requestStack = $container->get('request_stack');
    $instance->htmxRenderer = $container->get('main_content_renderer.htmx');
    $instance->currentRouteMatch = $container->get('current_route_match');
    $instance->database = $container->get('database');
    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  protected function getRequest() {
    return $this->requestStack->getCurrentRequest();
  }

  /**
   * Callback for the route drupal_htmx_examples_tabbed.
   *
   * @return array|\Drupal\Core\Render\HtmlResponse|\Symfony\Component\HttpFoundation\Response
   *   The render array, or a HTMX renderer response.
   */
  public function action() {
  }
}

This will form the basis of the controller.

Next, let's look at loading the data from the database.

Loading Pages From The Database

One of the services I included into this controller was a connection to the database. This is used to grab the first new articles from the database so that we can load them in the content of the tabs. The idea here is that I wanted an easy to replicate system for grabbing pages of content from the database. Loading the first few items seemed like a decent approach that anyone can replicate on their sites easily.

The following function takes a number and will return the (published) article page at that position in the database, ordered by created date.

  /**
   * Load the node in position "nth", ordered by date created descending.
   *
   * @param int $nth
   *   The position of the node to load, ordered by date created descending.
   *
   * @return \Drupal\Core\Entity\EntityInterface|null
   *   The node, or null if the node failed to load.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  protected function loadNthNode(int $nth): ?EntityInterface {
    $query = $this->database->select('node', 'n')
      ->fields('n', ['nid']);
    $query->join('node_field_data', 'nfd', '[nfd].[nid] = [n].[nid] AND [nfd].[langcode] = [n].[langcode]');
    $query->orderBy('nfd.created', 'desc');
    $query->where('n.type = :type', [':type' => 'article']);
    $query->where('nfd.status = 1');
    $query->range($nth, 1);
    $nid = $query->execute()->fetchField();

    // Then we load the data accordingly.
    $nodeStorage = $this->entityTypeManager()->getStorage('node');
    return $nodeStorage->load($nid);
  }

Is this useful? Maybe not, but it will work for most Drupal sites (as long as they have a content type called "article") and so is a good example of loading pages of content without knowing what's currently in the site.

The Controller Action

The controller action can be split into a roughly two parts. First we need to load the page of content, and then we need to respond to the HTMX request created by that page.

First, let's create the output for the page. All we need to do here is set up an array of numbers from 1 to 5, and then render a list of links using those numbers. Each link in the list is gets an name attribute and is then tagged with HTMX attributes that are used to power the tabbed elements.

    $range = range(1, 5);

    $items = [];

    foreach ($range as $item) {
      $id = 'page_' . $item;
      $items[$id] = [
        '#type' => 'html_tag',
        '#tag' => 'a',
        '#value' => $this->t('Page @number', ['@number' => $item]),
        '#attributes' => [
          'name' => $id,
          'href' => '#',
        ],
      ];

      (new Htmx())
        ->get()
        ->swap('outerHTML')
        ->target('#detail')
        ->trigger('click')
        ->applyTo($items[$id]);
    }

    $output['list_of_items'] = [
      '#theme' => 'item_list',
      '#title' => 'Links',
      '#items' => $items,
      '#type' => 'ul',
    ];

The use of the Htmx class here adds the following attributes to each link.

  • data-hx-get="" - This will send a GET request to the current URL.
  • data-hx-swap="outerHTML" - This attribute means that we will swap the returned HTML (remember that HTMX works using plain HTML) with this element. In other words, we remove this element and replace it with the data coming from the response.
  • data-hx-target="#detail" - This sets the target that the response to the request will be placed into. We haven't added this element to the code yet, that's next.
  • data-hx-trigger="click" - This means that when the user clicks the link the GET request is run.

Notice that all of the links get exactly the same attributes. We will come onto exactly how HTMX is able to tell what element is making the request later in the article.

The rest of the page render is used to generate the div element with the detail id, which is where the content will be placed into. Rather than just leave this element blank I decided to load the first element in the list and render this as the default content of the page. To that end, we use the entity type manager service to render the node in the teaser mode and add it as a child of the div element.

    // Load the first node in the database.
    $node = $this->loadNthNode(1);

    // Convert the node to a render array for the view mode "teaser".
    $viewBuilder = $this->entityTypeManager()->getViewBuilder('node');
    $renderArray = $viewBuilder->view($node, 'teaser');

    $output['tab_content'] = [
      '#type' => 'html_tag',
      '#tag' => 'div',
      '#value' => '',
      '#attributes' => [
        'id' => 'detail',
      ],
      'children' => $renderArray,
    ];

To respond to the request we need to make use of the request_stack, main_content_renderer.htmx and current_route_match services that we added to the controller create method at the start.

The response is created in the following way.

return $this->htmxRenderer->renderResponse(
  $output,
  $this->requestStack->getCurrentRequest(),
  $this->currentRouteMatch);

When we get a request from HTMX contains a number of headers, and because we added a name attribute to the link element the header contains a HX-Trigger-Name header. We can read this header using the getHtmxTriggerName() method of the HtmxRequestInfoTrait trait, which returns a string like "page_1".

Using this we can extract the ID of the page the user clicked on and start loading that to the page.

$trigger = $this->getHtmxTriggerName();
$number = str_replace('page_', '', $trigger);

The full code to respond to the HTMX request is as follows.

    if ($this->isHtmxRequest()) {
      // This is a HTMX request, so we create some output and respond with a
      // full HTMX Renderer response.
      // First we find the element that triggered the request.
      $trigger = $this->getHtmxTriggerName();

      // Then map to a node by finding the n-th item in the database depending
      // on what tab was clicked on.
      $number = str_replace('page_', '', $trigger);

      $node = $this->loadNthNode($number);

      $viewBuilder = $this->entityTypeManager()->getViewBuilder('node');
      $renderArray = $viewBuilder->view($node, 'teaser');

      // Then, set up the detail div and render it.
      $output['tab_content'] = [
        '#type' => 'html_tag',
        '#tag' => 'div',
        '#attributes' => [
          'id' => 'detail',
        ],
        'children' => $renderArray,
      ];

      $output['#cache'] = [
        'contexts' => [
          'url:path',
          'url:path.query',
        ],
        'tags' => $node->getCacheTags(),
      ];

      return $this->htmxRenderer->renderResponse(
        $output,
        $this->requestStack->getCurrentRequest(),
        $this->currentRouteMatch);
    }

Aside from a little bit of cache handling to ensure that the nodes are updated if changed elsewhere this is pretty much it for the code in the controller.

Let's look at the HTMX workflow in action here.

The HTMX Workflow

When the user first loads the page they will see a list of 6 links, followed by a div element containing the first article.

<ul>
<li><a name="page_1" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 1</a></li>
... links removed for brevity ...
<li><a name="page_6" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 6</a></li>
</ul>

<div id="detail">
... article 1 ...
</div>

Assuming that the user clicks on the "Page 6" link, HTMX will issue a request to the /drupal-htmx-examples/tabbed route, which will respond with the following HTML.

<div id="detail">
... article 6 ...
</div>

As we use the outerHTML swap strategy for this request, HTMX will replace the current detail div element with the one that comes from the server.

<ul>
<li><a name="page_1" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 1</a></li>
...
<li><a name="page_6" href="#" data-hx-get="" data-hx-swap="outerHTML ignoreTitle:true" data-hx-target="#detail" data-hx-trigger="click">Page 6</a></li>
</ul>

<div id="detail">
... article 6 ...
</div>

The user can now click through the links on the page to load different pages of content. HTMX makes up only a small proportion of the code here.

More in this series

Add new comment

The content of this field is kept private and will not be shown publicly.