Modal
A window overlaid on either the primary window or another dialog window.
💡 For in-depth guides and tips when using Modal, please refer to the Modal Mechanism page .
- In
<template>:
<script setup lang="ts">
import { ref, reactive } from 'vue'
import {
HButton, HModal, HSelect, type SelectOption,
} from '@holistics/design-system'
const shown = ref(false)
export interface Info {
role?: string
jobTitle?: string
}
const info = reactive<Info>({
role: undefined,
jobTitle: undefined,
})
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOption[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Open
</HButton>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="info.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="info.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</HModal>
</template>- Programmatically:
<script setup lang="ts">
import { HModalProvider } from '@holistics/design-system'
import Opener from './Opener.vue'
</script>
<template>
<HModalProvider>
<Opener />
</HModalProvider>
</template><script setup lang="ts">
import { HButton, useModal } from '@holistics/design-system'
import Modal from './Modal.vue'
const { open } = useModal()
async function onClick () {
const { state, data } = await open(Modal)
/**
* If dismissed: 'dismiss', undefined
* If resolved: 'resolve', []
*/
console.log(state, data)
}
</script>
<template>
<HButton
type="primary-highlight"
@click="onClick"
>
Open
</HButton>
</template><script setup lang="ts">
import { HModal, HSelect, type SelectOption } from '@holistics/design-system'
import { reactive } from 'vue'
const shown = defineModel<boolean>('shown', { required: true })
export interface Info {
role?: string
jobTitle?: string
}
const info = reactive<Info>({
role: undefined,
jobTitle: undefined,
})
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOption[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</script>
<template>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="info.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="info.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</HModal>
</template>Examples
Overflow Content
<script setup lang="ts">
import { ref } from 'vue'
import { HButton, HModal } from '@holistics/design-system'
const shown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Default
</HButton>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<div class="h-screen w-[100rem] bg-green-50" />
</HModal>
</template><script setup lang="ts">
import { ref } from 'vue'
import { HButton, HModal } from '@holistics/design-system'
const shown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Document Scrollable
</HButton>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
document-scrollable
>
<div class="h-screen w-[100rem] bg-green-50" />
</HModal>
</template>Async Body
Sometimes, we want to lazy load the modal content since there could be a lot of modals in 1 page and not every modal is opened within 1 user session. More importantly, some modals contain a lot of heavy code such as 3rd party libraries, complex components,... Therefore, lazy loading the modal content can significantly help reduce the bundle size to load a page.
<HModal> supports this behavior out of the box and even has animation + loading indicator to bring the best UI/UX to the end-user!
<script setup lang="ts">
import { ref } from 'vue'
import { HButton } from '@holistics/design-system'
import Modal from './Modal.vue'
const shown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Open
</HButton>
<Modal v-model:shown="shown" />
</template><script lang="ts">
/* eslint-disable import/first */
import { defineAsyncComponent } from 'vue'
const ModalBody = defineAsyncComponent({
loader: async () => {
await new Promise((res) => { setTimeout(res, 2000) }) // [!code warning] // ⚠️ Just for demo purpose
return import('./ModalBody.vue')
},
})
</script>
<script setup lang="ts">
import { HModal } from '@holistics/design-system'
const shown = defineModel<boolean>('shown', { required: true })
</script>
<template>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<ModalBody />
</HModal>
</template><script setup lang="ts">
import { reactive } from 'vue'
import { HSelect, type SelectOption } from '@holistics/design-system'
export interface Info {
role?: string
jobTitle?: string
}
const info = reactive<Info>({
role: undefined,
jobTitle: undefined,
})
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOption[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</script>
<template>
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="info.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="info.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</template>Only use defineAsyncComponent in <script>!
This is because in <script setup>, every variable and function will be re-defined again when the component is created and is destroyed after unmount. Therefore, you must define the async body component in the global context via normal <script> instead!
Show/hide modal programmatically
When showing modal programmatically, we also pass the modal component to the open() function. DO NOT defineAsyncComponent it again as all animations won't be applied properly!
Manually handle initial focus
Even though the modal body is lazy loaded, focus trap is still enabled on the container. This means the first tabbable element cannot be automatically focused while the body is loading. You must manually handle the initial focus yourself by using onMounted within the body component or using the @bodyResolved event (remember to nextTick()!).
💡 Quickly pass props to/emit data from the body component
Simply use v-bind="props" and re-emit the @dismiss and @resolve events! You can even define the emits as props with an on prefix to mark them as events for Vue compiler.
However, there's a caveat to this approach that is the @dismiss and @resolve events are originally emited by <HModal> itself which means you cannot leverage automatic Data Flow mechanism. To work-around this, you need to use the no-footer prop and fill the #body slot with the <HModalFooter> components. For example:
<script setup lang="ts">
import { ref } from 'vue'
import { HButton } from '@holistics/design-system'
import Modal from './Modal.vue'
import type { Info } from './ModalBody.vue'
const shown = ref(false)
function onResolve (info: Info) {
console.log(info)
}
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Open
</HButton>
<Modal
v-model:shown="shown"
:info="{ role: 'admin' }"
@resolve="onResolve"
/>
</template><script lang="ts">
/* eslint-disable import/first */
import { computed, defineAsyncComponent } from 'vue'
const ModalBody = defineAsyncComponent({
loader: async () => {
await new Promise((res) => { setTimeout(res, 2000) }) // [!code warning] // ⚠️ Just for demo purpose
return import('./ModalBody.vue')
},
})
</script>
<script setup lang="ts">
import { HModal } from '@holistics/design-system'
import type { Props, Emits } from './ModalBody.vue'
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const shown = defineModel<boolean>('shown', { required: true })
const title = computed(() => {
const { role } = props.info
return 'Some Modal' + (role ? ` (${role})` : '')
})
</script>
<template>
<HModal
v-model:shown="shown"
:title="title"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
no-footer
>
<template #body="{ dismiss, resolve }">
<ModalBody
v-bind="props"
@dismiss="() => { emit('dismiss'); dismiss() }"
@resolve="(info) => { emit('resolve', info); resolve() }"
/>
</template>
</HModal>
</template><script setup lang="ts">
import { HSelect, type SelectOption, HModalFooter } from '@holistics/design-system'
import { ref } from 'vue'
defineOptions({ inheritAttrs: false })
export interface Info {
role?: string
jobTitle?: string
}
export interface Props {
info: Info
}
export type Emits = {
dismiss: []
resolve: [info: Info]
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const infoLocal = ref({ ...props.info })
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOption[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</script>
<template>
<div class="flex-1 overflow-auto p-6">
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="infoLocal.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="infoLocal.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</div>
<HModalFooter
dismiss-button="Cancel"
resolve-button="Submit"
@dismiss="emit('dismiss')"
@resolve="emit('resolve', infoLocal)"
/>
</template>Nested Modals
<script setup lang="ts">
import { ref } from 'vue'
import { HButton, HModal } from '@holistics/design-system'
const firstModalShown = ref(false)
const secondModalShown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="firstModalShown = true"
>
Open
</HButton>
<HModal
v-model:shown="firstModalShown"
title="Modal 1"
>
<HButton
type="secondary-default"
@click="secondModalShown = true"
>
Open Nested
</HButton>
<HModal
v-model:shown="secondModalShown"
title="Modal 2"
>
Hello world!
</HModal>
</HModal>
</template>API
Pass-through: <div>
What does this mean?
All props, events, and attrs that are not specified in the tables below will be passed to the element/component described above.
Props
| Name | Type | Description |
|---|---|---|
shown * | boolean | |
teleportTo | string | RendererElement | null= "body" | Specify target container. Can either be a selector or an actual element. {@link https://vuejs.org/api/built-in-components.html#teleport} |
teleportDisabled | boolean | When {@link https://vuejs.org/api/built-in-components.html#teleport} |
teleportDefer | boolean | When {@link https://vuejs.org/api/built-in-components.html#teleport} |
size | "sm" | "md" | "lg" | "xl" | "full" | "fullscreen"= "md" | |
position | "top" | "right" | "bottom" | "left" | "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right"= MODAL_POSITIONS[1] | |
fullHeight | boolean | |
maskTransparent | boolean | |
maskBlurred | boolean | |
noCloseButton | boolean | |
noHeader | boolean | |
title | string | |
description | string | |
noFooter | boolean | |
dismissButton | string | ButtonPropsPreTyped= "Cancel" | |
resolveButton | string | ButtonPropsPreTyped= "Confirm" | |
hasCheckbox | boolean | |
checkbox | boolean | |
checkboxLabel | string= "Don't show this again" | |
leftText | string | |
disableFocusTrap | boolean | By default, focus cannot escape the modal container via keyboard, pointer, or a programmatic focus. To disable it,
set this to |
initialFocus | string | FocusableElement | null | |
documentScrollable | boolean | |
transitionDisabled | boolean | |
preventClickOutside | boolean | |
preventPressEscape | boolean | |
preventCloseAnimationDisabled | boolean | |
dismissLoading | boolean | |
resolveLoading | boolean | |
autoFocusElement | string | true | FocusableElement | null | By default, when mounted or when calling the exposed
NOTE: Set this to |
disableOutsidePointerEvents | boolean= true | When To interact with any element within lower layers, user will need to click outside of all layers with
This can be used as a way to force all lower layers to not emit |
immediate | boolean |
Events
| Name | Parameters | Description |
|---|---|---|
@pointerDownOutside | [event: PointerDownOutsideEvent] | |
@keydownEscape | [event: KeyboardEvent] | |
@autoFocusOnUnmount | [event: CustomEvent<any>] | |
@update:shown | [shown: boolean] | |
@update:checkbox | [checked: boolean] | |
@update:dismissLoading | [loading: boolean] | |
@update:resolveLoading | [loading: boolean] | |
@bodyResolved | [resolved: boolean] | |
@autoFocusOnBodyResolved | [event: CustomEvent<any>] | |
@dismiss | DefinePropEmit | |
@resolve | DefinePropEmit |
Slots
| Name | Scoped | Description |
|---|---|---|
#header | SlotPropsHeader | |
#title | SlotPropsHeader | |
#description | SlotPropsHeader | |
#body | SlotPropsDefault | |
#default | SlotPropsDefault | |
#body-fallback | any | |
#footer | SlotPropsDefault | |
#footer-left | SlotPropsBase & SlotPropsWithCheckbox | |
#checkbox-label | any | |
#dismiss-button | any | |
#resolve-button | any |
Exposed
| Name | Type | Description |
|---|---|---|
elementRef | HTMLDivElement | undefined | |
containerRef | HTMLDivElement | undefined |