Modal
A dialog window that can be used to display a message or request user input.
Demo
Related Components
This requires the following components to be installed:
Related Composables
This requires the following composables to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { ComponentConfig } from '@/ui/utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/ui/components/Button.vue'
import { usePortal } from '@/ui/composables/usePortal'
import { closeIcon } from '@/ui/icons'
import theme from '@/ui/theme/modal'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type Modal = ComponentConfig<typeof theme>
export interface ModalProps extends DialogRootProps {
title?: string
description?: string
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
overlay?: boolean
transition?: boolean
fullscreen?: boolean
portal?: boolean | string | HTMLElement
close?: boolean | Partial<ButtonProps>
closeIcon?: string
dismissible?: boolean
class?: any
ui?: Modal['slots']
}
export interface ModalEmits extends DialogRootEmits {
'after:leave': []
}
export interface ModalSlots {
default: (props: { open: boolean }) => any
content: (props?: object) => any
header: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }) => any
body: (props?: object) => any
footer: (props?: object) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<ModalProps>(), {
close: true,
portal: true,
overlay: true,
transition: true,
modal: true,
dismissible: true,
})
const emits = defineEmits<ModalEmits>()
const slots = defineSlots<ModalSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
const events = {
closeAutoFocus: (e: Event) => e.preventDefault(),
}
if (!props.dismissible) {
return {
pointerDownOutside: (e: Event) => e.preventDefault(),
interactOutside: (e: Event) => e.preventDefault(),
escapeKeyDown: (e: Event) => e.preventDefault(),
...events,
}
}
return events
})
const ui = computed(() => tv(theme)({
transition: props.transition,
fullscreen: props.fullscreen,
}))
</script>
<!-- eslint-disable vue/custom-event-name-casing -->
<template>
<DialogRoot v-slot="{ open }" v-bind="rootProps">
<DialogTrigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" />
</DialogTrigger>
<DialogPortal v-bind="portalProps">
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
<DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
<DialogTitle v-if="title || !!slots.title">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</VisuallyHidden>
<slot name="content">
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
<slot name="header">
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</div>
<DialogClose v-if="close || !!slots.close" as-child>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="ghost"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
/>
</slot>
</DialogClose>
</slot>
</div>
<div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
<slot name="body" />
</div>
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
<slot name="footer" />
</div>
</slot>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { ComponentConfig } from '@/UI/Utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/UI/Components/Button.vue'
import { usePortal } from '@/UI/Composables/usePortal'
import { closeIcon } from '@/UI/icons'
import theme from '@/UI/Theme/modal'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type Modal = ComponentConfig<typeof theme>
export interface ModalProps extends DialogRootProps {
title?: string
description?: string
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
overlay?: boolean
transition?: boolean
fullscreen?: boolean
portal?: boolean | string | HTMLElement
close?: boolean | Partial<ButtonProps>
closeIcon?: string
dismissible?: boolean
class?: any
ui?: Modal['slots']
}
export interface ModalEmits extends DialogRootEmits {
'after:leave': []
}
export interface ModalSlots {
default: (props: { open: boolean }) => any
content: (props?: object) => any
header: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }) => any
body: (props?: object) => any
footer: (props?: object) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<ModalProps>(), {
close: true,
portal: true,
overlay: true,
transition: true,
modal: true,
dismissible: true,
})
const emits = defineEmits<ModalEmits>()
const slots = defineSlots<ModalSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
const events = {
closeAutoFocus: (e: Event) => e.preventDefault(),
}
if (!props.dismissible) {
return {
pointerDownOutside: (e: Event) => e.preventDefault(),
interactOutside: (e: Event) => e.preventDefault(),
escapeKeyDown: (e: Event) => e.preventDefault(),
...events,
}
}
return events
})
const ui = computed(() => tv(theme)({
transition: props.transition,
fullscreen: props.fullscreen,
}))
</script>
<!-- eslint-disable vue/custom-event-name-casing -->
<template>
<DialogRoot v-slot="{ open }" v-bind="rootProps">
<DialogTrigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" />
</DialogTrigger>
<DialogPortal v-bind="portalProps">
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
<DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
<DialogTitle v-if="title || !!slots.title">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</VisuallyHidden>
<slot name="content">
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
<slot name="header">
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</div>
<DialogClose v-if="close || !!slots.close" as-child>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="ghost"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
/>
</slot>
</DialogClose>
</slot>
</div>
<div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
<slot name="body" />
</div>
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
<slot name="footer" />
</div>
</slot>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>Theme
ts
export default {
slots: {
overlay: '',
content: '',
header: '',
wrapper: '',
body: '',
footer: '',
title: '',
description: '',
close: '',
},
variants: {
transition: {
true: {
overlay: '',
content: '',
},
},
fullscreen: {
true: {
content: '',
},
false: {
content: '',
},
},
},
}View Nuxt UI theme
ts
export default {
slots: {
overlay: 'fixed inset-0 bg-elevated/75',
content: 'fixed bg-default divide-y divide-default flex flex-col focus:outline-none',
header: 'flex items-center gap-1.5 p-4 sm:px-6 min-h-16',
wrapper: '',
body: 'flex-1 overflow-y-auto p-4 sm:p-6',
footer: 'flex items-center gap-1.5 p-4 sm:px-6',
title: 'text-highlighted font-semibold',
description: 'mt-1 text-muted text-sm',
close: 'absolute top-4 end-4',
},
variants: {
transition: {
true: {
overlay: 'data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in]',
content: 'data-[state=open]:animate-[scale-in_200ms_ease-out] data-[state=closed]:animate-[scale-out_200ms_ease-in]',
},
},
fullscreen: {
true: {
content: 'inset-0',
},
false: {
content: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[calc(100vw-2rem)] max-w-lg max-h-[calc(100dvh-2rem)] sm:max-h-[calc(100dvh-4rem)] rounded-lg shadow-lg ring ring-default',
},
},
},
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import Modal from '@/ui/components/Modal.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('modal', () => {
const props = { open: true, portal: false }
it.each<[string, RenderOptions<typeof Modal>]>([
// Props
['with open', { props }],
['with title', { props: { ...props, title: 'Title' } }],
['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
['with fullscreen', { props: { ...props, fullscreen: true, title: 'Title', description: 'Description' } }],
['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with content slot', { props, slots: { content: () => 'Content slot' } }],
['with header slot', { props, slots: { header: () => 'Header slot' } }],
['with title slot', { props, slots: { title: () => 'Title slot' } }],
['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with close slot', { props, slots: { close: () => 'Close slot' } }],
['with body slot', { props, slots: { body: () => 'Body slot' } }],
['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
])('renders %s correctly', async (name, options) => {
const { html } = render(Modal, options)
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).matchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Modal from '@/UI/Components/Modal.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('modal', () => {
const props = { open: true, portal: false }
it.each<[string, RenderOptions<typeof Modal>]>([
// Props
['with open', { props }],
['with title', { props: { ...props, title: 'Title' } }],
['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
['with fullscreen', { props: { ...props, fullscreen: true, title: 'Title', description: 'Description' } }],
['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with content slot', { props, slots: { content: () => 'Content slot' } }],
['with header slot', { props, slots: { header: () => 'Header slot' } }],
['with title slot', { props, slots: { title: () => 'Title slot' } }],
['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with close slot', { props, slots: { close: () => 'Close slot' } }],
['with body slot', { props, slots: { body: () => 'Body slot' } }],
['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
])('renders %s correctly', async (name, options) => {
const { html } = render(Modal, options)
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).matchSnapshot()
})
})