Files
FabledGrimoire/frontend/src/components/ScrollSentinel.vue
T
bvandeusenandClaude Opus 5.5 373f5bd454
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 36s
feat(6839): the decision log and the knowledge feed load as you scroll, through one ScrollSentinel
ScrollSentinel sits at the foot of a server-paged list and asks for the
next page a screen before the reader reaches it. It asks once per cursor,
forgets the cursor when its element goes, and shows Try again on a failed
page instead of retrying in a loop.

FamilyView's decision log loses its "Older decisions" button. A failed
page shows in place rather than as a toast, and rows already shown are
skipped when a new decision shifts the offset.

KnowledgeView drops its inline IntersectionObserver for the component.
The cursor is the ids consumed, not the items rendered, so a batch that
returns fewer records than it asked for still moves the feed on. A failed
batch puts its ids back and offers Try again, where it used to fail
silently and stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:14:41 -05:00

97 lines
3.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* The end of a server-paged list: asks for the next page as the reader nears
* it, in place of a "Load more" button. The parent keeps the paging — what the
* next page is keyed on, whether one is in flight, whether the last one
* failed — and this owns the observing.
*
* <ScrollSentinel :cursor="more ? String(rows.length) : ''"
* :loading="loadingMore" :failed="moreFailed" @more="loadMore" />
*
* An empty cursor means the list is complete, and nothing renders.
*/
import { onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{ cursor: string; loading: boolean; failed?: boolean }>();
const emit = defineEmits<{ more: [] }>();
const sentinel = ref<HTMLElement | null>(null);
const visible = ref(false);
// One request per page. The observer reports every crossing, and a short last
// page leaves the sentinel on screen for good, so the cursor last asked for is
// remembered: a page is asked for once, and a failed one is not retried on
// every scroll, because a failure leaves the cursor where it was. Only
// Try again asks for it again.
let asked = "";
function pump() {
if (!visible.value || props.loading || props.failed || !props.cursor) return;
if (props.cursor === asked) return;
asked = props.cursor;
emit("more");
}
function retry() {
asked = props.cursor;
emit("more");
}
let observer: IntersectionObserver | undefined;
function observe(el: HTMLElement | null) {
observer?.disconnect();
observer = undefined;
if (!el) {
// Visibility and the cursor asked for go with the element. A list that
// starts over must not inherit "the end is on screen" from the one it
// replaced, and an offset cursor repeats from list to list, so "50" asked
// of the old list must not stop the new one asking for its own "50".
visible.value = false;
asked = "";
return;
}
observer = new IntersectionObserver(
(entries) => {
visible.value = entries.some((e) => e.isIntersecting);
pump();
},
// Extends the watched area a screen below the viewport, so the next 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 the props, or the list stops early.
watch([() => props.cursor, () => props.loading, () => props.failed], pump);
onBeforeUnmount(() => observer?.disconnect());
</script>
<template>
<div v-if="cursor" class="sentinel">
<!-- The one case with a button: an observer re-firing on a failure would be
a request loop. -->
<p v-if="failed" class="sentinel-note sentinel-failed" role="alert">
Couldn’t load more.
<button type="button" class="btn-secondary btn-sm" @click="retry">Try again</button>
</p>
<template v-else>
<div ref="sentinel" class="sentinel-mark" aria-hidden="true"></div>
<p v-if="loading" class="sentinel-note" role="status">Loading more…</p>
</template>
</div>
</template>
<style scoped>
.sentinel { grid-column: 1 / -1; padding: 0.75rem 0; text-align: center; }
.sentinel-mark { height: 1px; }
.sentinel-note { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
.sentinel-failed { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
</style>