Building a confirm dialog flow with htmx

When re-building docket with htmx, I wanted to retain the confirm dialog experience I had with react. Instead of taking on a dependency for this, I chose to build my own combining HTMX and Webcomponents. The resulting UX feels snappy and similar to a client rendered experience. My end result looks like this:

These kinds of dialogs get used all the time for confirming destructive actions – like my task delete confirmation above.

HTML and CSS

Starting off with the HTML and CSS, we have a basic container that is our webcomponent:

Show Plain Text
  1. <modal-window open="true">
  2.     <?= $this->fetch('content') ?>
  3. </modal-window>

This is a ‘layout’ file from my PHP templates. $this->fetch() reads a block of content from the template that is rendered into the layout. Inside my layout, I place the HTML for the contents of the dialog including the dialog element.

Show Plain Text
  1. // Render into the modal layout
  2. $this->setLayout('modal');
  3.  
  4. // Set resource specific content
  5. $target = ['_name' => 'tasks:delete', 'id' => $task->id];
  6. $title = 'Are you sure?';
  7. $description = 'This will also delete all subtasks this task has.';
  8.  
  9. // HTML and PHP template
  10. <dialog class="confirm-dialog" data-testid="confirm-dialog">
  11.     <?= $this->Form->create(null, ['url' => $target]) ?>
  12.     <h2><?= $title ?></h2>
  13.     <p><?= $description ?></p>
  14.     <div class="button-bar-right">
  15.         <?= $this->Html->link('Cancel', '#', [
  16.             'modal-close' => '1',
  17.             'class' => 'button button-muted',
  18.             'data-testid' => 'confirm-cancel',
  19.             'tabindex' => 0,
  20.         ]) ?>
  21.         <?= $this->Form->button('Ok', [
  22.             'type' => 'submit',
  23.             'class' => 'button button-danger',
  24.             'data-testid' => 'confirm-proceed',
  25.         ]) ?>
  26.     </div>
  27.     <?= $this->Form->end() ?>
  28. </dialog>

This template is rendered by a controller action that does a record load, authorization check that looks like.

Show Plain Text
  1. // TasksController
  2. public function deleteConfirm(string $id): void
  3. {
  4.     $task = $this->Tasks->get($id, contain: ['Projects']);
  5.     $this->Authorization->authorize($task, 'delete');
  6.  
  7.     // Set values to the template context
  8.     $this->set('task', $task);
  9. }

This method is exposed at the URL /tasks/1/deleteconfirm. I’ve authored the CSS with sass. There are a few mixins for common button styles and some sass variables that reduce magic numbers:

Show Plain Text
  1. .modal-float {
  2.   outline: none;
  3.  
  4.   position: absolute;
  5.   top: 5vh;
  6.   left: 5vh;
  7.   right: 5vh;
  8.   bottom: 0;
  9.  
  10.   border: none;
  11.   padding: 45px 20px 20px 20px;
  12.   background: var(--color-bg);
  13.   border-radius: $border-radius-large $border-radius-large 0 0;
  14.   box-shadow: var(--shadow-high);
  15. }
  16.  
  17. .modal-title {
  18.   display: flex;
  19.   justify-content: space-between;
  20.   align-items: center;
  21.  
  22.   margin-bottom: calc($space * 3);
  23.  
  24.   h1, h2, h3, h4 {
  25.     margin: 0;
  26.   }
  27. }
  28.  
  29. .modal-close {
  30.   @extend .button-bare;
  31.  
  32.   display: flex;
  33.   justify-content: center;
  34.   position: relative;
  35.   top: -2px;
  36.   right: -5px;
  37.  
  38.   border: none;
  39.   border-radius: 50%;
  40.   font-size: $font-size-large;
  41.   line-height: $font-size-large;
  42.   height: 30px;
  43.   width: 30px;
  44.   padding: 5px;
  45.   margin: 0;
  46.   box-shadow: none;
  47. }
  48.  
  49. .modal-contents {
  50.   height: 100%;
  51.   overflow: auto;
  52. }
  53.  
  54. @media (max-width: $breakpoint-phone) {
  55.   .modal-float {
  56.     top: $space * 2;
  57.     left: 5px;
  58.     right: 5px;
  59.  
  60.     padding: 45px $space $space $space;
  61.   }
  62. }
  63.  
  64. .modal-float {
  65.   top: 5vh;
  66.   left: 5vh;
  67.   right: 5vh;
  68.   bottom: auto;
  69.   border-radius: $border-radius-large;
  70.   padding: 20px 20px 20px 20px;
  71. }
  72. @media (max-width: $breakpoint-phone) {
  73.   .modal-float {
  74.     top: 1vh;
  75.     left: 1vh;
  76.     right: 1vh;
  77.   }
  78. }

I also use CSS custom properties to enable dark mode, and theming. For layout and spacing I use sass variables.

The ModalWindow component

Back in our HTML, we used a custom component modal-window. We now need to define the behavior of that component. I’ve chosen to use the basic webcomponent APIs as I wanted to learn the ‘hard way’ to better understand how the basic APIs worked. I’m still early in my webcomponents learning, and I want to understand the pain points of the browser APIs before learning the abstractions. This is the current state of my homebrew ModalWindow component. It has some rough corners with regards to usability, but as a solo recreational project it fits my needs well enough.

Show Plain Text
  1. // Define a subclass of HTMLElement
  2. class ModalWindow extends HTMLElement {
  3.   // This is effectively onMount callback.
  4.   connectedCallback() {
  5.     const open = this.getAttribute('open');
  6.     const dialog = this.querySelector('dialog');
  7.     if (open && dialog) {
  8.       dialog.showModal();
  9.     }
  10.     this.setupClose(dialog);
  11.     this.closeOnSubmit(dialog);
  12.   }
  13.  
  14.   // You can easily query your subtree to attach behavior based on custom attributes.
  15.   setupClose(dialog: HTMLDialogElement | null): void {
  16.     const closer = this.querySelector('[modal-close="true"]');
  17.     if (!closer) {
  18.       return;
  19.     }
  20.     closer.addEventListener(
  21.       'click',
  22.       evt => {
  23.         evt.preventDefault();
  24.         if (dialog) {
  25.           dialog.close();
  26.           dialog.remove();
  27.         }
  28.       },
  29.       false
  30.     );
  31.   }
  32.  
  33.   // Because we want all modal windows to have a distinct URL we can use
  34.   // back() for cancel and submit forms to take actions.
  35.   closeOnSubmit(dialog: HTMLDialogElement | null): void {
  36.     const form = this.querySelector('form');
  37.     if (!form || !dialog) {
  38.       return;
  39.     }
  40.     dialog.addEventListener('submit', () => dialog.close(), false);
  41.   }
  42. }
  43.  
  44. // Attach our HTMLElement to the DOM.
  45. customElements.define('modal-window', ModalWindow);
  46.  
  47. // Export for testing or re-use elsewhere.
  48. export default ModalWindow;

I appreciate how well the browser native APIs for querying and manipulating the DOM work these days. Being able to write simple Javascript components without libraries is lovely. I do however, like using typescript. I find it useful when learning new browser APIs via the methods and properties summaries. I also appreciate typescript’s ability to detect entire classes of problems.

This is a fairly simple component, because the recently added dialog element is doing a lot of the lifting for us. dialog is a fairly new component that is part of the work being done to add more foundational UX patterns in the DOM. It is handling showing the ‘backdrop’ and capturing clicks. Within the DOM dialog’s also expose methods to hide and show the dialog.

Connecting the interaction

We can now have pages that contain links to start a destructive action. This is a GET request that returns a form within a modal-window. If the user confirms the delete, they submit the form in a POST request. With the confirmation complete, we redirect the client back to their referrer or a collection view. When we fetch the confirm view, we’re going to use htmx to splice the response into the DOM. We’ll start by fetching our confirmation modal with a link

Show Plain Text
  1. <a href="<?= $this->Url->build($deleteConfirmUrl) ?>"
  2.   hx-get="<?= $this->Url->build($deleteConfirmUrl) ?>"
  3.   hx-target="body"
  4.   hx-swap="beforeend"
  5. >
  6.     Delete Task
  7. </a>

We’ve attached htmx to this link. Instead of directing the entire viewport to this URL, htmx will fetch content from the provided URL in the background and can insert it that content into the DOM anywhere you’d like. Here, I’ve just appended the modal window to the bottom of the DOM. When the HTML from our response is inserted into the DOM, our webcomponent will trigger connectedCallback and show the dialog.

I am pretty happy with the UX this provides. It has lots of opportunity for improvement and I plan to tinker on it for a while longer. Hopefully it can show how much UX you can build with webcomponents and a small amount of htmx on top. Not every application needs the power of react or vue, and those scenarios can be a great fit for htmx to improve the interactions in your application with server rendered HTML.

Comments

Just saw your presentation from Cakefest on YouTube this week, I had never really heard of htmx before but had been looking at libraries like Inertia.js (via the cakephp-inertia plugin) to do something similar. Htmx looks like a lightweight alternative.

Thanks for all your work, hopefully next year I can make it out to the conference in person.

Zoltan on 7/31/24

Have your say: