Skip to content

Modal mechanism ​

Guides and tips for working with the Modal component.

Data flow ​

A default modal has two events:

  • @dismiss: emitted when the user clicks the secondary footer button, the close icon in the top-right corner, or the mask behind the modal. This event hides the modal.
  • @resolve: emitted when the user clicks the primary footer button. This event hides the modal.

When you create a modal, you usually redefine these two events to carry more data as event parameters. The caller can then listen to them and control the data flow after the modal hides.

Show and hide ​

1. In <template> ​

This is the simplest way to use a modal. Create a ref and bind it to v-model:shown:

vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
import {
  HButton, HModal, HSelect, type SelectOptionBase,
} 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 SelectOptionBase[]

const jobTitles = [
  { value: 'product-manager', label: 'Product Manager' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOptionBase[]
</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>

The modal works like a normal Vue component here. Define props, emits, slots, and injections as usual, and all Vue behaviors apply:

vue
<script setup lang="ts">
import { ref } from 'vue'
import { HButton } from '@holistics/design-system'
import Modal, { type Info } from './Modal.vue'

const shown = ref(false)

const numRoleSelected = ref(0)

function onResolve (info: Info) {
  console.log(info)
}
</script>

<template>
  <HButton
    type="primary-highlight"
    @click="shown = true"
  >
    Open
  </HButton>

  <div class="mt-2">
    Role has been selected: <code>{{ numRoleSelected }}</code> times!
  </div>

  <Modal
    v-model:shown="shown"
    message="Remember to choose a role!"
    @resolve="onResolve"
    @select-role="numRoleSelected++"
  />
</template>
vue
<script setup lang="ts">
import { HModal, HSelect, type SelectOptionBase } from '@holistics/design-system'
import { reactive } from 'vue'

export interface Info {
  role?: string
  jobTitle?: string
}

defineProps<{
  message: string
}>()

const emit = defineEmits<{
  resolve: [info: Info]
  selectRole: [role: string]
}>()

const shown = defineModel<boolean>('shown', { required: true })

const info = reactive<Info>({
  role: undefined,
  jobTitle: undefined,
})

const roles = [
  { value: 'admin', label: 'Admin' },
  { value: 'user', label: 'User' },
] as const satisfies SelectOptionBase[]

const jobTitles = [
  { value: 'product-manager', label: 'Product Manager' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOptionBase[]

async function onResolve () {
  await new Promise((res) => { setTimeout(res, 1000) }) // Server validation, API calls,...
  emit('resolve', info)
}
</script>

<template>
  <HModal
    v-model:shown="shown"
    title="Some Modal"
    @resolve="onResolve"
  >
    <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"
        @select="(role) => emit('selectRole', role as string)"
      />
    </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>

    <div class="mt-4 italic">
      Message: {{ message }}
    </div>
  </HModal>
</template>

2. Programmatically ​

Sometimes, you may want to show a modal, doing something with it, and then expects it to return some data in an imperative manner. You can achieve that by passing an entire Vue component into the open() function injected via the useModal() composable.

PREREQUISITES

To make this work, you must ensure the following conditions are met:

  • There must be a <HModalProvider> placed as an ancestor of wherever useModal() is called
  • The modal component passed to open():
    • Must place <HModal> at root and make sure inheritAttrs = true OR set inheritAttrs = false and bind all the attrs to <HModal v-bind="$attrs">
    • If defined, the following props and events must be bound to <HModal>:
      • shown prop and @update:shown events
      • @dismiss and @resolve events
vue
<script setup lang="ts">
import { HModalProvider } from '@holistics/design-system'
import Opener from './Opener.vue'
</script>

<template>
  <HModalProvider>
    <Opener />
  </HModalProvider>
</template>
vue
<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>
vue
<script setup lang="ts">
import { HModal, HSelect, type SelectOptionBase } 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 SelectOptionBase[]

const jobTitles = [
  { value: 'product-manager', label: 'Product Manager' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOptionBase[]
</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>

To pass props to the modal component, you can use the 2nd argument of open(). The composable is infallible: it always returns a Promise. What the Promise contains depends on whether @dismiss or @resolve was emitted:

  • If @dismiss is emitted, the Promise contains an object with state = 'dismiss'
  • If @resolve is emitted, the Promise contains an object with state = 'resolve' and a data array holding the parameters that @resolve defines.

Typing

The second argument of open() and the data property are typed automatically, based on the modal component you pass in.

If you don't want automatic typing, you can manually adjust them via generics when calling open(). For example: open<typeof Modal, CustomProps, CustomData>(Modal)

Don't want to await?

You can use onDismiss and onResolve callbacks when passing the 2nd argument to open() instead of awaiting and then checking for the values of state and data.

This also applies to all events defined by the modal component if you want to do something without closing the modal. Every event is a prop with an on prefix.

provide and inject

Every modal component you pass to open() mounts as a direct child of <HModalProvider>. Use this to inject the contexts your modals need: place <HModalProvider> at the right point in the tree, instead of threading data through props and emits.