Recipes

Form modal with validation

A modal that hosts a form, returns validated data via confirm(data), and guards against accidental dirty-close.

Form modal with validation

This recipe shows a form inside a modal that:

  • Returns typed, validated data via confirm(data).
  • Warns the user before closing with unsaved changes (Esc, overlay click, programmatic close()).
  • Supports an initialValue prop for pre-filling (edit flows).
  • Submits asynchronously before resolving.

Group registration

main.ts
import type { DefineGroups } from '@kolirt/vue-modal'

declare module '@kolirt/vue-modal' {
  interface ModalGroupRegistry extends DefineGroups<['forms']> {}
}
App.vue
<script setup lang="ts">
import { ModalOverlay, ModalTarget } from '@kolirt/vue-modal'
</script>

<template>
  <RouterView />
  <ModalTarget group="forms">
    <ModalOverlay class="bg-black/40" />
  </ModalTarget>
</template>

The modal component

<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import {
  ModalRoot,
  ModalContent,
  ModalTitle,
  useModalContext
} from '@kolirt/vue-modal'

defineOptions({ modalGroup: 'forms' })

export interface ProfileData {
  name: string
  email: string
}

const props = withDefaults(
  defineProps<{ initialValue?: Partial<ProfileData> }>(),
  { initialValue: () => ({}) }
)

// Typed result — confirm() only accepts ProfileData
const { close, confirm, onBeforeClose } = useModalContext<ProfileData>()

const form = reactive<ProfileData>({
  name: props.initialValue.name ?? '',
  email: props.initialValue.email ?? ''
})

const errors = reactive({ name: '', email: '' })
const submitting = ref(false)
const serverError = ref('')

const isDirty = computed(
  () =>
    form.name !== (props.initialValue.name ?? '') ||
    form.email !== (props.initialValue.email ?? '')
)

// Guard: if the form is dirty, ask before closing
onBeforeClose(async () => {
  if (!isDirty.value) return true
  return window.confirm('You have unsaved changes. Discard them?')
})

function validate(): boolean {
  errors.name = form.name.trim() ? '' : 'Name is required.'
  errors.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)
    ? ''
    : 'Enter a valid email address.'
  return !errors.name && !errors.email
}

async function submit() {
  serverError.value = ''
  if (!validate()) return

  submitting.value = true
  try {
    await api.users.updateProfile(form)
    // Resolve the modal with the saved data — bypass the dirty-form guard
    // since the changes have just been persisted
    confirm({ name: form.name, email: form.email }, { ignoreGuard: true })
  } catch (e: any) {
    serverError.value = e?.message ?? 'Something went wrong.'
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <ModalRoot class="flex items-center justify-center p-4">
    <ModalContent class="relative w-full max-w-md rounded-2xl bg-white shadow-2xl p-8 space-y-6">
        <ModalTitle class="text-xl font-bold text-gray-900">Edit profile</ModalTitle>

        <form class="space-y-4" @submit.prevent="submit">
          <div>
            <label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
            <input
              v-model="form.name"
              type="text"
              class="w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
              :class="errors.name ? 'border-red-400' : 'border-gray-300'"
              placeholder="Jane Doe"
            />
            <p v-if="errors.name" class="mt-1 text-xs text-red-500">{{ errors.name }}</p>
          </div>

          <div>
            <label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
            <input
              v-model="form.email"
              type="email"
              class="w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
              :class="errors.email ? 'border-red-400' : 'border-gray-300'"
              placeholder="jane@example.com"
            />
            <p v-if="errors.email" class="mt-1 text-xs text-red-500">{{ errors.email }}</p>
          </div>

          <p v-if="serverError" class="text-sm text-red-600 bg-red-50 rounded-lg px-3 py-2">
            {{ serverError }}
          </p>

          <div class="flex justify-end gap-3 pt-2">
            <button
              type="button"
              class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50"
              @click="close()"
            >
              Cancel
            </button>
            <button
              type="submit"
              class="rounded-lg px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
              :disabled="submitting"
            >
              <svg v-if="submitting" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
              </svg>
              Save changes
            </button>
          </div>
        </form>
    </ModalContent>
  </ModalRoot>
</template>

How onBeforeClose works

onBeforeClose(handler) registers a guard that runs before the modal closes for any reason — Esc, overlay click, or programmatic close(). Return false (or a promise resolving to false) to cancel the close.

onBeforeClose(async () => {
  if (!isDirty.value) return   // allow close
  return window.confirm('Discard changes?')
  // false → close is cancelled, true / undefined → allowed
})
Both close() and confirm(data) run onBeforeClose guards. To bypass them (e.g. on a successful save where you want to skip the dirty-check), pass { ignoreGuard: true }: confirm(data, { ignoreGuard: true }).

Using useModal for repeated opens

If you open the same form many times from one component, useModal keeps the handle reactive and cleans up on unmount:

const editModal = useModal<ProfileData>(EditProfileModal, {
  props: { initialValue: user.value }
})

// editModal.isOpen — reactive boolean
await editModal.open({ props: { initialValue: freshUser } })
Copyright © 2026