Reputation: 4921
Tring to create a (semi) reusable Modal Component based on Bootstrap 5,with vuejs 3 and composible API. Managed to get it partially working,
Given (Mostly standard Bootstrap 5 modal, but with classes being added based on 'show' prop, and slots in 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 'called' 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 should be in the body</template>
<template #footer>
<button class="btn btn-primary">Extra footer button</button>
</template>
</Modal>
</template>
I get a modal 'shown' but the fade in animation doesn't work ,and the backdrop isn't visible, and the data-bs- buttons in the modal don't work ( i.e. it won't close). I feel its something to do with my whole approach.
NOTE. I cannot use a standard button with data-bs-toggle="modal" data-bs-target="#exampleModal"
attributes as the actual trigger of this model comes from the logic of another component (as in just setting a bool), and the reusable modal component will be independent of its trigger --- it also doesn't feel the proper 'Vue' way to do it.
So I think I'm just 'showing' the html, and I need to instantiate a bootstrap modal somehow... just not sure how to do it
package.json (well the relavant ones)
"dependencies": {
"@popperjs/core": "^2.11.2",
"bootstrap": "^5.1.3",
"vue": "^3.2.31",
},
Code sand box here (Couldn't get the new Composition API and TS with working on code sandbox, so its a slight re-write with the standard options API approach, so code is slightly different, but exibits the same behaviour)
Upvotes: 13
Views: 23060
Reputation: 83
For Future Comers,
<template>
<div class="modal fade" id="id-of-modal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
/* body */
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Modal } from 'bootstrap'
onMounted(async () => {
modal.value = new Modal('#id-of-modal', {})
})
const modal = ref(null)
function showModal() {
modal.value.show();
}
function closeModal() {
modal.value.hide();
}
/*
other codes
*/
<script>
Thats it.
N.B. Make sure, ID is matched with your modal's ID
Upvotes: 0
Reputation: 4921
OK.. so a few more hours I came up with a solution, posting here as it may help others.
The bootstrap modal 'Object' needs to be created. So first had to import the modal object from bootstrap. Its creation needed a DOM reference, so had to add a ref
to the html element, and a ref
prop in the script to hold the link to it.
The DOM references in Vue aren't populated until the component is mounted so the construction of the Bootstrap modal object needs to be done in Onmounted as the ref
will now link to the actual DOM element.
Then instead of passing a show prop down, as making this keep in sync between parent and child was cumbersome, I just exposed a show
method on the dialog component itself (also feels a bit more elegant). Since <script setup>
objects are CLOSED BY DEFAULT
, the exposure of the method is done via defineExpose
.. and we now all disco
<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 the 'parent'
<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>
.. probably should additionally should add an OnUnmount to clean up the object to be tidy.
Upvotes: 24
Reputation: 73
I'm not using TS but it can easily be converted into TS. Make sure you keep the Accessibility features (focus trap, esc to close the modal, aria attributes) !!! This is how I did it. The only thing I would change is the backdrop animation it's a bit sloppy. (Also i included a simple utility to generate unique ID's).
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">×</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>
Unique Id:
let Id = 0;
export default () => {
return Id++;
};
Upvotes: 1