@empathyco/x-components
Version:
Empathy X Components
180 lines (177 loc) • 7.74 kB
JavaScript
import { defineComponent, ref, computed, provide, onMounted, getCurrentInstance, onBeforeUnmount, watch } from 'vue';
import { DISABLE_ANIMATIONS_KEY } from '../../../components/decorators/injection.consts.js';
import { useState } from '../../../composables/use-state.js';
import { useXBus } from '../../../composables/use-x-bus.js';
import { scrollXModule } from '../x-module.js';
import { ScrollObserverKey } from './scroll.const.js';
/**
* Extends the scroll making it able to sync the first visible element, and allowing
* the children position to be restored.
*
* Each child element that wants to have this support must be wrapped in a {@link MainScrollItem}
* component.
*
* @public
*/
var _sfc_main = defineComponent({
name: 'MainScroll',
xModule: scrollXModule.name,
props: {
/**
* If `true`, sets this scroll instance to the main of the application. Being the main
* scroll implies that features like restoring the scroll when the query changes, or storing
* the scroll position in the URL will be enabled for this container.
*/
useWindow: {
type: Boolean,
default: false,
},
/**
* Timeout in milliseconds to abort trying to restore the scroll position to the target
* element.
*/
restoreScrollTimeoutMs: {
type: Number,
default: 5000,
},
/** Intersection percentage to consider an element visible. */
threshold: {
type: Number,
default: 0.3,
},
/** Adjusts the size of the scroll container bounds. */
margin: {
type: String,
default: '0px',
},
},
setup(props, { slots }) {
const xBus = useXBus();
/** The reference to the root element of the component. */
const rootRef = ref();
/** The elements that are currently considered visible. */
const intersectingElements = ref([]);
/** Intersection observer to determine visibility of the elements. */
const intersectionObserver = ref(null);
/**
* Pending identifier scroll position to restore. If it matches the {@link MainScrollItem} item
* `id` property, this component should be scrolled into view.
*/
const { pendingScrollTo } = useState('scroll');
/** Disables the animations. */
const disableAnimations = computed(() => !!pendingScrollTo.value);
provide(DISABLE_ANIMATIONS_KEY, disableAnimations);
/**
* Removes an element from the {@link MainScroll.intersectingElements} list.
*
* @param element - The element to remove from the visible elements.
*/
const removeVisibleElement = (element) => {
const index = intersectingElements.value.indexOf(element);
if (index !== -1) {
intersectingElements.value.splice(index, 1);
}
};
/**
* Creates an `IntersectionObserver` to detect the first visible elements. Children of this
* component should register themselves if they want to be observed.
*
* @returns The intersection observer.
*/
const visibleElementsObserver = computed(() => {
const observer = intersectionObserver.value;
return observer
? {
observe: observer.observe.bind(observer),
unobserve: element => {
removeVisibleElement(element);
observer.unobserve(element);
},
}
: null;
});
provide(ScrollObserverKey, visibleElementsObserver);
/**
* Updates the visible elements given a list of intersection observer entries.
*
* @param entries - The entries from whom update the visibility.
*/
const updateVisibleElements = (entries) => {
entries.forEach(entry => {
const target = entry.target;
if (entry.isIntersecting) {
intersectingElements.value.push(target);
}
else {
removeVisibleElement(target);
}
});
};
/** Stores the root element and initialise the observer after mounting the component. */
onMounted(() => {
rootRef.value = getCurrentInstance()?.proxy?.$el;
if (rootRef.value) {
intersectionObserver.value = new IntersectionObserver(updateVisibleElements, {
root: props.useWindow ? document : rootRef.value,
threshold: props.threshold,
rootMargin: props.margin,
});
}
});
/** Disconnects the intersection observer. */
onBeforeUnmount(() => {
intersectionObserver.value?.disconnect();
xBus.emit('UserScrolledToElement', '');
});
/** Disconnects the previous observer. */
watch(intersectionObserver, (_new, old) => old?.disconnect());
/** Stores the identifier of the timeout that will consider the scroll failed to restore. */
let restoreScrollFailTimeoutId;
/**
* If there is a pending scroll, starts a countdown to stop trying to restore the scroll.
*
* @param pendingScrollTo - The position the scroll should be restored to.
*/
watch(pendingScrollTo, () => {
// TODO Move this logic to the wiring. A cancelable delay operator is needed
clearTimeout(restoreScrollFailTimeoutId);
if (pendingScrollTo.value) {
restoreScrollFailTimeoutId = window.setTimeout(() => {
xBus.emit('ScrollRestoreFailed');
}, props.restoreScrollTimeoutMs);
}
});
/**
* The first visible element contained in this component.
*
* @returns The first visible element in this component.
*/
const firstVisibleElement = computed(() => {
if (intersectingElements.value.length === 0) {
return '';
}
const firstVisibleElement = intersectingElements.value.reduce((firstVisibleElement, anotherElement) => {
// FIXME: This algorithm only takes into account LTR layouts
const firstVisibleElementBounds = firstVisibleElement.getBoundingClientRect();
const anotherElementBounds = anotherElement.getBoundingClientRect();
return anotherElementBounds.left <= firstVisibleElementBounds.left &&
anotherElementBounds.top <= firstVisibleElementBounds.top
? anotherElement
: firstVisibleElement;
});
return firstVisibleElement === rootRef.value?.querySelector('[data-scroll]')
? ''
: firstVisibleElement.dataset.scroll;
});
watch(firstVisibleElement, () => xBus.emit('UserScrolledToElement', firstVisibleElement.value), { immediate: true });
/*
* Obtains the vNodes array of the default slot and renders only the first one.
* It avoids to render a `Fragment` with the vNodes in Vue3 and the same behaviour in Vue2
* because Vue2 only allows a single root node. Then, `getCurrentInstance()?.proxy?.$el` to
* retrieve the HTML element in both versions.
*/
return () => slots.default?.()[0] ?? '';
},
});
export { _sfc_main as default };
//# sourceMappingURL=main-scroll.vue.js.map