- [Regular Modals](#regular-modals) - [Basic Usage](#basic-usage) - [Props](#props) - [Slots](#slots) - [Default slot](#default-slot) - [`title` slot](#title-slot) - [`actions` slot](#actions-slot) - [Scrollable Content](#scrollable-content) - [Merged Header Mode](#merged-header-mode) - [Modal Stacking](#modal-stacking) - [Exposed Methods](#exposed-methods) - [Multistage Modals](#multistage-modals) - [Architecture](#architecture) - [Building a Multistage Modal](#building-a-multistage-modal) - [1. Define the context](#1-define-the-context) - [2. Define stage configs](#2-define-stage-configs) - [3. Create stage components](#3-create-stage-components) - [4. Create the wrapper component](#4-create-the-wrapper-component) - [Modal API](#modal-api) - [Non-Progress Stages (Edit Sub-Flows)](#non-progress-stages-edit-sub-flows) - [Reference Implementation](#reference-implementation) # Regular Modals Use the `NewModal` component (`packages/ui/src/components/modal/NewModal.vue`) for all standard modals. - Set the modal’s width via the `width` or `maxWidth` props. For responsive sizing, use `min(base-size, calc(95vw - 10rem))`. - `ModalWrapper` is deprecated — modal behavior is automatically handled via the `injectModalBehavior` DI utility. ## Basic Usage ```vue ``` Call `show(event?)` to open the modal. Passing the `MouseEvent` triggers an animation originating from the click position. Call `hide()` to close it programmatically. ## Props | Prop | Type | Default | Description | | --------------------- | ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ | | `header` | `string` | — | Title text displayed in the header bar | | `hideHeader` | `boolean` | `false` | Hides the entire header (title + close button) | | `mergeHeader` | `boolean` | `false` | Removes the header bar; renders a floating close button over the content | | `closable` | `boolean` | `true` | Shows the close button and enables ESC / click-outside dismissal | | `disableClose` | `boolean` | `false` | Disables all close actions (close button, ESC, click-outside). The close button appears disabled | | `closeOnEsc` | `boolean` | `true` | Allow closing with the Escape key | | `closeOnClickOutside` | `boolean` | `true` | Allow closing by clicking the overlay | | `scrollable` | `boolean` | `false` | Enables scroll tracking with top/bottom fade indicators | | `maxContentHeight` | `string` | `’70vh’` | Max height of the scrollable content area (only applies when `scrollable`) | | `noPadding` | `boolean` | `false` | Removes padding from the content area for edge-to-edge layouts | | `maxWidth` | `string` | `’60rem’` | Maximum width of the modal | | `width` | `string` | `fit-content` | Width of the modal body | | `noblur` | `boolean` | — | Disables backdrop blur. Defaults to the value from `injectModalBehavior` | | `fade` | `’standard’ \| ‘warning’ \| ‘danger’` | `’standard’` | Overlay color variant | | `danger` | `boolean` | `false` | **Deprecated** — use `fade="danger"` instead | | `onShow` | `() => void` | — | Called when the modal opens | | `onHide` | `() => void` | — | Called when the modal closes | ## Slots ### Default slot The main content area. Rendered inside a padded, optionally scrollable container. ```vue

Are you sure you want to proceed?

``` ### `title` slot Replaces the default header text. Use this when you need custom markup in the header (e.g. an icon next to the title or a badge). ```vue

Content here.

``` ### `actions` slot Renders a bottom action bar below the content area (with `p-4 pt-0` padding). Use this for confirm/cancel buttons. ```vue

This action cannot be undone.

``` ## Scrollable Content Set `scrollable` to enable scroll tracking. The modal renders animated fade gradients at the top and bottom edges when content is scrolled, giving users a visual cue that more content exists. ```vue ``` The `checkScrollState` method is exposed via ref — call it after dynamically changing content to re-evaluate whether fade indicators should appear. When `scrollable` is `false` (the default), content uses `overflow-y: auto` without fade indicators. ## Merged Header Mode When `mergeHeader` is set, the header bar is hidden and a floating close button is rendered in the top-right corner of the modal. Content receives extra top padding to avoid overlapping the button. This is useful for modals with hero images or full-bleed content at the top. ```vue

Content below the image.

``` ## Modal Stacking `NewModal` integrates with a modal stack (`useModalStack`). Multiple modals can be open simultaneously — only the topmost modal responds to the Escape key. The document body scroll is locked when any modal is open and restored when the last modal closes. ## Exposed Methods | Method | Description | | -------------------- | ------------------------------------------------------- | | `show(event?)` | Opens the modal. Pass `MouseEvent` for origin animation | | `hide()` | Closes the modal | | `checkScrollState()` | Re-evaluates scroll fade indicators (when `scrollable`) | # Multistage Modals The `MultiStageModal` component (`packages/ui/src/components/base/MultiStageModal.vue`) provides a wizard-like modal with progress tracking, conditional stages, and per-stage button configuration. ## Architecture A multistage modal has three parts: 1. **Context** — A DI provider that holds all state, business logic, and stage configs 2. **Stage configs** — Data objects describing each stage (title, component, buttons, skip conditions) 3. **Stage components** — Vue components rendered inside the modal, consuming the context ## Building a Multistage Modal ### 1. Define the context Create a DI provider with all the state your wizard needs. Include the modal ref and stage configs. ```ts // 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 isSubmitting: Ref // Modal control modal: ShallowRef | null> stageConfigs: StageConfigInput[] // Business logic handleSubmit: () => Promise } export const [injectMyModalContext, provideMyModalContext] = createContext('MyModal') export function createMyModalContext( modal: ShallowRef | null>, ): MyModalContext { const formData = ref({ ... }) 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 configs Each stage is a `StageConfigInput` where `T` is your context type. Most fields accept either a static value or a function receiving the context (`MaybeCtxFn`). ```ts // 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 = { id: 'details', stageContent: markRaw(DetailsStage), title: 'Details', // Conditional behavior based on 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 config fields:** | Field | Type | Purpose | | ----------------------- | ------------------------------------------ | ------------------------------------------------ | | `id` | `string` | Unique stage identifier (used with `setStage()`) | | `stageContent` | `Component` | Vue component to render (wrap with `markRaw()`) | | `title` | `MaybeCtxFn` | Stage title in breadcrumbs | | `skip` | `MaybeCtxFn` | Skip this stage conditionally | | `nonProgressStage` | `MaybeCtxFn` | Exclude from progress bar (for edit sub-flows) | | `hideStageInBreadcrumb` | `MaybeCtxFn` | Hide from breadcrumb nav | | `cannotNavigateForward` | `MaybeCtxFn` | Block forward navigation (validation) | | `disableClose` | `MaybeCtxFn` | Disable closing the modal | | `leftButtonConfig` | `MaybeCtxFn` | Left action button | | `rightButtonConfig` | `MaybeCtxFn` | Right action button | | `maxWidth` | `MaybeCtxFn` | Per-stage max width (default `560px`) | **Button config fields:** | Field | Purpose | | -------------- | ----------------------- | | `label` | Button text | | `icon` | Icon component | | `iconPosition` | `'before'` or `'after'` | | `color` | ButtonStyled color prop | | `disabled` | Disable the button | | `onClick` | Click handler | ### 3. Create stage components Stage components inject the context and render their UI: ```vue ``` ### 4. Create the wrapper component The wrapper provides context and renders `MultiStageModal`: ```vue ``` ## Modal API `MultiStageModal` exposes via ref: | Method/Property | Description | | --------------------- | ----------------------------------- | | `show()` | Open the modal | | `hide()` | Close the modal | | `setStage(indexOrId)` | Jump to stage by index or string id | | `nextStage()` | Advance to next non-skipped stage | | `prevStage()` | Go back to previous stage | | `currentStageIndex` | Ref to current stage index | ## Non-Progress Stages (Edit Sub-Flows) For stages that shouldn't appear in the progress bar (e.g. editing a specific field from a summary page): ```ts export const editLoadersStageConfig: StageConfigInput = { 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', }), } ``` Navigate to it with `modal.value?.setStage('edit-loaders')` — it won't affect the progress indicator. ## Reference Implementation The version creation/edit modal is the most complete example: | File | Purpose | | ------------------------------------------------------------- | --------------------------------- | | `apps/frontend/src/providers/version/manage-version-modal.ts` | Context creation + business logic | | `apps/frontend/src/providers/version/stages/index.ts` | Stage config barrel export | | `apps/frontend/src/providers/version/stages/*-stage.ts` | Individual stage configs | The context includes computed properties for conditional UI, watchers for auto-fetching dependencies, loading states for granular button disabling, and both "create" and "edit" flows sharing the same stages with different button configs.