18 — Modal patterns¶
The repo has 30+ modal components at the top level of components/. They cover everything from confirmation dialogs to multi-step content creation. This doc is the reference for how modals are structured here, when to reuse vs. create new, and the conventions to follow.
Inventory (snapshot)¶
components/
├── AddWebsiteModal.vue
├── approvalModal.vue
├── ArchiveModal.vue
├── brandPostModal.vue
├── BulkActionsModal.vue
├── CarouselPostModal.vue
├── chatModal.vue
├── CircleNetworkModal.vue
├── ContentEditModal.vue
├── createContentModal.vue
├── CustomTermsModal.vue
├── DeleteCarouselPostModal.vue
├── DeleteModal.vue
├── DeleteTemplateSetModal.vue
├── editPostModal.vue
├── HolidaySelectionModal.vue
├── libraryModal.vue
├── LoginModal.vue
├── NewFolderModal.vue
├── paymentModal.vue
├── pModal.vue
├── PreviewModal.vue
├── ReelIdeasModal.vue
├── Reschedulearticlemodal.vue
├── SaveAsModal.vue
├── ScheduleArticleModal.vue
├── ShareModal.vue
├── tempBrandModal.vue
├── tempSetModal.vue
├── updateSPostModal.vue
├── UploadModal.vue
└── VideoModal.vue
The naming is inconsistent (PascalCase, camelCase, lowercase mixed) — don't rename existing files for style. New modals should use PascalCase ending in Modal.vue.
How modals are built here¶
Modals are built on top of bootstrap-vue's <b-modal>. The typical shape is:
<template>
<b-modal
id="my-modal"
v-model="show"
title="Confirm action"
centered
hide-footer
@hidden="onHidden"
>
<p>Are you sure?</p>
<div class="d-flex justify-content-end">
<button class="btn btn-secondary" @click="cancel">Cancel</button>
<button class="btn btn-primary" @click.once="confirm" :disabled="loading">
<b-spinner v-if="loading" small></b-spinner>
Confirm
</button>
</div>
</b-modal>
</template>
<script>
export default {
name: 'MyModal',
props: { value: Boolean, target: Object },
data: () => ({ loading: false }),
computed: {
show: {
get() { return this.value },
set(v) { this.$emit('input', v) }
}
},
methods: {
cancel() { this.show = false },
async confirm() {
this.loading = true
try {
await this.$axios.post('/auth/<endpoint>', this.target)
this.$emit('confirmed', this.target)
this.show = false
} catch (e) {
this.$toast.error('Something went wrong')
} finally {
this.loading = false
}
},
onHidden() {
// reset modal state if needed
}
}
}
</script>
Things to notice:
v-modelexposes the open state. Parent does<MyModal v-model="modalOpen" :target="thing" />.@click.onceon the primary action button — house style to prevent double-submit. See09-conventions.md.hide-footeris common — most modals hand-roll their own button row for layout flexibility.centeredis the default visual treatment.- A
<b-spinner>inside the disabled button for loading states. - Toast for failure feedback (
vue-toastification's$toast.error). Don't block the close on failure unless re-entry is required.
Confirmation modal pattern¶
For "Are you sure you want to delete X?" flows, reuse DeleteModal.vue rather than creating a new variant. It accepts a target prop and emits a confirm event.
If you need a delete-with-extra-state flow (e.g. "Delete and remove from all folders"), that's where a feature-specific modal makes sense — DeleteCarouselPostModal.vue and DeleteTemplateSetModal.vue are existing examples.
The general rule:
- Generic confirmation →
DeleteModal.vue(orArchiveModal.vuefor archive). - Confirmation with feature-specific options → dedicated modal.
Multi-step modal pattern¶
Some modals (createContentModal.vue, BulkActionsModal.vue, ScheduleArticleModal.vue) are wizards with internal step state. Conventions:
- Track step in local
data(currentStep: 1). - Render each step in a separate
<template v-if="currentStep === N">block. - Hide the global modal close (
hide-header-close) if a partial state would be invalid. - Reset step state on
@hiddenso reopening the modal doesn't strand the user mid-wizard.
Editor modals¶
Several modals embed a content editor (ContentEditModal.vue, editPostModal.vue, updateSPostModal.vue, tempBrandModal.vue). They use TinyMCE or a custom textarea — not the Polotno bundle (which is full-page, not modal). If a feature genuinely needs Polotno inside a modal, talk to the team first; the bundle is large and was not designed for modal embedding.
Shared modal triggers¶
A common pattern: a parent component owns the modal's open state and the target object:
<template>
<div>
<button @click="openModal(post)">Edit</button>
<EditPostModal v-model="editOpen" :post="selectedPost" @saved="onSaved" />
</div>
</template>
<script>
export default {
data: () => ({ editOpen: false, selectedPost: null }),
methods: {
openModal(post) {
this.selectedPost = post
this.editOpen = true
},
onSaved() {
this.editOpen = false
this.$emit('refresh')
}
}
}
</script>
This keeps the modal stateless across openings — its lifecycle is "open with this target → either confirm or cancel."
Modal behaviour to standardise¶
When in doubt, follow these defaults for new modals:
| Concern | Convention |
|---|---|
| Open state | v-model on a Boolean prop |
| Target / context | A separate prop (e.g. :post="...") |
| Primary button | @click.once, :disabled="loading", contains <b-spinner v-if="loading" small> |
| Secondary (cancel) | @click="show = false" |
| Close behaviour | @hidden to reset internal state |
| Backdrop click | Allowed unless mid-flow; use no-close-on-backdrop if needed |
| Esc key | Allowed unless mid-flow; use no-close-on-esc if needed |
| Failure feedback | $toast.error(...) with TOAST_OPTIONS (see 17-helpers.md) |
| Success feedback | $toast.success(...) then this.$emit('saved' \| 'confirmed' \| 'done') then close |
| Cache invalidation | After a mutation, dispatch api/clearApiCache or a specific force: true refetch |
| Body scroll | Bootstrap-vue handles this automatically; don't override |
Don't reinvent¶
Before creating a new modal, search:
…there are dozens. The single most common reason to add a new one is a feature-specific data-collection step, not a confirmation. For confirmations, reuse DeleteModal / ArchiveModal / pModal.
pModal.vue is short for "prompt modal" or similar — verify with the team for its intended scope before reusing widely.
Sharp edges¶
- bootstrap-vue ships with Bootstrap 4 conventions, but the app also loads Bootstrap 5 from CDN. Some modal styles overlap and the CDN version can override bootstrap-vue's CSS in unexpected ways. Test in the actual app, not in isolation.
- The
idprop on<b-modal>matters if you trigger modals via$bvModal.show('id')from elsewhere. If two modals share anid, both will respond. Use unique IDs. - Mounting an editor modal twice can leak listeners or cause Vue to double-initialise the editor. Always destroy/reset on
@hidden. - Toast inside a modal is a common UX issue — a modal that opens a toast that closes when the modal closes confuses users. Either close the modal first, then toast, or use an inline error message inside the modal.
- Modal stacking is supported by bootstrap-vue but visually messy. Avoid opening modals from inside modals where possible.