Skip to content

Modal mechanism

Guides and tips about mechanisms of the Modal compoennt.

Data flow

In term of data flow, a default modal has 2 events:

  • @dismiss: emited when the user clicks the secondary button at footer, the ❌ icon at the top-right corner, the underlying mask... This event hides the modal.
  • @resolve: emited when the user clicks the primary button at footer. This event hides the modal.

Typically, when creating a new modal, you often want to re-define the 2 events but with more data (as event parameters) so the caller can listen to them and control the data flow after hiding the modal.

Show & Hide

1. In <template>

This is the simplest way to use modal. Just 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 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>

In this method, the modal works like a normal Vue component, which means you can simply define any props, emits, slots, injections,... and all Vue behaviors will apply as expected:

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 SelectOption } 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 SelectOption[]

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

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 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>

To pass props to the modal component, you can use the 2nd argument of open(). Moreover, the composable is an infallible function which means it always returns a Promise containing data depends on either @dismiss or @resolve is emited:

  • If @dismiss is emited, Promise contains an object that has state = 'dismiss'
  • If @resolve is emited, Promise contains an object that has state = 'resolve' and data which is an array of parameters defined by @resolve itself.

💪 Typing

The 2nd argument of open() and the data property are automatically typed based on the passed modal component!

If automatic typings is not desirable, 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. All events are just props with an additional on prefix!

💉 provide/inject

All modal components passed to open() are mounted as direct children of <HModalProvider>. Therefore, you can leverage this behavior to inject the relevant contexts (if you're tired of props/emiting data flow) for the modals by placing <HModalProvider> at appropriate place!