Basics In Blazor - Modal

Featured on Hashnode

This post is part of a series called Basics in Blazor, where I explore returning to the simplicity of web UI, preferring semantic HTML and CSS to bloated, complicated, and/or restrictive component libraries. These posts focus on using Blazor to work with native web UI, not fight an uphill battle against it.

To that end, this post aims to show an example of a lightweight, yet powerful Blazor modal, built on top of the html <dialog> element.

The Dialog Element

HTML has out of the box, native dialog element and modal support with just a bit of JavaScript (and our implementation will only use a small amount of this). Let's take a look at the dialog element:

<dialog open>
    <h1>Hello World!</h1>
    <p>This is an open dialog</p>
</dialog>

This creates a dialog that is always open. From here it's trivial to use a conditional to show or hide this in Blazor:

@if (ShouldShowDialog)
{
    <dialog open>
        <h1>Hello World!</h1>
        <p>This is an open dialog
    </dialog>
}

This works, and it's a very "Blazor" way to go about this, but it isn't a modal. It's a dialog, a pop up even, but it doesn't disable the rest of the view, or have other behaviors we typically associate with modals. This modal functionality of the dialog element is available via JavaScript:

const dialogRef = document.querySelector('dialog');
dialogRef.showModal();
dialogRef.close();

If we want this behavior in Blazor, we'll have to utilize the built in JavaScript Interop. The IJSRuntime interface lets us call a JavaScript function asynchronously, so let's add a couple utility methods to do that in a file we will call modal.js

function showModal(modal) { modal.showModal(); }
function closeModal(modal) { modal.close(); }

That's it! Once we've served this up with the rest of our source files, we can craft the following Blazor component, Modal. First the template:

<dialog @ref=DialogRef>
    @ChildContent
</dialog>

I did say this control was going to be very lightweight. We only add 2 things to the dialog element. First we capture a reference to this element called DialogRef and second, we render any child content that was passed to us, in the dialog element. Let's take a look at the code behind file.

public partial class Modal
{
    [Inject]
    public required IJSRuntime JS { get; init; }

    ElementReference DialogRef;

    [Parameter]
    public bool IsOpen { get; set; }
    private bool _isOpen;

    [Parameter]
    public required RenderFragment ChildContent { get; set; }

    protected override Task OnParametersSetAsync()
    {
        await ToggleModal();
        await base.SetParametersAsync(parameters);
    }

    private async Task ToggleModal()
    {
        if (_isOpen == IsOpen) return;

        string jsCommand = IsOpen ? "showModal" : "closeModal";
        await JS.InvokeVoidAsync(jsCommand, DialogRef);
        _isOpen = IsOpen;
    }
}

First, we inject a reference to the IJSRuntime so we can call our JavaScript functions from earlier. Then we provide a field to store the reference to our dialog element. We take in two parameters, IsOpen and ChildContent. ChildContent will contain whatever inner elements were defined when this component was used, IsOpen is the binding we've given the client to determine whether the modal should show or hide.

You may notice the _isOpen backing field doesn't really back the auto-property. Component parameters are recommended to be auto properties, but we need to track the actual state of the modal, not just what our client is requesting the state to be. Because the input parameter is an auto-prop, we can't react to the value changing in the setter, so we override the OnParametersSetAsync() method to call our own ToggleModal().

It's important to note that just because OnParametersSetAsync() is called, it doesn't necessarily mean any of the values have changed, so we check for this case and early return if we don't have anything to do. In the case that the value of IsOpen did change, we execute the correct JavaScript function, passing in our dialog reference as a parameter, and then update our actual state, _isOpen to match the client requested state.

Seriously, That's it.

And just like that, we're ready to put this component in action. Let's drop this component on a page and see how it's used

@page "/"

<PageTitle>Home</PageTitle>

<h3>This is a page</h3>
<button @onclick="ShowModal">Show Modal</button>

<Modal IsOpen=ShouldShowModal>
    <h3>This is a modal</h3>
    <button @onclick="CloseModal">Close Modal</button>
</Modal>
public partial class Home
{
    public bool ShouldShowModal { get; set; } = false;

    public void ShowModal()
    {
        ShouldShowModal = true;
    }
    public void CloseModal()
    {
        ShouldShowModal = false;
    }
}

We throw a button on the page to show the modal, and then define some content for the modal which includes a button to close the modal. We toggle this by toggling the value of ShouldShowModal and the Modal component takes care of the rest.

While we have provided a functional modal, I think it's important to note what we haven't done. We haven't pulled in some bloated and/or restrictive 3rd party component library, we haven't added layers of elements and divs between the parent component and the modal content, and we haven't limited the users control over the modal style, and contents. This latter feature really made this design shine when I tried to take the next step of functionality the HTML dialog element offers: form return values

Getting Data Out

If we take a look at the documentation example for the dialog element from MDN, obtaining and returning the value of form data is a complex process when compared to the showModal() and close() functions we used previously.

<dialog id="favDialog">
  <form>
    <p>
      <label>
        Favorite animal:
        <select>
          <option value="default">Choose…</option>
          <option>Brine shrimp</option>
          <option>Red panda</option>
          <option>Spider monkey</option>
        </select>
      </label>
    </p>
    <div>
      <button value="cancel" formmethod="dialog">Cancel</button>
      <button id="confirmBtn" value="default">Confirm</button>
    </div>
  </form>
</dialog>
<p>
  <button id="showDialog">Show the dialog</button>
</p>
<output></output>

🤢

const showButton = document.getElementById("showDialog");
const favDialog = document.getElementById("favDialog");
const outputBox = document.querySelector("output");
const selectEl = favDialog.querySelector("select");
const confirmBtn = favDialog.querySelector("#confirmBtn");

// "Show the dialog" button opens the <dialog> modally
showButton.addEventListener("click", () => {
  favDialog.showModal();
});

// "Cancel" button closes the dialog without submitting because of [formmethod="dialog"], triggering a close event.
favDialog.addEventListener("close", (e) => {
  outputBox.value =
    favDialog.returnValue === "default"
      ? "No return value."
      : `ReturnValue: ${favDialog.returnValue}.`; // Have to check for "default" rather than empty string
});

// Prevent the "confirm" button from the default behavior of submitting the form, and close the dialog with the `close()` method, which triggers the "close" event.
confirmBtn.addEventListener("click", (event) => {
  event.preventDefault(); // We don't want to submit this fake form
  favDialog.close(selectEl.value); // Have to send the select box value here.
});

🤮

So, I set about doing what I thought I should do; encapsulating all of this and making it easier for a Blazor developer to use. And after a few hours of struggling, it dawned on me: I already had

The same trick I used to pass a button (whose click handler I had bound to a local function) into the modal would work for, in theory, any content. Let's modify our home page example one more time and test my hunch.

@page "/"

<PageTitle>Home</PageTitle>

<h3>This is a page</h3>
<button @onclick="ShowModal">Show Modal</button>

<Modal IsOpen=ShouldShowModal>
    <h3>This is a modal</h3>
    <!-- Add an input element and bind it's value to a property called 'Input' -->
    <input type="text" @bind-value=Input />
    <button @onclick="CloseModal">Close Modal</button>
</Modal>
public partial class Home
{
    public bool ShouldShowModal { get; set; } = false;
    // Add a string property called Input
    public string Input { get; set; } = string.Empty;

    public void ShowModal()
    {
        ShouldShowModal = true;
    }
    public void CloseModal()
    {
        Console.WriteLine($"Input: {Input}");
        ShouldShowModal = false;
    }
}

I tested this and was pleasantly surprised when, in the end, it did turn out to be that simple. The input element binds to our Input even as part of the modal, so the value is already updated when we click close. No need to mess around with form actions, submit values, confusing callbacks or preventing default actions. An entire form, bound to a property of the parent component, can be put into the Modal content.

Conclusion

Whenever I encounter some difficulty, the solution is often "It's easier than I'm making it", and web development is no exception to this rule. In our example here, we've created a modal control that is flexible and powerful enough to be shared throughout your entire application, but small enough to fit in your head.