Files
inkwell/frontend/src/components/ScrollSentinel.vue
T
bvandeusenandClaude Opus 5.5 eb6a1ff3ef Web: Activity loads older events as you scroll, no Show older button
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>
2026-10-08 16:54:19 -04:00

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>