After a shorter than expected time in the core issue queue the HTMX integration in now usable in Drupal Core, Let's see how we can use it!
HTMX has a respectable pool of examples to choose from, today we'll focus on forms. How can we make an equivalent of the cascading select HTMX example in Drupal?
The HTMX version of the cascading select is very simple. There are two selects, make and models. On the make select we add 2 attributes hx-get="/models"
and hx-target="#models"
. Assuming the backends returns the proper data we're done, when we choose a value for the make select, models is updated with the appropriate options.
What does it look like in Drupal? Let's start with the form, this is what renders something like the HTMX example:
function buildForm(array $form, FormStateInterface $form_state) {
$make = ['Audi', 'Toyota', 'BMW'];
$models = [
['A1', 'A4', 'A6'],
['Landcruiser', 'Tacoma', 'Yaris'],
['325i', '325ix', 'X5'],
];
$form['make'] = [
'#title' => 'Make',
'#type' => 'select',
'#options' => $make,
];
$form['model'] = [
'#title' => 'Models',
'#type' => 'select',
'#options' => $models[$form_state->getValue('make', 0)] ?? [],
// We'll need that later.
'#wrapper_attributes' => ['id' => 'models-wrapper'],
];
return $form;
}
This is not a complete form, the submit button is missing for starters, and usually I'd add a few more things like description translation support and so on. I'm trying to keep this easy to understand. The form is not the point HTMX is. Now this form exists and we want to add the HTMX magic to it. We could do it manually, by adding the attributes in $form['make']['#attributes']
, attaching the HTMX library… or we can use the new Htmx object:
(new Htmx())
// An empty method call uses the current URL.
->post()
// Same as the HTMX example we target the select.
->target('[name="model"]')
// Here the response contains the whole form,
// let's use only what we need.
->select('[name="model"] option')
// This is where the magic happens, data attributes are
// added and the JS library is attached.
->applyTo($form['make']);
Pretty close to the HTMX example no? We're submitting the form using POST because of Drupal form handling, it can work with GET but there is some more backend code to deal with. Conceptually it's the same, submit the form, get the response and update the list of options.
This is working but it can prevent things like inline form errors to show up. Ideally we'd want to target the wrapper of the select so that errors are added when necessary, it becomes:
(new Htmx())
->post()
// We select the wrapper around the select.
->target(':has(>[name="model"])')
// And replace the whole wrapper
// not simply updating the options in place.
->select(':has(>[name="model"])')
// We replace the whole element for this form.
->swap('outerHTML')
->applyTo($form['make']);
Now that CSS selector is clever, and clever is not great for maintainability. There is a different way to do the same thing. Instead of declaring what and where we want things replaced, we could also let the response tell us what needs to happen. We can do that with an out of band swap:
(new Htmx())
// This select triggers the request,
->post()
// but it doesn't do anything with the response.
->swap('none')
->applyTo($form['make']);
# Later in the form for the models select
(new Htmx())
// We tell HTMX to replace this element by relying
// on the element id we added earlier.
->swapOob(TRUE)
// We use #wrapper_attributes instead of the clever CSS rule,
// this makes sure inline form errors are properly added.
->applyTo($form['model'], '#wrapper_attributes');
There are several ways of doing the same thing, and we don't have a recommendation yet! So feel free to make up your mind on what you prefer.
For a feature complete implementation of cascading selects, with browser history support, check out /admin/config/development/configuration/single/export in Drupal 11.3
As for our form, here is the complete version
function buildForm(array $form, FormStateInterface $form_state) {
$make = ['Audi', 'Toyota', 'BMW'];
$models = [
['A1', 'A4', 'A6'],
['Landcruiser', 'Tacoma', 'Yaris'],
['325i', '325ix', 'X5'],
];
$form['make'] = [
'#title' => 'Make',
'#type' => 'select',
'#options' => $make,
];
(new Htmx())
->post()
->swap('none')
->applyTo($form['make']);
$form['model'] = [
'#title' => 'Models',
'#type' => 'select',
'#options' => $models[$form_state->getValue('make', 0)] ?? [],
'#wrapper_attributes' => ['id' => 'models-wrapper'],
];
(new Htmx())
->swapOob(TRUE)
->applyTo($form['model'], '#wrapper_attributes');
return $form;
}