feat: move notion docs to standards folder (#5590)
* feat: move notion docs to standards folder * fix: remove skills mention (automatic now)
This commit is contained in:
370
standards/frontend/MODALS.md
Normal file
370
standards/frontend/MODALS.md
Normal file
@@ -0,0 +1,370 @@
|
||||
- [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
|
||||
<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 here.</p>
|
||||
</NewModal>
|
||||
</template>
|
||||
```
|
||||
|
||||
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
|
||||
<NewModal ref="modal" header="Confirm">
|
||||
<p>Are you sure you want to proceed?</p>
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
### `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
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<AlertIcon />
|
||||
<span class="text-2xl font-semibold text-contrast">Custom Title</span>
|
||||
</template>
|
||||
<p>Content here.</p>
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
### `actions` slot
|
||||
|
||||
Renders a bottom action bar below the content area (with `p-4 pt-0` padding). Use this for confirm/cancel buttons.
|
||||
|
||||
```vue
|
||||
<NewModal ref="modal" header="Delete Item" fade="danger">
|
||||
<p>This action cannot be undone.</p>
|
||||
<template #actions>
|
||||
<ButtonStyled color="danger">
|
||||
<button @click="handleDelete">Delete</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
## 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
|
||||
<NewModal ref="modal" header="Long Content" scrollable max-content-height="60vh">
|
||||
<!-- Long content that may overflow -->
|
||||
</NewModal>
|
||||
```
|
||||
|
||||
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
|
||||
<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 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<MyFormData>
|
||||
isSubmitting: Ref<boolean>
|
||||
|
||||
// Modal control
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
|
||||
stageConfigs: StageConfigInput<MyModalContext>[]
|
||||
|
||||
// Business 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 configs
|
||||
|
||||
Each stage is a `StageConfigInput<T>` where `T` is your context type. Most fields accept either a static value or a function receiving the context (`MaybeCtxFn<T, R>`).
|
||||
|
||||
```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<MyModalContext> = {
|
||||
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<T, string>` | Stage title in breadcrumbs |
|
||||
| `skip` | `MaybeCtxFn<T, boolean>` | Skip this stage conditionally |
|
||||
| `nonProgressStage` | `MaybeCtxFn<T, boolean>` | Exclude from progress bar (for edit sub-flows) |
|
||||
| `hideStageInBreadcrumb` | `MaybeCtxFn<T, boolean>` | Hide from breadcrumb nav |
|
||||
| `cannotNavigateForward` | `MaybeCtxFn<T, boolean>` | Block forward navigation (validation) |
|
||||
| `disableClose` | `MaybeCtxFn<T, boolean>` | Disable closing the modal |
|
||||
| `leftButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Left action button |
|
||||
| `rightButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Right action button |
|
||||
| `maxWidth` | `MaybeCtxFn<T, string>` | 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
|
||||
<!-- 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
|
||||
|
||||
The wrapper provides context and renders `MultiStageModal`:
|
||||
|
||||
```vue
|
||||
<!-- 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 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<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',
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user