#! code: Drupal 11: Creating A Tabbed Interface With HTMX

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.The initial skeleton of the controller has a few services so that we have everything we need for the rest of the functionality. $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 first task is to create the route for our controller.Using this we can extract the ID of the page the user clicked on and start loading that to the page.

The Route

/**
* 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 DrupalCoreEntityEntityInterface|null
* The node, or null if the node failed to load.
*
* @throws DrupalComponentPluginExceptionInvalidPluginDefinitionException
* @throws DrupalComponentPluginExceptionPluginNotFoundException
*/
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);
}

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

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

The Controller

The response is created in the following way.Next, let’s look at loading the data from the database.return $this->htmxRenderer->renderResponse(
$output,
$this->requestStack->getCurrentRequest(),
$this->currentRouteMatch);

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.drupal_htmx_examples_tabbed:
path: '/drupal-htmx-examples/tabbed'
defaults:
_title: 'HTMX Tabbed'
_controller: 'Drupaldrupal_htmx_examplesControllerTabbedController::action'
requirements:
_permission: 'access content'

<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>

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.Let’s look at the HTMX workflow in action here.When the user first loads the page they will see a list of 6 links, followed by a div element containing the first article.

The Controller Action

In addition to this trait, I am going to respond to the HTMX request using the DrupalCoreRenderMainContentHtmxRenderer 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. // 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,
];

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.   

  • 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.

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

<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>

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.<?php

namespace Drupaldrupal_htmx_examplesController;

use DrupalCoreControllerControllerBase;
use DrupalCoreEntityEntityInterface;
use DrupalCoreHtmxHtmx;
use DrupalCoreHtmxHtmxRequestInfoTrait;
use SymfonyComponentDependencyInjectionContainerInterface;

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

use HtmxRequestInfoTrait;

/**
* The request stack service.
*
* @var SymfonyComponentHttpFoundationRequestStack
*/
protected $requestStack;

/**
* The HTMX Renderer service.
*
* @var DrupalCoreRenderMainContentHtmxRenderer
*/
protected $htmxRenderer;

/**
* The route match service.
*
* @var DrupalCoreRoutingRouteMatchInterface
*/
protected $currentRouteMatch;

/**
* The database service.
*
* @var DrupalCoreDatabaseConnection
*/
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|DrupalCoreRenderHtmlResponse|SymfonyComponentHttpFoundationResponse
* The render array, or a HTMX renderer response.
*/
public function 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.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.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.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.This route points at a controller, and because we have a single endpoint here we need to use the DrupalCoreHtmxHtmxRequestInfoTrait 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.

The HTMX Workflow

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);
}

The following function takes a number and will return the (published) article page at that position in the database, ordered by created date.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.Let’s build the controller that this route points to.

Similar Posts