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
initialValueprop 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>
<script setup lang="ts">
import { openModal, ModalClosedError } from '@kolirt/vue-modal'
import EditProfileModal, { type ProfileData } from './EditProfileModal.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const user = ref({ name: 'Jane Doe', email: 'jane@example.com' })
async function editProfile() {
try {
const saved = await openModal<ProfileData>(EditProfileModal, {
props: { initialValue: user.value }
})
// saved is ProfileData — fully typed
user.value = saved
toast.success('Profile updated')
} catch (e) {
if (e instanceof ModalClosedError) return // user cancelled — that's fine
toast.error('Unexpected error')
}
}
</script>
<template>
<button @click="editProfile">Edit profile</button>
</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 } })
Related
Confirm dialog
A reusable ConfirmDialog component paired with a confirm() helper that returns Promise<boolean>. Covers loading state and destructive variants.
Image lightbox
A full-screen lightbox that navigates a list of images with arrow keys, closes on backdrop or Esc, and transitions instantly between images using replaceModal.
