15 KiB
Standard Modals
Use NewModal (packages/ui/src/components/modal/NewModal.vue) for all standard modals.
- Set the modal width with the
widthormaxWidthprop. - For a responsive width, use
min(base-size, calc(95vw - 10rem)). - Do not use
ModalWrapper. TheinjectModalBehaviorDI utility supplies modal behavior.
Basic Use
<script setup lang="ts">
import { ref } from 'vue'
import { NewModal } from '@modrinth/ui'
const modal = ref<InstanceType<typeof NewModal> | null>(null)
</script>
<template>
<button @click="modal?.show($event)">Open</button>
<NewModal ref="modal" header="My Modal">
<p>Modal content.</p>
</NewModal>
</template>
Call show(event?) to open the modal. A MouseEvent starts the animation at the click position.
Call hide() to close the modal from code.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
header |
string |
None | Sets the title in the header bar. |
hideHeader |
boolean |
false |
Hides the title and close button. |
mergeHeader |
boolean |
false |
Replaces the header bar with a floating close button. |
closable |
boolean |
true |
Enables the close button, Escape key, and overlay click. |
disableClose |
boolean |
false |
Disables all close actions and shows a disabled close button. |
closeOnEsc |
boolean |
true |
Enables the Escape key as a close action. |
closeOnClickOutside |
boolean |
true |
Enables an overlay click as a close action. |
scrollable |
boolean |
false |
Enables scroll tracking and edge-fade indicators. |
maxContentHeight |
string |
'70vh' |
Sets the maximum scrollable-content height. |
noPadding |
boolean |
false |
Removes content padding for edge-to-edge layouts. |
maxWidth |
string |
'60rem' |
Sets the maximum modal width. |
width |
string |
fit-content |
Sets the modal-body width. |
noblur |
boolean |
None | Disables the backdrop blur. The DI behavior supplies the default. |
fade |
'standard' | 'warning' | 'danger' |
'standard' |
Sets the overlay color variant. |
danger |
boolean |
false |
Deprecated. Use fade="danger". |
onShow |
() => void |
None | Runs when the modal opens. |
onHide |
() => void |
None | Runs when the modal closes. |
maxContentHeight has an effect only when scrollable is true.
Slots
Default Slot
The default slot contains the main content. NewModal puts it in a padded container that can scroll.
<NewModal ref="modal" header="Confirm">
<p>Are you sure that you want to continue?</p>
</NewModal>
title Slot
The title slot replaces the default header text. Use it for custom header markup, such as an icon or badge.
<NewModal ref="modal">
<template #title>
<AlertIcon />
<span class="text-2xl font-semibold text-contrast">Custom Title</span>
</template>
<p>Content.</p>
</NewModal>
actions Slot
The actions slot makes an action bar below the content. The bar uses p-4 pt-0 padding.
Use this slot for confirmation and cancellation buttons:
<NewModal ref="modal" header="Delete Item" fade="danger">
<p>You cannot reverse this action.</p>
<template #actions>
<Button type="colored" color="red" @click="handleDelete">Delete</Button>
<Button @click="modal?.hide()">Cancel</Button>
</template>
</NewModal>
Scrollable Content
Set scrollable to enable scroll tracking. Fade gradients appear at the top and bottom when more content exists.
<NewModal ref="modal" header="Long Content" scrollable max-content-height="60vh">
<!-- Long content can overflow. -->
</NewModal>
Call the exposed checkScrollState method after a dynamic content change. The method recalculates the fade-indicator state.
When scrollable is false, the content uses overflow-y: auto without fade indicators. False is the default value.
Merged Header
When mergeHeader is true, the header bar is hidden. A floating close button appears in the top-right corner.
The content gets more top padding. This padding prevents overlap with the button.
Use this mode for a hero image or full-width content at the top:
<NewModal ref="modal" merge-header no-padding>
<img src="..." class="w-full" />
<div class="p-6">
<p>Content below the image.</p>
</div>
</NewModal>
Modal Stack
NewModal uses useModalStack. Multiple modals can be open at the same time.
Only the top modal responds to the Escape key. The first open modal locks document-body scrolling.
The last modal restores document-body scrolling when it closes.
Exposed Methods
| Method | Description |
|---|---|
show(event?) |
Opens the modal. Pass a MouseEvent for the origin animation. |
hide() |
Closes the modal. |
checkScrollState() |
Recalculates fade indicators when scrollable is true. |
Multistage Modals
MultiStageModal (packages/ui/src/components/base/MultiStageModal.vue) supplies progress, conditional stages, and button configurations for each stage.
Architecture
A multistage modal has three parts:
- The context contains all state, application logic, and stage configurations.
- Stage configurations define the title, component, buttons, and skip conditions for each stage.
- Stage components inject the context and render inside the modal.
Create a Multistage Modal
1. Define the Context
Make a DI provider that contains the modal state. Include the modal reference and stage configurations.
// providers/my-feature/my-modal.ts
import type { ShallowRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import type { MultiStageModal, StageConfigInput } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
export interface MyModalContext {
// State.
formData: Ref<MyFormData>
isSubmitting: Ref<boolean>
// Modal control.
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
stageConfigs: StageConfigInput<MyModalContext>[]
// Application logic.
handleSubmit: () => Promise<void>
}
export const [injectMyModalContext, provideMyModalContext] =
createContext<MyModalContext>('MyModal')
export function createMyModalContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
): MyModalContext {
const formData = ref<MyFormData>({ ... })
const isSubmitting = ref(false)
async function handleSubmit() {
isSubmitting.value = true
try {
await saveData(formData.value)
modal.value?.hide()
} finally {
isSubmitting.value = false
}
}
return { formData, isSubmitting, modal, stageConfigs, handleSubmit }
}
2. Define Stage Configurations
Each stage is a StageConfigInput<T>, where T is the context type.
Most fields accept a static value or a function that receives the context. The function type is MaybeCtxFn<T, R>.
// providers/my-feature/stages/details-stage.ts
import { markRaw } from 'vue'
import type { StageConfigInput } from '@modrinth/ui'
import type { MyModalContext } from '../my-modal'
import DetailsStage from './DetailsStage.vue'
import { RightArrowIcon, SaveIcon } from '@modrinth/assets'
export const detailsStageConfig: StageConfigInput<MyModalContext> = {
id: 'details',
stageContent: markRaw(DetailsStage),
title: 'Details',
// Set behavior from the context.
skip: (ctx) => ctx.shouldSkipDetails.value,
cannotNavigateForward: (ctx) => !ctx.formData.value.name,
disableClose: (ctx) => ctx.isSubmitting.value,
leftButtonConfig: (ctx) => ({
label: 'Cancel',
onClick: () => ctx.modal.value?.hide(),
}),
rightButtonConfig: (ctx) => ({
label: 'Next',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: !ctx.formData.value.name,
onClick: () => ctx.modal.value?.nextStage(),
}),
}
Stage configuration fields:
| Field | Type | Purpose |
|---|---|---|
id |
string |
Supplies the unique stage identifier. |
stageContent |
Component |
Supplies the Vue component. Use markRaw(). |
title |
MaybeCtxFn<T, string> |
Supplies the breadcrumb title. |
skip |
MaybeCtxFn<T, boolean> |
Skips the stage when the value is true. |
nonProgressStage |
MaybeCtxFn<T, boolean> |
Removes the stage from the progress bar. |
hideStageInBreadcrumb |
MaybeCtxFn<T, boolean> |
Removes the stage from breadcrumb navigation. |
cannotNavigateForward |
MaybeCtxFn<T, boolean> |
Prevents forward navigation. |
disableClose |
MaybeCtxFn<T, boolean> |
Disables modal close actions. |
leftButtonConfig |
MaybeCtxFn<T, StageButtonConfig | null> |
Configures the left action button. |
rightButtonConfig |
MaybeCtxFn<T, StageButtonConfig | null> |
Configures the right action button. |
maxWidth |
MaybeCtxFn<T, string> |
Sets the stage width. The default is 560px. |
Button configuration fields:
| Field | Purpose |
|---|---|
label |
Supplies the button text. |
icon |
Supplies the icon component. |
iconPosition |
Uses 'before' or 'after'. |
color |
Supplies the Button color prop. |
disabled |
Disables the button when true. |
onClick |
Supplies the click handler. |
3. Create Stage Components
Inject the context into each stage component. Then, render the applicable UI:
<!-- providers/my-feature/stages/DetailsStage.vue -->
<script setup lang="ts">
import { injectMyModalContext } from '../my-modal'
const { formData } = injectMyModalContext()
</script>
<template>
<div class="flex flex-col gap-4">
<StyledInput v-model="formData.name" label="Name" />
<StyledInput v-model="formData.description" label="Description" />
</div>
</template>
4. Create the Wrapper Component
Provide the context from the wrapper. Then, render MultiStageModal:
<!-- components/MyModalWrapper.vue -->
<script setup lang="ts">
import { shallowRef } from 'vue'
import { MultiStageModal } from '@modrinth/ui'
import { createMyModalContext, provideMyModalContext } from '../providers/my-feature/my-modal'
const modal = shallowRef<InstanceType<typeof MultiStageModal> | null>(null)
const ctx = createMyModalContext(modal)
provideMyModalContext(ctx)
defineExpose({ show: () => modal.value?.show() })
</script>
<template>
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
</template>
Modal API
MultiStageModal exposes these methods and properties through its reference:
| Method or property | Description |
|---|---|
show() |
Opens the modal. |
hide() |
Closes the modal. |
setStage(indexOrId) |
Goes to a stage by index or string ID. |
nextStage() |
Goes to the next applicable stage. |
prevStage() |
Goes to the previous stage. |
currentStageIndex |
Contains the current stage index as a Ref. |
Non-Progress Stages
Use a non-progress stage for an edit flow that must not appear in the progress bar:
export const editLoadersStageConfig: StageConfigInput<MyContext> = {
id: 'edit-loaders',
nonProgressStage: true,
stageContent: markRaw(EditLoadersStage),
title: 'Edit loaders',
leftButtonConfig: (ctx) => ({
label: 'Back',
onClick: () => ctx.modal.value?.setStage('summary'),
}),
rightButtonConfig: (ctx) => ({
...ctx.saveButtonConfig(),
label: 'Save',
}),
}
Call modal.value?.setStage('edit-loaders') to open the stage. This stage does not change the progress indicator.
Reference Implementation
The version create-and-edit modal is the most complete example:
| File | Purpose |
|---|---|
apps/frontend/src/providers/version/manage-version-modal.ts |
Contains context and application logic. |
apps/frontend/src/providers/version/stages/index.ts |
Exports all stage configurations. |
apps/frontend/src/providers/version/stages/*-stage.ts |
Contains each stage configuration. |
The context has computed properties for conditional UI. It also has dependency watchers and granular button loading states.
The create and edit flows use the same stages with different button configurations.