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.
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?
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.
Source code on Github
Adam’s post on Building a dialog component