DEV Community

Blaine
Blaine

Posted on

Using the new Native Dialog API as a reusable component in Vue 3

📝 The Native HTML Dialog

We've all heard the buzz around browser native dialogs which are now widely supported across all modern browsers. Now you no longer need to depend on third party packages to achieve a user friendly dialog/popover experience.

Now if you are just here for the code, here it is below. However, if you are here to understand how we got here, stick to the end!

<script setup lang="ts">
import { ref } from 'vue';

const dialog = ref<HTMLDialogElement>();

const props = defineProps({
  classes: {
    type: String,
    default: "",
  },
});

const visible = ref(false);

const showModal = () => {
  dialog.value?.showModal();
  visible.value = true;
};

defineExpose({
  show: showModal,
  close: (returnVal?: string): void => dialog.value?.close(returnVal),
  visible,
});
</script>

<template>
  <dialog
    ref="dialog"
    @close="visible = false"
  >
    <form
      v-if="visible"
      method="dialog"
      :class="{
        [props.classes]: props.classes,
      }"
    >
      <slot />
    </form>
  </dialog>
</template>
Enter fullscreen mode Exit fullscreen mode

👷 The Breakdown

The new Dialog API is pretty straightforward. As seen from the MDN Page, all you need to get started is a simple <dialog> element. Any reference to this element will have programatic access to the function .showModal() to toggle the dialog, so you could do this with a button!.

Vanilla HTML

Lets take a look at the provided example

<dialog open>
  <p>Greetings, one and all!</p>
  <form method="dialog">
    <button>OK</button>
  </form>
</dialog>
Enter fullscreen mode Exit fullscreen mode

Do note the additional comment on MDN,

Because this dialog was opened via the open attribute, it is non-modal. In this example, when the dialog is dismissed, no method is provided to re-open it. Opening dialogs via HTMLDialogElement.show() is preferred over the toggling of the boolean open attribute.

So although this is a good demonstration of the result, programatic access is suggested.

Looking closer, other than the open attribute, we have

  1. A form component, with method="dialog". This is a new form method, which on form submission, automatically closes the dialog with a submit event.
  2. Accessibility, pressing tab will toggle only through the form elements in the dialog.
  3. The dialog content will be shown in a new layer, which can be visualized from the browsers dev tools.

🤖 Difference between show and showModal

There are two ways to open the dialog.

  1. .show() will toggle open the dialog, but
    • Esc will not close the element.
    • Backdrop will not be darkened (default styling).
  2. .showModal() will toggle open the dialog in the modal mode,
    • Esc will close the modal
    • Backdrop will be darkened in default styling. (The styling can be changed).

⛵️ Bringing in Vue

Lets be real, you are probably not working on a project with vanilla HTML, it is nice but does not scale. You might be using a Frontend library, probably something among React, Vue or Svelte. I will be covering Vue here, but the design pattern should be similar in Svelte. Pretty sure, React folk don't have to worry about not using Third Party packages :P.

A simple solution in Vue is to just slap a <slot />, have a two way binding to the open attribute via props and call it a day. Lets look at this example

<script setup lang="ts">
const props = defineProps<{
   open: boolean
}>();
</script>

<template>
<dialog :open="props.open">
  <form method="dialog">
    <slot />
  </form>
</dialog>
</template>
Enter fullscreen mode Exit fullscreen mode

This is a good enough solution but lets see if we can do better!

💪 Trying to do better

We will try to

  1. Adhere to using programmatic access.
  2. See if we can mount the child component only when the modal is open, so that any mount hooks run only when needed.

To achieve the first we shall use a ref. This way we get a ref to the dialog element, to be able to call the showModal function. We avoid ids, because we want this re-usable! Plus avoid document.querySelector makes it SSR safe?. Don't quote me on that.

<script setup lang="ts">
import { ref } from 'vue';

const dialog = ref<HTMLDialogElement>();
....
</script>
<template>
<dialog
    ref="dialog"
>
   ...
</dialog>
</template>
Enter fullscreen mode Exit fullscreen mode

We then declare a function to call showModal(), then expose that function to parent components via defineExpose().

const showModal = () => {
  dialog.value?.showModal();
};

defineExpose({
  show: showModal
});
Enter fullscreen mode Exit fullscreen mode

The parent component can then bind a ref to the modal and toggle dialog as needed.

import Modal from 'path/to/Modal.vue';

const modal = ref<InstanceType<typeof Modal>>();

const showModal = () => modal.value?.show();
Enter fullscreen mode Exit fullscreen mode

With that complete lets also get conditional loading so that mount hooks work proper (otherwise this is equivalent to a v-show)

We create a state called visible and toggle that when state of dialog changes. We shall set visible to true, when we show the modal, and use the close event to toggle it to false.

<script setup lang="ts">
...
const visible = ref(false);

const showModal = () => {
  dialog.value?.showModal();
  visible.value = true;
};
...
</script>
<template>
  <dialog
    ref="dialog"
    @close="visible = false"
  >
    <form
      v-if="visible"
      method="dialog"
    >
      <slot />
    </form>
  </dialog>
</template>
Enter fullscreen mode Exit fullscreen mode

and there's that! Note that if you still want a cleaner approach, you can still use the open prop for conditional rendering as well as the open attribute but do note that some of the accessibility will be lost.

<script setup lang="ts">
const props = defineProps<{
   open: boolean
}>();
</script>

<template>
<dialog :open="props.open">
  <form v-if="props.open" method="dialog">
    <slot />
  </form>
</dialog>
</template>
Enter fullscreen mode Exit fullscreen mode

the final result is the code at the very start of the guide!

🎨 With DaisyUI and TailwindCSS!

DaisyUI v3 supports native modals! Here is a quick custom component with slots and styles to make life easier for you!

<script setup lang="ts">
import { ref } from 'vue';

const dialog = ref<HTMLDialogElement>();

const props = defineProps({
  confirmText: {
    type: String,
    default: 'Confirm',
  },
  cancelText: {
    type: String,
    default: 'Cancel',
  },
  hideConfirm: {
    type: Boolean,
    default: false,
  },
  showCancel: {
    type: Boolean,
    default: false,
  },
  classes: {
    type: String,
    default: '',
  },
});

const emit = defineEmits(['confirm', 'cancel']);

const cancel = () => {
  dialog.value?.close();
  emit('cancel');
};

const confirm = () => {
  dialog.value?.close();
  emit('confirm');
};

const visible = ref(false);

const showModal = () => {
  dialog.value?.showModal();
  visible.value = true;
};

defineExpose({
  show: showModal,
  close: (returnVal?: string): void => dialog.value?.close(returnVal),
  visible,
});
</script>

<template>
  <dialog
    ref="dialog"
    class="modal modal-bottom sm:modal-middle"
    @close="visible = false"
  >
    <form
      v-if="visible"
      method="dialog"
      :class="{
        'modal-box rounded-none p-4': true,
        [props.classes]: props.classes,
      }"
    >
      <slot />

      <div class="modal-action" v-if="!props.hideConfirm || props.showCancel">
        <slot name="footer" />
        <slot name="actionButtons">
          <button
            v-if="props.showCancel"
            value="false"
            class="btn"
            @click.prevent="cancel"
          >
            {{ props.cancelText }}
          </button>
          <button
            v-if="!props.hideConfirm"
            value="true"
            class="btn btn-primary"
            @click.prevent="confirm"
          >
            {{ props.confirmText }}
          </button>
        </slot>
      </div>
    </form>
    <form method="dialog" class="modal-backdrop">
      <button>close</button>
    </form>
  </dialog>
</template>
Enter fullscreen mode Exit fullscreen mode

Here is a stackblitz for the same

Closing

Thanks so much for reading! If you have any improvements on the above do share in the comments. As for me, I really love the new native dialog, and the future things coming to vanilla HTML, like the Popover element bring simpler tooltips and page transitions which may enable frameworks like astro to simplify page transitions via CSS where JS libraries like SWUP which takeover client side page navigation are the only way to transition.

Top comments (3)

Collapse
 
theathleticnerd profile image
Job Fernandez Shoban

Thank you for the wonderful article. It helped me.
I tried the same thing in Nuxt 3,the modal opens up but clicking outside doesn't close the modal. Do you have any idea on how to fix this?

Collapse
 
blainesensei profile image
Blaine • Edited

Sorry for such a late response! But It is also possible to do this natively, with some CSS magic (inspired by daisyui daisyui.com/components/modal/#dial...)

Here is the updated stackblitz stackblitz.com/edit/vitejs-vite-ih...

edit: Even if you are not using DaisyUI you should be able to replicate the css.

Collapse
 
xfran profile image
Fran • Edited

Hi there, there is a nice directive in VueUse that you can use for that:
import { vOnClickOutside } from '@vueuse/components'

and then use
dialog v-on-click-outside="close"

FYI: It is not part of the @vueuse/nuxt module, you need to install it (npm i --savedev @vueuse/components) and then import it as it will not be autoimported by nuxt.