Preference 172. ScrollSentinel watches the end of a cursor-paged list and asks for the next page a screen early. It asks once per cursor, keeps going when a page lands without pushing the end off screen, and stops on a failure until Try again (an observer re-firing on a failure is a request loop). A failed older page now gets its own error beside the retry, instead of replacing the list's load error. Adapted from fabledstash's ScrollSentinel (snippet 3760). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
2.8 KiB
Vue
77 lines
2.8 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, ref, watch } from "vue";
|
|
|
|
// The end of a cursor-paged list: asks for the next page as the reader nears it,
|
|
// in place of a "Show older" button (preference 172). The parent keeps its cursor,
|
|
// its in-flight flag and its fetch; this owns the observing.
|
|
//
|
|
// <ScrollSentinel :cursor="cursor" :loading="loadingMore" :error="moreError" @more="loadMore" />
|
|
//
|
|
// `cursor` is empty once the list is complete, and then nothing renders.
|
|
|
|
const props = defineProps<{ cursor: string; loading: boolean; error?: string }>();
|
|
const emit = defineEmits<{ more: [] }>();
|
|
|
|
const sentinel = ref<HTMLElement | null>(null);
|
|
const visible = ref(false);
|
|
|
|
// One request per page. The observer fires on every crossing, and a short last
|
|
// page leaves the sentinel on screen for good. Keying on the cursor stops a page
|
|
// being asked for twice, and stops a failed one being retried on every scroll,
|
|
// since a failure leaves the cursor where it was. Only Try again re-asks.
|
|
let asked = "";
|
|
|
|
function pump() {
|
|
if (!visible.value || props.loading || props.error || !props.cursor) return;
|
|
if (props.cursor === asked) return;
|
|
asked = props.cursor;
|
|
emit("more");
|
|
}
|
|
|
|
let observer: IntersectionObserver | undefined;
|
|
|
|
function observe(el: HTMLElement | null) {
|
|
observer?.disconnect();
|
|
observer = undefined;
|
|
if (!el) {
|
|
// Visibility goes with the element, or a list that starts over would fetch
|
|
// its second page on the old list's scroll position.
|
|
visible.value = false;
|
|
return;
|
|
}
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
visible.value = entries.some((e) => e.isIntersecting);
|
|
pump();
|
|
},
|
|
// Extends the watched area a screen below the viewport, so the page is asked
|
|
// for before the reader reaches the end rather than when they do.
|
|
{ rootMargin: "0px 0px 100% 0px" },
|
|
);
|
|
observer.observe(el);
|
|
}
|
|
|
|
// flush: "post": the template ref is only set once the DOM is patched.
|
|
watch(sentinel, observe, { flush: "post" });
|
|
|
|
// A page that lands without pushing the end off screen gives the observer nothing
|
|
// new to report, so carry on from here or the list stops a page early.
|
|
watch([() => props.cursor, () => props.loading], pump);
|
|
|
|
onBeforeUnmount(() => observer?.disconnect());
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="cursor" role="status" aria-live="polite">
|
|
<!-- The one case with a button: an observer re-firing on a failure is a request loop. -->
|
|
<div v-if="error" class="flex items-center gap-3">
|
|
<p class="form-error">{{ error }}</p>
|
|
<button type="button" class="btn-sm" @click="emit('more')">Try again</button>
|
|
</div>
|
|
<template v-else>
|
|
<div ref="sentinel" aria-hidden="true"></div>
|
|
<p v-if="loading" class="text-sm text-neutral-400">Loading older…</p>
|
|
</template>
|
|
</div>
|
|
</template>
|