UNPKG

@empathyco/x-components

Version:
227 lines (224 loc) • 9.26 kB
import { defineComponent, ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'; import '../../composables/create-use-device.js'; import 'vuex'; import '@vue/devtools-api'; import '../../plugins/devtools/timeline.devtools.js'; import '@empathyco/x-utils'; import 'rxjs/operators'; import 'rxjs'; import '../../plugins/devtools/colors.utils.js'; import '../../plugins/x-bus.js'; import '../../plugins/x-plugin.js'; import { useDebounce } from '../../composables/use-debounce.js'; import '@vueuse/core'; import { AnimationProp } from '../../types/animation-prop.js'; import { FOCUSABLE_SELECTORS } from '../../utils/focus.js'; import { getTargetElement } from '../../utils/html.js'; import '../../utils/storage.js'; import '../animations/animate-clip-path/animate-clip-path.style.scss.js'; import '../animations/animate-scale/animate-scale.style.scss.js'; import '../animations/animate-translate/animate-translate.style.scss.js'; import '../animations/animate-width.vue2.js'; import '../animations/animate-width.vue3.js'; import '../animations/change-height.vue2.js'; import '../animations/collapse-height.vue2.js'; import '../animations/collapse-height.vue3.js'; import '../animations/collapse-width.vue2.js'; import '../animations/collapse-width.vue3.js'; import '../animations/cross-fade.vue2.js'; import '../animations/cross-fade.vue3.js'; import '../animations/fade-and-slide.vue2.js'; import '../animations/fade-and-slide.vue3.js'; import Fade from '../animations/fade.vue.js'; import _sfc_main$1 from '../animations/no-animation.vue.js'; import '../animations/staggered-fade-and-slide.vue2.js'; import '../animations/staggered-fade-and-slide.vue3.js'; /** * Base component with no XPlugin dependencies that serves as a utility for constructing more * complex modals. * * @public */ var _sfc_main = defineComponent({ name: 'BaseModal', props: { /** Determines if the modal is open or not. */ open: { type: Boolean, required: true, }, /** * Determines if the focused element changes to one inside the modal when it opens. Either the * first element with a positive tabindex or just the first focusable element. */ focusOnOpen: { type: Boolean, default: true, }, /** * The reference selector of a DOM element to use as reference to position the modal. * This selector can be an ID or a class, if it is a class, it will use the first * element that matches. */ referenceSelector: String, /** Animation to use for opening/closing the modal.This animation only affects the content. */ animation: { type: AnimationProp, default: () => _sfc_main$1, }, /** * Animation to use for the overlay (backdrop) part of the modal. By default, it uses * a fade transition. */ overlayAnimation: { type: AnimationProp, default: () => Fade, }, /** Class inherited by content element. */ contentClass: String, /** Class inherited by overlay element. */ overlayClass: String, }, emits: ['click:overlay', 'focusin:body'], setup(props, { emit }) { /** Reference to the modal element in the DOM. */ const modalRef = ref(); /** Reference to the modal content element in the DOM. */ const modalContentRef = ref(); /** The previous value of the body overflow style. */ const previousBodyOverflow = ref(''); /** The previous value of the HTML element overflow style. */ const previousHTMLOverflow = ref(''); /** Boolean to delay the leave animation until it has completed. */ const isWaitingForLeave = ref(false); /** The reference element to use to find the modal's position. */ let referenceElement; /** Disables the scroll of both the body and the window. */ function disableScroll() { previousBodyOverflow.value = document.body.style.overflow; previousHTMLOverflow.value = document.documentElement.style.overflow; document.body.style.overflow = document.documentElement.style.overflow = 'hidden'; } /** Restores the scroll of both the body and the window. */ function enableScroll() { document.body.style.overflow = previousBodyOverflow.value; document.documentElement.style.overflow = previousHTMLOverflow.value; } /** * Emits the `click:overlay` event if the click has been triggered in the overlay layer. * * @param event - The click event. */ function emitOverlayClicked(event) { // eslint-disable-next-line vue/custom-event-name-casing emit('click:overlay', event); } /** * Emits the `focusin:body` event if a focus event has been triggered outside the modal. * * @param event - The focusin event. */ function emitFocusInBody(event) { if (!modalContentRef.value?.contains(getTargetElement(event))) { // eslint-disable-next-line vue/custom-event-name-casing emit('focusin:body', event); } } /** * Adds listeners to the body element ot detect if the modal should be closed. * * @remarks TODO find a better solution and remove the timeout * To avoid emit the focusin on opening X that provokes closing it immediately. * This is because this event was emitted after the open of main modal when the user clicks * on the customer website search box (focus event). This way we avoid add the listener before * the open and the avoid the event that provokes the close. */ function addBodyListeners() { setTimeout(() => { document.body.addEventListener('focusin', emitFocusInBody); }); } /** Removes the body listeners. */ function removeBodyListeners() { document.body.removeEventListener('focusin', emitFocusInBody); } /** * Sets the focused element to the first element either the first element with a positive * tabindex or, if there isn't any, the first focusable element inside the modal. */ function setFocus() { const candidates = Array.from(modalContentRef.value?.querySelectorAll(FOCUSABLE_SELECTORS) ?? []); const element = candidates.find(element => element.tabIndex) ?? candidates[0]; element?.focus(); } /** * Syncs the body to the open state of the modal, adding or removing styles and listeners. * * @remarks nextTick() to wait for `modalContentRef` to be updated to look for focusable * candidates inside. * * @param isOpen - True when the modal is opened. */ async function syncBody(isOpen) { if (isOpen) { disableScroll(); addBodyListeners(); if (props.focusOnOpen) { await nextTick(); setFocus(); } } else { enableScroll(); removeBodyListeners(); } } /** * Updates the position of the modal setting the top of the element depending * on the selector. The modal will be placed under this selector. */ const debouncedUpdatePosition = useDebounce(() => { const { height, y } = referenceElement?.getBoundingClientRect() ?? { height: 0, y: 0 }; modalRef.value.style.top = `${height + y}px`; modalRef.value.style.bottom = '0'; modalRef.value.style.height = 'auto'; }, 100, { leading: true }); let resizeObserver; onMounted(() => { watch(() => props.open, syncBody); if (props.open) { syncBody(true); } resizeObserver = new ResizeObserver(debouncedUpdatePosition); watch(() => props.referenceSelector, () => { resizeObserver.disconnect(); if (props.referenceSelector) { const element = document.querySelector(props.referenceSelector); if (element) { referenceElement = element; resizeObserver.observe(element); } } else { referenceElement = undefined; debouncedUpdatePosition(); } }, { immediate: true }); }); onBeforeUnmount(() => { if (props.open) { removeBodyListeners(); enableScroll(); } resizeObserver.disconnect(); }); return { emitOverlayClicked, isWaitingForLeave, modalContentRef, modalRef, }; }, }); export { _sfc_main as default }; //# sourceMappingURL=base-modal.vue2.js.map