Using Vue.js 3 and Bootstrap 5 to Create a Custom Reusable Modal Component for Programmatically Showing Content

Trying to develop a reusable Modal Component using Bootstrap 5, Vuejs 3, and composible API. I have managed to achieve partial functionality,
Provided (Basic Bootstrap 5 modal with classes added based on the 'show' prop, and slots in the body and footer):

<script setup lang="ts">
defineProps({
  show: {
    type: Boolean,
    default: false,
  },
  title: {
    type: String,
    default: "<<Title goes here>>",
  },
});
</script>

<template>
  <div class="modal fade" :class="{ show: show, 'd-block': show }"
    id="exampleModal" tabindex="-1" aria-labelledby="" aria-hidden="true">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="exampleModalLabel">{{ title }}</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <slot name="body" />
        </div>
        <div class="modal-footer">
          <slot name="footer" />
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
            Close
          </button>              
        </div>
      </div>
    </div>
  </div>
</template>

and being triggered by

<script setup lang="ts">
import { ref } from "vue";
import Modal from "@/components/Common/Modal.vue";

let modalVisible = ref(false);

function showModal(){
 modalVisible.value = true;
}
</script>

<template>
  <button @click="showModal">Show Modal</button>
  <Modal title="Model title goes here" :show="modalVisible">
    <template #body>This content goes in the body</template>
    <template #footer>
      <button class="btn btn-primary">Extra footer button</button>
    </template>
</Modal>
</template>

The modal is displayed but the fade-in animation isn't working, the backdrop isn't visible, and the buttons in the modal don't function (i.e., it won't close). There seems to be an issue with my overall approach.

https://i.sstatic.net/wgF75.png

NOTE. I can't use a standard button with

data-bs-toggle="modal" data-bs-target="#exampleModal"
attributes as the actual trigger for this model originates from the logic of another component (just setting a boolean), and the reusable modal component will be independent of its trigger --- it also doesn't seem like the proper 'Vue' way to do it.

It feels like I'm just displaying the HTML and need to somehow instantiate a bootstrap modal... I'm just not sure how to approach it

package.json (relevant dependencies)

"dependencies": {
    "@popperjs/core": "^2.11.2",
    "bootstrap": "^5.1.3",
    "vue": "^3.2.31",
  },

Code sandbox here (I couldn't get the new Composition API and TS to work on CodeSandbox, so it's a slight rewrite using the standard Options API approach, resulting in slightly different code behavior)

Answer №1

After spending a few more hours working on it, I finally found a solution that could potentially help others facing the same issue. The key was creating the bootstrap modal 'Object'. To achieve this, I had to first import the modal object from bootstrap. Since its creation required a DOM reference, I added a ref to the HTML element and a ref prop in the script to store the link to it. In Vue, DOM references are not populated until the component is mounted. Therefore, constructing the Bootstrap modal object needed to be done in Onmounted so that the ref could now link to the actual DOM element. Instead of passing a show prop down which caused synchronization issues between parent and child components, I simply exposed a show method on the dialog component itself (which felt more elegant). Since <script setup> objects are CLOSED BY DEFAULT, I used defineExpose to expose the method.

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Modal } from "bootstrap";
defineProps({
  title: {
    type: String,
    default: "<<Title goes here>>",
  },
});
let modalEle = ref(null);
let thisModalObj = null;

onMounted(() => {
  thisModalObj = new Modal(modalEle.value);
});

function _show() {
  thisModalObj.show();
}

defineExpose({ show: _show });
</script>

<template>
  <div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="" aria-hidden="true" ref="modalEle">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="exampleModalLabel">{{ title }}</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <slot name="body" />
        </div>
        <div class="modal-footer">
          <slot name="footer"></slot>
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
            Close
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

And for the 'parent' component:

<script setup lang="ts">
import { ref } from "vue";
import Modal from "@/components/Common/Modal.vue";

let thisModal= ref(null);

function showModal(){
  thisModal.value.show();
}
</script>

<template>
  <button @click="showModal">Show Modal</button>
  <Modal title="Model title goes here" ref="thisModal">
    <template #body>This should be in the body</template>
    <template #footer>
      <button class="btn btn-primary">Extra footer button</button>
    </template>
  </Modal>
</template>

I think it would be wise to also add an OnUnmount method to clean up the object for better code organization.

Answer №2

If you're not utilizing TS, consider the ease of conversion into TS while maintaining Accessibility features like focus trap, modal close functionality on escape key press, and aria attributes intact! Here's how it can be done. The only aspect I'd tweak is the backdrop animation for a smoother effect. (I've also added a handy utility to generate unique IDs).

Children:

<template>
  <teleport to="body">
    <focus-trap v-model:active="active">
      <div
          ref="modal"
          class="modal fade"
          :class="{ show: active, 'd-block': active }"
          tabindex="-1"
          role="dialog"
          :aria-labelledby="`modal-${id}`"
          :aria-hidden="active"
      >
        <div class="modal-dialog modal-dialog-centered" role="document" >
          <div class="modal-content">
            <div class="modal-header">
              <h5 class="modal-title text-dark" :id="`modal-${id}`"><slot name="title"></slot></h5>
              <button
                  type="button"
                  class="close"
                  data-dismiss="modal"
                  aria-label="Close"
                  @click="$emit('closeModal', false)"
              >
                <span aria-hidden="true">&times;</span>
              </button>
            </div>
            <div class="modal-body text-dark">
              <slot></slot>
            </div>
            <div class="modal-footer text-dark">
              <button type="button" class="btn btn-danger" @click="$emit('closeModal', true)">Yes</button>
              <button type="button" class="btn btn-success" @click="$emit('closeModal', false)">No</button>
            </div>
          </div>
        </div>
      </div>
    </focus-trap>
    <div class="fade" :class="{ show: active, 'modal-backdrop show': active }"></div>
  </teleport>
</template>

<script>

import { ref, watch} from 'vue'
import IdUnique from '../js/utilities/utilities-unique-id';
import { FocusTrap } from 'focus-trap-vue'

export default {
  name: 'Modal',
  emits: ['closeModal'],
  components: {
    FocusTrap: FocusTrap
  },
  props: {
    showModal: Boolean,
    modalId: String,
  },
  setup(props) {
    const id = IdUnique();
    const active = ref(props.showModal);

    watch(() => props.showModal, (newValue, oldValue) => {
      if (newValue !== oldValue) {
        active.value = props.showModal;
        const body = document.querySelector("body");
        props.showModal ? body.classList.add("modal-open") : body.classList.remove("modal-open")
      }
    },{immediate:true, deep: true});

    return {
      active,
      id
    }
  }
}
</script>

Parent:

<template>
  <div class="about">
    <div v-for="product in products">
      <Product :product="product" :mode="mode"></Product>
    </div>
  </div>
  <template v-if="mode === 'cart'">
    <div class="hello">
      <modal :showModal="showModal" @closeModal="handleCloseModal">
        <template v-slot:title>Warning</template>
        <p>Do you really wish to clear your cart</p>
      </modal>

      <button href="#" @click="handleToggleModal">{{ $t('clearCart') }}</button>
    </div>
  </template>
</template>

<script>
import {ref} from "vue";
import Product from '../components/Product'
import Modal from '../components/Modal'

export default {
  name: 'HomeView',
  components: {
    Product,
    Modal
  },
  setup() {
    const mode = ref('cart');
    const showModal = ref(false);
    let products = JSON.parse(localStorage.getItem('products'));
    
    const handleClearLocalstorage = () => {
      localStorage.clear();
      location.reload();
      return false;
    }

    const handleCloseModal = (n) => {
      showModal.value = false;
      if(n) {
        handleClearLocalstorage();
      }
    }

    const handleToggleModal = () => {
      showModal.value = !showModal.value;
    }

    return {
      handleClearLocalstorage,
      handleCloseModal,
      handleToggleModal,
      showModal,
      mode,
      products
    }
  }
}
</script>

Generate Unique ID:

let Id = 0;

export default () => {
    return Id++;
};

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

What is the optimal method for defining a JSON serialization format for a TypeScript class?

Currently, I am in the process of developing a program using Angular and TypeScript. Within this program, there is a specific class named Signal that consists of various properties: export class Signal { id: number; obraId: number; obra: string ...

Issue with Typescript typing for the onChange event

I defined my state as shown below: const [updatedStep, updateStepObj] = useState( panel === 'add' ? new Step() : { ...selectedStep } ); Additionally, I have elements like: <TextField ...

What is the approach to constructing an observable that triggers numerous observables depending on the preceding outcome?

One of my endpoints returns { ids: [1, 2, 3, 45] }, while the other endpoint provides values for a given id like { id: 3, value: 30, active: true }. I am currently attempting to create an observable that will call the first endpoint and then, for each id r ...

Can the keys be extracted from the combination of multiple objects?

Basic Example Consider this scenario type Bar = { x: number } | { y: string } | { z: boolean }; Can we achieve type KeysOfBar = 'x' | 'y' | 'z'; I attempted this without success type Attempted = keyof Bar; // ...

Tips for incorporating auth0 into a vue application with typescript

Being a beginner in TypeScript, I've successfully integrated Auth0 with JavaScript thanks to their provided sample code. However, I'm struggling to find any sample applications for using Vue with TypeScript. Any recommendations or resources would ...

Testing NestJS Global ModulesExplore how to efficiently use NestJS global

Is it possible to seamlessly include all @Global modules into a TestModule without the need to manually import them like in the main application? Until now, I've had to remember to add each global module to the list of imports for my test: await Tes ...

Adding Components Dynamically to Angular Parent Dashboard: A Step-by-Step Guide

I have a dynamic dashboard of cards that I created using the ng generate @angular/material:material-dashboard command. The cards in the dashboard are structured like this: <div class="grid-container"> <h1 class="mat-h1">Dashboard</h1> ...

Transforming the unmanaged value state of Select into a controlled one by altering the component

I am currently working on creating an edit form to update data from a database based on its ID. Here is the code snippet I have been using: import React, {FormEvent, useEffect, useState} from "react"; import TextField from "@material ...

Expanding the properties of an object dynamically and 'directly' by utilizing `this` in JavaScript/TypeScript

Is it possible to directly add properties from an object "directly" to this of a class in JavaScript/TypeScript, bypassing the need to loop through the object properties and create them manually? I have attempted something like this but it doesn't se ...

Solving problems with Vue.js by effectively managing array content and reactivity issues

In the past, it was considered a bad practice to use the following code snippet: array = []; This was because if the array was referenced elsewhere, that reference wouldn't be updated correctly. The recommended approach back then was to use array.le ...

What is the best way to extract a specific property from an object?

Consider the following function: getNewColor(): {} { return colors.red; } Along with this object: colors: any = { red: {primary: '#ad2121',secondary: '#FAE3E3'}, blue: {primary: '#1e90ff',secondary: '#D1E8FF&apos ...

Adjusting button alignment when resizing the browser

Is there a method to relocate buttons using Bootstrap 5 in HTML or CSS when the browser is resized? For instance, if the button is centered on a full-screen browser, but I want it at the bottom when resized. Is this achievable? ...

What is the process for importing Buffer into a Quasar app that is using Vite as the build tool

I'm having issues with integrating the eth-crypto module into my Quasar app that utilizes Vite. The errors I'm encountering are related to the absence of the Buffer object, which is expected since it's typically found in the front end. Is ...

Discover how to reference a ref from a different component in Vue 3

I have multiple components that I utilize in a component named QuickButton.vue: <Tabs> <TabPanels> <TabPanel><EmployeesMainData/></TabPanel> <TabPanel><ContactAddressData/></TabPanel> ...

Having difficulty accessing certain code in TypeScript TS

Struggling with a TypeScript if else code that is causing errors when trying to access it. The specific error message being displayed is: "Cannot read properties of undefined (reading 'setNewsProvider')" Code Snippet if (this.newsShow != ...

What is the best way to add a hyperlink to a cell in an Angular Grid column

I need help creating a link for a column cell in my angular grid with a dynamic job id, like /jobs/3/job-maintenance/general. In this case, 3 is the job id. I have element.jobId available. How can I achieve this? Here is the code for the existing column: ...

A guide to implementing unit tests for Angular directives with the Jest testing framework

I am currently integrating jest for unit testing in my Angular project and I am relatively new to using jest for unit tests. Below is the code snippet for DragDropDirective: @HostListener('dragenter',['$event']) @HostListener(& ...

Do [(ngModel)] bindings strictly adhere to being strings?

Imagine a scenario where you have a radiobutton HTML element within an angular application, <div class="radio"> <label> <input type="radio" name="approvedeny" value="true" [(ngModel)]=_approvedOrDenied> Approve < ...

Encountering TS 2694 Error while running the ng serve command

I'm encountering some troublesome errors while attempting to run my angular application. Honestly, I can't figure out what's wrong, so I'm hoping someone here can assist me. I didn't make any significant changes, just added a singl ...

Combining marker, circle, and polygon layers within an Angular 5 environment

I am working on a project where I have an array of places that are being displayed in both a table and a map. Each element in the table is represented by a marker, and either a circle or polygon. When an element is selected from the table, the marker icon ...