Drupal 11: Cascading Select Forms With HTMX

This is part four of a series of articles looking at HTMX in Drupal. In the last two articles we looked at using HTMX with controllers in different ways. This time I'll be venturing into the world of HTMX and forms.

Years ago on this site I wrote an article about Cascading ajax select forms in Drupal, which I often refer back to when I'm trying to figure out something to do with select forms and ajax. In that article I take a year, month, and day select field and tie them together so that they influence each other during the selection process.

I've been writing Drupal sites for quite a number of years and I still need to take a deep breath before attempting to embark on implementing ajax in Drupal forms. I end up with form fields that have wrapper elements or custom attributes in an attempt to get things working. It always seems to be a painful experience.

When I was learning about HTMX and Drupal I sat down to re-implement this cascading select form and had something working in about half an hour. Most of that time was spend adding the form elements to the build form method. A stark difference between the old and the new ways of adding ajax to forms in Drupal.

In this article we will look at creating a form that contains multiple select elements and then use HTMX (and a little bit of the form states API) to tie them together so that selecting one element updates the others.

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.   

Just like the other articles on HTMX, I'm going to start with the basics and define the route.

The Route

The route we need here just needs to point the path /htmx-examples/cascading-select at our form class.

drupal_htmx_examples_cascading_select_form:
  path: "/htmx-examples/cascading-select"
  defaults:
    _form: '\Drupal\drupal_htmx_examples\Form\CascadingSelectForm'
    _title: "HTMX Cascading Select Example Form"
  requirements:
    _permission: "access content"

There isn't anything unusual about this route, it's just a regular form route.

Let's create the form for this route.

The Form

The form class is just a standard Drupal form, but there is a difference between using HTMX in controllers and HTMX in forms. When we define a controller that will use HTMX we (sometimes) need to inject the request_stack service into the form so we can use it to detect any incoming HTMX requests. This isn't necessary for forms as the request_stack service is part of the core FormBase class, which we extend to create all forms in Drupal.

This is the outline of the form class, that will contain our cascading select form.

<?php

namespace Drupal\drupal_htmx_examples\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Htmx\Htmx;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Form to show cascading selects using HTMX.
 */
class CascadingSelectForm extends FormBase {

  /**
   * {@inheritDoc}
   */
  public function getFormId() {
    return 'htmx_cascade_select_form';
  }

  /**
   * {@inheritDoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {}

  /**
   * {@inheritDoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state): void {
    $year = $form_state->getValue('year');
    $month = $form_state->getValue('month');
    $day = $form_state->getValue('day');

    $args = [
      '%year' => $year,
      '%month' => $month,
      '%day' => $day,
    ];
    $this->messenger()->addMessage($this->t('Submitted form with values year: %year, month: %month, day: %day', $args));
  }

}

I've also added the submit handler here, which will just print out the input parameters as a message.

To build our form we need to define the year, month, and day select fields so that they can update each other during user interaction.

The first element, for the year, just defines an arbitrary date range from 2019 to 2050 and feeds that as an array into a select field. We also pull the current year value from the form state in case the user has submitted the form already and we want to maintain that state. We could be more nuanced with the date range here, but this is just a test form.

    $years = range(2019, 2050);
    $years = array_combine($years, $years);
    $year = $form_state->getValue('year');

    $form['year'] = [
      '#title' => $this->t('Year'),
      '#type' => 'select',
      '#empty_option' => $this->t('- Select -'),
      '#options' => $years,
      '#default_value' => $year,
    ];

To define the month we just create a small month array (there are only ever 12 values after all) and feed this into the month select element. We also add the current value of the month from the form state and then set up the form '#states' API here so that the month is only shown if the year select has a value in it.

    $months = [
      1 => $this->t('Jan'),
      2 => $this->t('Feb'),
      3 => $this->t('Mar'),
      4 => $this->t('Apr'),
      5 => $this->t('May'),
      6 => $this->t('Jun'),
      7 => $this->t('Jul'),
      8 => $this->t('Aug'),
      9 => $this->t('Sep'),
      10 => $this->t('Oct'),
      11 => $this->t('Nov'),
      12 => $this->t('Dec'),
    ];
    $month = $form_state->getValue('month');

    $form['month'] = [
      '#title' => $this->t('Month'),
      '#type' => 'select',
      '#options' => $months,
      '#empty_option' => $this->t('- Select -'),
      '#default_value' => $month,
      '#states' => [
        '!visible' => [
          ':input[name="year"]' => ['value' => ''],
        ],
      ],
    ];

Creating a list of days is slightly more involved. Some months have a different number of days[citation needed] and so we can't just add a range from 1 to 31 and be done with it. Instead we need to listen to the value added to both the year and the month and then load the number of days using the PHP built in function cal_days_in_month(). This means that if the month is February on a leap year we will still show the correct number of days in our select list. We also add in the #states API attribute so that the days field is only shown if the month element has a value in it.

    $days = [];
    if ($month) {
      $number = cal_days_in_month(CAL_GREGORIAN, $month, $year);
      $days = range(1, $number);
      $days = array_combine($days, $days);
    }
    $day = $form_state->getValue('day');

    $form['day'] = [
      '#title' => $this->t('Day'),
      '#type' => 'select',
      '#options' => $days,
      '#empty_option' => $this->t('- Select -'),
      '#default_value' => $day,
      '#states' => [
        '!visible' => [
          ':input[name="month"]' => ['value' => ''],
        ],
      ],
    ];

Finally, all we need now is to add a submit button and to return the form.

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Submit',
    ];

    return $form;

This form will show and hide elements based on the states API, but it won't populate the days field with the correct number of days. So let's add the important HTMX to the form.

Adding HTMX

The HTMX for this form needs to be added after the select elements have been defined and before the return statement at the end of the form.

We need to add HTMX to two of the fields in this form for the following actions:

  • When the user selects a month we need to go back to the form to calculate the number of days in that month and populate the day select field.
  • When the user selects a year we need to go back and re-populate the month and the day select fields. This will cover the instances where a leap year is selected and the user happens to have selected February 29th.

We use the Htmx Drupal class to create the attributes we need and apply them to the month select element.

    (new Htmx())
      ->post()
      ->select('*:has(>select[name="day"])')
      ->target('*:has(>select[name="day"])')
      ->swap('outerHTML')
      ->applyTo($form['month']);

This adds the following HTMX attributes to the form element.

  • data-hx-post - This will send a POST request back to the form. Since we didn't add a value to the method it will automatically use the current route.
  • data-hx-select - A select attribute is essential when dealing with HTMX forms in Drupal. If you have read my previous articles you will remember that when we make a HTMX request we get the entire form back from the server. We therefore need to tell HTMX to pick out of the response just the elements that we need. In this case we are using the selector string *:has(>select[name="day"] which tells HTMX that we want to find the parent of the select element that has the name of "day", which is our day select element.
  • data-hx-target - This tells HTMX where to place the element we have pulled out of the response. We use the same selector string as the data-hx-select attribute we want to replace the day select element on our page.
  • data-hs-swap - Finally, we set the swap strategy to outerHTML, which tells HTMX to entirely replace the target element with the selected value from the response.

Another Htmx Drupal object is created to add the attributes we need to the year select element.

    (new Htmx())
      ->post()
      ->select('*:has(>select[name="month"])')
      ->target('*:has(>select[name="month"])')
      // We also target the edit-day ID (which is the select element) with a
      // out of bounds select to replace the day select. This catches edge
      // cases where te 29th Feb is selected and a non-leap year is selected.
      ->selectOob('#edit-day')
      ->swap('outerHTML')
      ->applyTo($form['year']);

The main addition to the year form element is the addition of a selectOob() method. This adds a data-hx-select-oob attribute to the year field, which targets the day field when from the response from the year trigger.

If you are reading this and wondering why I could re-use the selector string from the month field data-hx-select and data-hx-target attributes then it is because the selector seems to cause problems with HTMX when using the out-of-band select. HTMX seems to want an ID to be present in the attribute, so if we pass a string starting with a "*" then it tries to make that an id name like "#*", which is invalid syntax for selectors.

The HTMX Workflow

These HTMX elements work in the following way.

Note that I have simplified and removed quite a bit of the markup to make things easier to read and follow.

What we are doing to do here is select the date "29th Feb 2024", which is a leap year, and then select the year 2023, which is not a leap year. This has the effect of de-selecting the day select since the record 29 doesn't exist any more.

The form is created, which contains the following elements.

<div class="js-form-item form-item form-type-select js-form-type-select form-item-year js-form-item-year">
<select data-hx-post="" data-hx-select="*:has(&gt;select[name=&quot;month&quot;])" data-hx-target="*:has(&gt;select[name=&quot;month&quot;])" data-hx-select-oob="edit-day" data-hx-swap="outerHTML ignoreTitle:true" data-drupal-selector="edit-year" id="edit-year" name="year" class="form-select form-element form-element--type-select">
  <option value="" selected="selected">- Select -</option>
  <option value="2019">2019</option>
  ...
  <option value="2024">2024</option>
  ...
  <option value="2050">2050</option>
</select>
</div>

<div class="js-form-item form-item form-type-select js-form-type-select form-item-month js-form-item-month" style="display: none;">
<select data-hx-post="" data-hx-select="*:has(&gt;select[name=&quot;day&quot;])" data-hx-target="*:has(&gt;select[name=&quot;day&quot;])" data-hx-swap="outerHTML ignoreTitle:true" data-drupal-selector="edit-month" id="edit-month" name="month" class="form-select form-element form-element--type-select" data-drupal-states="{&quot;!visible&quot;:{&quot;:input[name=\u0022year\u0022]&quot;:{&quot;value&quot;:&quot;&quot;}}}" data-once="states">
  <option value="" selected="selected">- Select -</option>
  <option value="1">Jan</option>
  <option value="2">Feb</option>
  <option value="3">Mar</option>
  <option value="4">Apr</option>
  <option value="5">May</option>
  <option value="6">Jun</option>
  <option value="7">Jul</option>
  <option value="8">Aug</option>
  <option value="9">Sep</option>
  <option value="10">Oct</option>
  <option value="11">Nov</option>
  <option value="12">Dec</option>
</select>
</div>

<div class="js-form-item form-item form-type-select js-form-type-select form-item-day js-form-item-day" style="display: none;">
<select data-drupal-selector="edit-day" id="edit-day" name="day" class="form-select form-element form-element--type-select" data-drupal-states="{&quot;!visible&quot;:{&quot;:input[name=\u0022month\u0022]&quot;:{&quot;value&quot;:&quot;&quot;}}}" data-once="states">
  <option value="" selected="selected">- Select -</option>
</select>
</div>

There's quite a lot going on in this HTML, even though I've simplified it down to just the elements we need. What we can see is that the state API is hiding the month and day fields by adding a "display: none" to the parent div of those elements. The day element also has no options in it currently.

The first step, in selecting the year of 2024, triggers a HTMX action to the form which returns all of the HTML of the form. HTMX then uses the data-hx-select to pull the month select element out of the response and data-hx-target to put this in place of the month select element. The data-hx-select-oob is also used to replace the day select element with that from the response. As we haven't actually changed much on the form yet nothing actually changes, but the HTMX will still trigger and update the form. As we have now selected a year the Drupal form #states API will display the month field.

When we select the month of February from the month select we trigger another HTMX request to the form. This time we see that the user has filled in the year and month fields from the form state and so can build the day select with the correct number of days in it. The response from Drupal is the entire form, so the data-hx-select attribute just picks out the day select element and uses the data-hx-target to swap the element on the page.

<div class="js-form-item form-item form-type-select js-form-type-select form-item-day js-form-item-day">
<select data-drupal-selector="edit-day" id="edit-day" name="day" class="form-select form-element form-element--type-select" data-drupal-states="{&quot;!visible&quot;:{&quot;:input[name=\u0022month\u0022]&quot;:{&quot;value&quot;:&quot;&quot;}}}" data-once="states">
  <option value="" selected="selected">- Select -</option>
  <option value="1">1</option>
...
  <option value="29">29</option>
</select>
</div>

This shows that the day field now contains 29 options (1 - 29) which correlates with the leap year we have selected of 2024.

If we select 29 from the day field and then select a different (non-leap) year the HTMX request will return a form without the number 29 in the day select and the element will be reset.

What also happens when we make these requests is that the form_build_id is refreshed automatically, this means that the form is viable in terms of Drupal's protection mechanisms. As you can see from this example, I haven't looked at replacing the form_build_id of the form in this code at all. This is all handled automatically behind the scenes with the Drupal HTMX interface.

If you are building Drupal forms and want to use HTMX then there are two things that you must remember.

  • The response from Drupal always contains the entire form so you must pick the elements you want out of the form response. If you don't do this you will see the form appearing inside the form, which is a bit of a mess.
  • The form must have a consistent state. This means that you can't just add a bunch of elements to the form and expect it to work. The form state must re-build the form in exactly the same way with or without the HTMX request. This makes sense if you thik at how forms are submitted. In the form workflow the forms are built and then submitted, so if the element doesn't exist in the build step, then it won't be part of the submitted values in the submit handler.

I will explore more Drupal form and HTMX handling in the next article. Stay tuned for more!

More in this series

Add new comment

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