Skip to content

Latest commit

 

History

History
263 lines (198 loc) · 8.6 KB

File metadata and controls

263 lines (198 loc) · 8.6 KB

Modal dialogs

Modal dialogs are a common UI element that you may need to create using htmx. The htmx modal dialog docs provide a great starting point, but below I will show a complete example in Django, with a small amount of vanilla JS that you have to write once and can re-use for every modal interaction, and a few enhancements.

This example will be more full-featured than the one in the htmx docs, and considers the case where the modal includes a form that may itself need server round-trips before it closes.

Our example will be a page that lists monsters. We then want a button that will load a dialog for adding a new monster. The HTML looks like this:

<button
  hx-trigger="click"
  hx-get="{% url 'modals_create_monster' %}"
  hx-target="body"
  hx-swap="beforeend"
  >
  Add a monster
</button>

It loads the new HTML at the end of the body. We need to ensure that this content doesn’t get displayed, but in this example we are going to lean heavily on <dialog> (see MDN docs for dialog), which thankfully has the desired behaviour of being invisible by default, and also has reached the level of support where it is probably your best option.

Our dialog view code is going to be based on the standard “create form” flow you’ll recognise, with a few changes, which I’ll show a bit later.

The template needs a <dialog>, and also a <form> that will post back to the same view via htmx, replacing the whole contents, using our normal inline partials approach:

<dialog id="dialog-main" data-onload-showmodal>
  {% block dialog-contents %}
    <form
        hx-post="{{ request.get_full_path }}"
        hx-target="#dialog-main"
        hx-vals='{"use_block": "dialog-contents"}'
        hx-swap="innerHTML"
    >
      {{ form.as_p }}

      <button type="submit">Add</button>
    </form>
  {% endblock %}
</dialog>

This will enable our view to do validation as normal, showing the results in the same dialog.

Notice we’ve used hx-post="{{ request.get_full_path }}" rather than the shortcut hx-post=".", because . would refer to the current browser URL, which is the parent page since we didn’t change the URL when we popped up the dialog.

This approach means we need to add the @for_htmx(use_block_from_params=True) decorator to our view.

I’ve added an attribute data-onload-showmodal which is going to trigger the showing of our own modal. We need to call the .showModal() method on the <dialog> element after it loads, which we can do using a very few lines of vanilla JS:

document.body.addEventListener("htmx:afterSettle", function(detail) {
    const dialog = detail.target.querySelector('dialog[data-onload-showmodal]');
    if (dialog) {
        dialog.showModal();
    };
});

Wherever I have action-at-a-distance like this (i.e. the Javascript implementation is not close to the HTML which uses it), I like to use explicit attributes like data-onload-showmodal, even if I always want this behaviour for <dialog> elements, because it makes it much easier to see that something magic is going on, and grep for the code that is causing the behaviour.

It’s also a good idea to ensure clean up happens, by first adding an event handler that will completely remove the dialog HTML from the DOM when the dialog closes:

dialog.addEventListener("close", () => {
  dialog.remove();
});

Finally, we want the dialog to close when the save button is pressed and the object successfully created. We achieve this most easily by having the server return an Hx-Trigger response header and respond to that via Javascript. In addition, since we added an item, the parent page is now out of date, and we also want to trigger the parent page to update somehow. We’ll use another event for that which the parent can subscribe to using an hx-trigger attribute.

So our final view code for the modal looks like this:

@for_htmx(use_block_from_params=True)
def create_monster(request: HttpRequest):
    if request.method == "POST":
        form = CreateMonsterForm(request.POST)
        if form.is_valid():
            monster = form.save()
            return HttpResponse(
                headers={
                    "Hx-Trigger": json.dumps(
                        {
                            "closeModal": True,
                            "monsterCreated": monster.id,
                        }
                    )
                }
            )
    else:
        form = CreateMonsterForm()
    return TemplateResponse(request, "modals_create_monster.html", {"form": form})

To respond to the closeModal trigger, we need this Javascript:

document.body.addEventListener('closeModal', function() {
    document.querySelector('dialog[open]').close();
});

To respond to the monsterCreated event, we need the relevant part of the main page to look something like this, using our normal inline partials pattern:

{% block monster-list %}
  <div
      id="monster-list"
      hx-trigger="monsterCreated from:body"
      hx-get="."
      hx-vals='{"use_block": "monster-list"}'
      hx-target="#monster-list"
      hx-swap="outerHTML"
  >
    {% for monster in monsters %}
       …
    {% endfor %}

  </div>
{% endblock %}

In English: “when the monsterCreated event is triggered on the document body, then do a GET request to the current URL, with additional query parameter use_block=monster-list, which asks the server to render only the monster-list block; the result should be use to replace the outerHTML of the #monster-list DOM element”.

This again requires @for_htmx(use_block_from_params=True) on the list view.

Tips

Dialog elements are now very well supported, and do a lot of things for us, like focus and accessibility. I’ve collected a few more tips if you want to improve the look, and add support for transitions.

Closing

In addition to using Esc button for closing a dialog (which is automatically supported by <dialog>), you can add a no-Javascript close button like this:

<form method="dialog"><button>Close</button></form>

Transitions and styling

You can add a transition for loading and style the dialog with this CSS:

dialog {
    /* Override some builtins that limit us: */
    max-height: 100vh;
    max-width: 100vw;

    /* Positioning */
    box-sizing: border-box;
    width: calc(100vw - 40px);
    height: calc(100vh - 40px);
    top: 20px;
    left: 20px;
    position: fixed;
    margin: 0;

    /* Styling */
    border: 0;
    border-top: 2px solid #888;
    padding: 20px;

    /* Fade in: */
    display: flex;  /* for some reason, display: block disables the transition. */
    flex-direction: column;
    opacity: 0;
    transition: opacity 0.15s;
    pointer-events: none; /* necessary or the main page becomes inaccessible after closing dialog */
}

dialog[open] {
    opacity: 1;
    pointer-events: inherit;
}

dialog::backdrop {
    background-color: #0008;
}

(Thanks to this Stackoverflow answer)

Reusing

If you have a standard dialog format you want to use, you can use normal Django template inheritance to define your modal templates, with the <dialog> in the parent and blocks to override for the content.

Related patterns

If your modal is simply a confirmation prompt, I would instead use the hx-confirm, or build something using the hx:confirm event.

Full code