@empathyco/x-components
Version:
Empathy X Components
227 lines (224 loc) • 9.26 kB
JavaScript
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