Building a promise based dialog component

In this post I want to share what I built for a client where we needed a lot of in-page contextual user input. When you need the user to perform an in-page action you have many options for solving this but I think the <dialog> element is a good alternative. View the source and try the demo!

Consider the scenario where a user wants to log in or create a profile without changing the page. The dialog can solve this, but it has a few caveats you should know about and is missing some functionality we will add later.

<dialog> has been around for a while and the support is now stable across browsers. There is even a polyfill for solving some of those older browsers.

drawing

This post is heavily influenced by how Adam Argyle built a dialog component in his post. I strongly recommend reading that article as well.

Note that Adam does not rely on a frontend framework in his post but I used Lit.dev + Vite + Typescript to create a more real-world setup(used this scaffold tool from Vite).

 

The application is a copy of Adam’s solution, but polished in order to make it work with Lit, Custom Elements and the use of ShadowDOM where the CSS is isolated and global CSS has no effect on the contents of the element.

We start of with a <profile-list> element that renders user avatars and a button for adding a new user. Clicking the + will open a <dialog> that contains a <form method="dialog"> with input for avatar image and three buttons, Clear that resets the form, Cancel that closes the dialog and Confirm that submits the form AND closes the dialog.

When Confirm is pressed, the dialog animates out and the uploaded avatar is animated into the list of users. Adam goes into details of how to make the <dialog> animate in and out depending on screen size so I recommend checking that out.

This UI is identical to what Adam created in his post with support for light and dark mode.

The next sections are refactorings of Adam’s solution, IMO to make the solution more real world and what you’d expect to see in complex applications. These are also my subjective thoughts on how I think the dialog works best.

 

You could always argue that components already rendered in the DOM is faster to show, but this strategy fails fast when you need multiple versions of dialogs and contents presented in dialogs. Rendering elements in the DOM that is not in use is also not great and does not follow the YAGNI principle.

Consider querying the DOM with document.querySelector('dialog') and you end up with multiple results and many not even visible, but only one is the actual result you wanted?

We should as developers opt for rendering as little as possible and what you render should serve a purpose.

Also, who is responsible for initializing the dialog? In my mind it is often a button somewhere far inside an already complex component. So the responsibility of the <dialog> content and submission should lie where the <dialog> is needed.

class SomeComplexComponent extends HTMLElement {
   ...
   render() {
     return html`
       ...
       <button @click="${this.handleAddProfile}">Add user</button>
     `;
   }
   handleAddProfile() {
     const dialogElement = dialog(renderAddUserDialogContent());
     dialogElement.addEventHandler('submit', this.handleDialogSubmit, { once: true });
   }
   handleDialogSubmit(event: Event) {
     // store and refresh
   }
}

A bit of pseudo code there, but you get the point, create the dialog and handle the response when the form is submitted.

The dialog function is also quite simple but a key point here is that it creates a new <dialog> element for every request for a dialog. This is ok because the dialog will remove itself from the DOM when it’s closed.

function dialog(
  content: TemplateResult
): HTMLDialogElement {
    const renderBuffer = document.createDocumentFragment();
    const dialogContent = html`
      <dialog
         ...
         @close="${function (this: HTMLDialogElement, event: Event) {
            this.remove();
         }}"
      >
         ${content}
      </dialog>
   `;
    render(dialogContent, renderBuffer);
    const dialogElement = renderBuffer.firstElementChild as HTMLDialogElement;
    document.body.appendChild(dialogElement);

    dialogElement.showModal();
    return dialogElement;
  });
}
 

A key benefit of making in-page contextual action is the ability to wait for the user to complete before continuing. We usually solve this by adding an event listener for submit on the dialog or similar and set state internally before updating and continuing. This code is difficult to maintain in the real world since the needs evolve every day.

But what if the button itself could open a dialog and receive the result when the dialog is completed?

Consider what that would look like in pseudo code:

function renderAddUserDialogContent() {
   return html`
      <form method="dialog">
         <input type="file" name="userimage" />
         <button type="submit">Confirm</button>
      </form>
   `;
}
<profile-list>
   ...
   <button @click="${async () => {
      const { data } = await dialog(renderAddUserDialogContent());
      console.log(data);
      // Output
      // { userimage: { name: 'avatar-1.png', size: 1234, type: 'image/png' ... } }
   }}">Add new user</button>
</profile-list>

The renderAddUserDialogContent function returns a template that contains a form with input for file and a submit button that, when clicked, will submit the form.

In order for dialog function to be awaitable, thus a Promise, we need to make some decisions:

  • What should resolve the <dialog>?

  • What should reject the <dialog>?

This may not suite your needs, but let’s assume that submitting the form will resolve the Promise and that cancelling the form will reject the Promise. We can hook into the events for the <form> to know when the form is submitted, but as far as I can see there is no event for cancelled.

So if:

  • <button type=”submit”> will submit the form

  • <button type=”reset”> will clear the form values

  • <button type=”button”> has no default behavior

we chould use <button type=”button”> to cancel the form/dialog.

If we need the button to not cancel the dialog, ie. when clicking tabs inside the dialog, we could introduce an attribute so that we don’t cancel the dialog when event.target.hasAttribute(‘prevent-close’); .

 

So if we update the handleAddProfile function to handle the <dialog> Promise, javascript suspend the click handler until the dialog Promise resolves. The Promise is resolved with the values within the form and some metadata for good measure.

async handleAddProfile() {
    try {
      const { id, formData } = await dialog(renderAddUserDialogContent());
      const file = formData.userimage as File;
      ...
      this.profiles = [
        ...this.profiles,
        {
           id: `profile-${Date.now()}`,
          avatar: loadedAvatarAsDataUrl as string,
        },
      ];

      console.info(`Dialog resolved`, formData);
    } catch (data) {
      console.error(`Dialog rejected`, data);
    }
}
type DialogResultType = 'cancel' | 'submit';
interface DialogResult {
  type: DialogResultType;
  [key: string]: any;
}

async function dialog(
  content: TemplateResult
): Promise<DialogResult> {
  return new Promise<DialogResult>(async (resolve, reject) => {
    const renderBuffer = document.createDocumentFragment();

    render(renderDialog({ content, resolve, reject }), renderBuffer);
    const dialogElement = renderBuffer.firstElementChild as HTMLDialogElement;
    document.body.appendChild(dialogElement);

    dialogElement.showModal();
  });
}

export function renderDialog({
  resolve,
  reject,
  content,
}: {
  content: TemplateResult;
  resolve: (result: DialogResult) => void;
  reject: (result: DialogResult) => void;
}) {
  const id = Date.now();

  async function handleClick(this: HTMLDialogElement, event: MouseEvent) {
    if ((event.target as HTMLButtonElement).type === 'button') {
      reject({ id, type: 'cancel' });
      this.close('Cancelled by user');
    }
  }

  async function handleSubmit(this: HTMLDialogElement, event: Event) {
    resolve({
      id,
      type: 'submit',
      formData: Object.fromEntries(
        new FormData(event.target as HTMLFormElement)
      ),
    });
  }

  async function handleClose(this: HTMLDialogElement, _event: Event) {
    this.remove();
  }

  return html`
    <dialog
      @click="${handleClick}"
      @submit="${handleSubmit}"
      @close="${handleClose}"
    >
      ${content}
    </dialog>
  `;
}

The dialog.returnValue property is set based on the value of the button or input with type=submit or empty string if not set. So using dialog.returnValue to deliver the form data is not possible. The Promise however could deliver a more suitable object structure.

It would be nice if <dialog> dispatched a cancel event to accompany the submit. Then we could handle the reject part of the Promise in the @cancel handler but I can’t find references that this is mentioned or wanted for now.

When testing the dialog with a cross browser test tool like Playwright or Puppeteer we need to add a bit more work in order to be certain that the functionality is running correctly.

Currently, the code explicitly says that we resolve the dialog promise before the dialog is closed or removed.

So, if your use case is to open a new dialog when a dialog is closed, there is a chance that there is a dialog in the DOM when new is added. This might not be fatal, but is something you should consider, and if so, we should move the resolving of the dialog Promise to the @close function. This would however change the feel of the application, where the user needs to wait for the dialog to animate out before the result will appear.

export function renderDialog({
  resolve,
  reject,
  content,
}: {
  content: TemplateResult;
  resolve: (result: DialogResult) => void;
  reject: (result: DialogResult) => void;
}) {
  ...
  function handleClose(this: HTMLDialogElement, _event: Event) {
    // animate out the dialog before resolv/reject-ing
    await animationsComplete(this);
    if (this.dataset.resolution === 'submit') {
      resolve({
        id,
        type: 'submit',
        formData: this.formData,
      });
    } else {
      reject({ id, type: 'cancel' });
    }
    this.remove();
  }

  return html`
    <dialog
      ...
      @close="${handleClose}"
    >
      ${content}
    </dialog>
  `;
}

Conclusion

After creating the promise based dialog for a client, team developers mentioned that it was easy to understand, maintain and that creating new dialogs was an easy task.

Hope this also can help you in creating maintainable production code.

Erik Salhus

Erik Salhus har over 17 års erfaring som systemutvikler og løsningsarkitekt med solid erfaring fra komplekse prosjekter innenfor offentlig og privat sektor som NRK TV, SpareBank1, Gjensidige, Statens Pensjonskasse, Nordea og NorgesGruppen.

Han er en fremoverlent og engasjert utvikler som brenner for faget og streber etter kontinuerlig kompetanseheving på et fag som er under stadig utvikling.
Erik setter brukeren i sentrum og fokuserer på å forstå forretningsbehovene før han anbefaler og implementerer teknisk løsning. Han er opptatt av kvalitet gjennom hele arbeidsprosessen og er flink til å komme med forslag til forbedringstiltak.

Erik har høy arbeidskapasitet, jobber strukturert og ansvarsfullt og er opptatt av å levere resultater innenfor avtalt tid. Han jobber selvstendig, men er også åpen og lett å samarbeide med. Han har lang erfaring med arbeid i miljø hvor automatiserte tester, kontinuerlig utvikling og hyppige lanseringer er standard rutine.
Han har de siste årene hatt stor interesse for ytelse på frontend og streber etter å lage løsningene så "snappy" som mulig.

Forrige
Forrige

Replicating SwiftUI styles for custom components

Neste
Neste

SCIPT 2023 - Poker for veldedighet