Modal.vue 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. <template>
  2. <transition name="fade" appear @leave="onTransitionLeaveStart">
  3. <div
  4. ref="modal"
  5. class="inset-0 transition z-10 z-50 fixed hide-scrollbar overflow-y-auto"
  6. >
  7. <div
  8. class="flex min-h-screen text-center items-end justify-center sm:block"
  9. >
  10. <transition name="fade" appear>
  11. <div
  12. class="bg-primaryLight opacity-90 inset-0 transition fixed"
  13. @touchstart="!dialog ? close() : null"
  14. @touchend="!dialog ? close() : null"
  15. @mouseup="!dialog ? close() : null"
  16. @mousedown="!dialog ? close() : null"
  17. ></div>
  18. </transition>
  19. <span
  20. v-if="placement === 'center'"
  21. class="hidden sm:h-screen sm:inline-block sm:align-middle"
  22. aria-hidden="true"
  23. >&#8203;</span
  24. >
  25. <transition
  26. appear
  27. enter-active-class="transition"
  28. enter-class="translate-y-4 scale-95"
  29. enter-to-class="translate-y-0 scale-100"
  30. leave-active-class="transition"
  31. leave-class="translate-y-0 scale-100"
  32. leave-to-class="translate-y-4 scale-95"
  33. >
  34. <div
  35. class="
  36. bg-primary
  37. shadow-xl
  38. text-left
  39. w-full
  40. transform
  41. transition-all
  42. inline-block
  43. align-bottom
  44. overflow-hidden
  45. sm:align-middle sm:rounded-xl
  46. "
  47. :class="[
  48. { 'mt-24 md:mb-8': placement === 'top' },
  49. { 'p-4': !fullWidth },
  50. maxWidth,
  51. ]"
  52. >
  53. <div
  54. v-if="title"
  55. class="flex mb-4 pl-2 items-center justify-between"
  56. >
  57. <h3 class="heading">{{ title }}</h3>
  58. <span class="flex">
  59. <slot name="actions"></slot>
  60. <ButtonSecondary
  61. v-if="dimissible"
  62. class="rounded"
  63. svg="x"
  64. @click.native="close"
  65. />
  66. </span>
  67. </div>
  68. <div
  69. class="flex flex-col max-h-md overflow-y-auto hide-scrollbar"
  70. :class="{ 'py-2': !fullWidth }"
  71. >
  72. <slot name="body"></slot>
  73. </div>
  74. <div
  75. v-if="hasFooterSlot"
  76. class="flex flex-1 mt-4 p-2 items-center justify-between"
  77. >
  78. <slot name="footer"></slot>
  79. </div>
  80. </div>
  81. </transition>
  82. </div>
  83. </div>
  84. </transition>
  85. </template>
  86. <script lang="ts">
  87. import { defineComponent } from "@nuxtjs/composition-api"
  88. import { useKeybindingDisabler } from "~/helpers/keybindings"
  89. const PORTAL_DOM_ID = "hoppscotch-modal-portal"
  90. // Why ?
  91. const stack = (() => {
  92. const stack: number[] = []
  93. return {
  94. push: stack.push.bind(stack),
  95. pop: stack.pop.bind(stack),
  96. peek: () => (stack.length === 0 ? undefined : stack[stack.length - 1]),
  97. }
  98. })()
  99. export default defineComponent({
  100. props: {
  101. dialog: {
  102. type: Boolean,
  103. default: false,
  104. },
  105. title: {
  106. type: String,
  107. default: "",
  108. },
  109. dimissible: {
  110. type: Boolean,
  111. default: true,
  112. },
  113. placement: {
  114. type: String,
  115. default: "top",
  116. },
  117. fullWidth: {
  118. type: Boolean,
  119. default: false,
  120. },
  121. maxWidth: {
  122. type: String,
  123. default: "sm:max-w-lg",
  124. },
  125. },
  126. setup() {
  127. const { disableKeybindings, enableKeybindings } = useKeybindingDisabler()
  128. return {
  129. disableKeybindings,
  130. enableKeybindings,
  131. }
  132. },
  133. data() {
  134. return {
  135. stackId: Math.random(),
  136. // when doesn't fire on unmount, we should manually remove the modal from DOM
  137. // (for example, when the parent component of this modal gets destroyed)
  138. shouldCleanupDomOnUnmount: true,
  139. }
  140. },
  141. computed: {
  142. hasFooterSlot(): boolean {
  143. return !!this.$slots.footer
  144. },
  145. },
  146. mounted() {
  147. const $portal = this.$getPortal()
  148. $portal.appendChild(this.$refs.modal as any)
  149. stack.push(this.stackId)
  150. document.addEventListener("keydown", this.onKeyDown)
  151. this.disableKeybindings()
  152. },
  153. beforeDestroy() {
  154. const $modal = this.$refs.modal
  155. if (this.shouldCleanupDomOnUnmount && $modal) {
  156. this.$getPortal().removeChild($modal as any)
  157. }
  158. stack.pop()
  159. document.removeEventListener("keydown", this.onKeyDown)
  160. },
  161. methods: {
  162. close() {
  163. this.$emit("close")
  164. this.enableKeybindings()
  165. },
  166. onKeyDown(e: KeyboardEvent) {
  167. if (e.key === "Escape" && this.stackId === stack.peek()) {
  168. e.preventDefault()
  169. this.close()
  170. }
  171. },
  172. onTransitionLeaveStart() {
  173. this.close()
  174. this.shouldCleanupDomOnUnmount = false
  175. },
  176. $getPortal() {
  177. let $el = document.querySelector("#" + PORTAL_DOM_ID)
  178. if ($el) {
  179. return $el
  180. }
  181. $el = document.createElement("DIV")
  182. $el.id = PORTAL_DOM_ID
  183. document.body.appendChild($el)
  184. return $el
  185. },
  186. },
  187. })
  188. </script>