Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
168 lines
3.8 KiB
Vue
168 lines
3.8 KiB
Vue
<template>
|
|
<section :class="{ 'is-full-height-scrollable': scrollable }">
|
|
<header v-if="$slots.header">
|
|
<slot name="header"></slot>
|
|
</header>
|
|
<main ref="content" :data-scrolling="scrollable">
|
|
<div class="is-scrollbar-progress is-hidden-mobile">
|
|
<scroll-progress v-show="paused" :indeterminate="loading" :auto-hide="!loading"></scroll-progress>
|
|
</div>
|
|
<slot :setLoading="setLoading"></slot>
|
|
<div ref="scrollObserver" class="is-scroll-observer"></div>
|
|
</main>
|
|
|
|
<div class="is-scrollbar-notification">
|
|
<transition name="fade">
|
|
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused">
|
|
<icon name="download"></icon>
|
|
</button>
|
|
</transition>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script>
|
|
import Icon from "./Icon";
|
|
import ScrollProgress from "./ScrollProgress";
|
|
|
|
export default {
|
|
props: {
|
|
scrollable: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
},
|
|
components: {
|
|
Icon,
|
|
ScrollProgress,
|
|
},
|
|
name: "ScrollableView",
|
|
data() {
|
|
return {
|
|
paused: false,
|
|
hasMore: false,
|
|
loading: false,
|
|
};
|
|
},
|
|
mounted() {
|
|
const { content } = this.$refs;
|
|
const mutationObserver = new MutationObserver((e) => {
|
|
if (!this.paused) {
|
|
this.scrollToBottom("instant");
|
|
} else {
|
|
const record = e[e.length - 1];
|
|
if (
|
|
record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]
|
|
) {
|
|
this.hasMore = true;
|
|
}
|
|
}
|
|
});
|
|
mutationObserver.observe(content, { childList: true, subtree: true });
|
|
this.$once("hook:beforeDestroy", () => mutationObserver.disconnect());
|
|
|
|
const intersectionObserver = new IntersectionObserver(
|
|
(entries) => (this.paused = entries[0].intersectionRatio == 0),
|
|
{ threshholds: [0, 1], rootMargin: "80px 0px" }
|
|
);
|
|
intersectionObserver.observe(this.$refs.scrollObserver);
|
|
this.$once("hook:beforeDestroy", () => intersectionObserver.disconnect());
|
|
},
|
|
|
|
methods: {
|
|
scrollToBottom(behavior = "instant") {
|
|
this.$refs.scrollObserver.scrollIntoView({ behavior });
|
|
this.hasMore = false;
|
|
},
|
|
setLoading(loading) {
|
|
this.loading = loading;
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
<style scoped lang="scss">
|
|
section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
header {
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--body-background-color);
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
&.is-full-height-scrollable {
|
|
height: 100vh;
|
|
min-height: 0;
|
|
}
|
|
|
|
main {
|
|
flex: 1;
|
|
overflow: auto;
|
|
scroll-snap-type: y proximity;
|
|
}
|
|
|
|
.is-scrollbar-progress {
|
|
text-align: right;
|
|
margin-right: 110px;
|
|
.scroll-progress {
|
|
position: fixed;
|
|
top: 60px;
|
|
z-index: 2;
|
|
}
|
|
}
|
|
|
|
.is-scroll-observer {
|
|
height: 1px;
|
|
}
|
|
|
|
.is-scrollbar-notification {
|
|
text-align: right;
|
|
margin-right: 65px;
|
|
button {
|
|
position: fixed;
|
|
bottom: 30px;
|
|
background-color: var(--secondary-color);
|
|
transition: background-color 1s ease-out;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
|
border: none !important;
|
|
color: #222;
|
|
|
|
&.has-more {
|
|
background-color: var(--primary-color);
|
|
animation-name: bounce;
|
|
animation-duration: 1000ms;
|
|
animation-fill-mode: both;
|
|
color: #fff;
|
|
}
|
|
}
|
|
}
|
|
|
|
@keyframes bounce {
|
|
0%,
|
|
20%,
|
|
50%,
|
|
80%,
|
|
100% {
|
|
transform: translateY(0);
|
|
}
|
|
40% {
|
|
transform: translateY(-30px);
|
|
}
|
|
60% {
|
|
transform: translateY(-15px);
|
|
}
|
|
}
|
|
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.15s ease-in;
|
|
}
|
|
.fade-enter,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
</style>
|