Skip to content

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-model exposes the open state. Parent does <MyModal v-model="modalOpen" :target="thing" />.
  • @click.once on the primary action button — house style to prevent double-submit. See 09-conventions.md.
  • hide-footer is common — most modals hand-roll their own button row for layout flexibility.
  • centered is 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 (or ArchiveModal.vue for 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 @hidden so 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."

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:

grep -rln "<b-modal" components/ | wc -l

…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 id prop on <b-modal> matters if you trigger modals via $bvModal.show('id') from elsewhere. If two modals share an id, 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.