Infinite scroll

Asks for the next page of a list as its end comes into view, with a spinner, a retry after a failure, and a line when there is no more.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add infinite-scroll
pnpm dlx ermineui add infinite-scroll
yarn dlx ermineui add infinite-scroll
bunx ermineui add infinite-scroll

That copies 2 files into src/components/ui/infinite-scroll/, and the 2 components it is built from. Files you already have are kept.

Use

import { InfiniteScroll } from '@/components/ui/infinite-scroll'
<PullToRefresh v-slot="{ refresh }" :on-refresh="reload">
  <Button @click="refresh">Refresh</Button>
  <InfiniteScroll :loading="loading" :done="!hasMore" :error="error" @load="loadNextPage">…your list…</InfiniteScroll>
</PullToRefresh>

<InstallPrompt description="Opens straight to your orders, and works offline." />
<UpdatePrompt />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
InfiniteScroll.vuesrc/components/ui/infinite-scroll/InfiniteScroll.vue
index.tssrc/components/ui/infinite-scroll/index.ts

It is built from: Button, Spinner. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

InfiniteScroll.vue 107 lines
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Button } from '../button'
import { Spinner } from '../spinner'

const props = withDefaults(
  defineProps<{
    /** Set while a page is on its way. Nothing more is asked for until it is false again. */
    loading?: boolean
    /** There is nothing left to load. */
    done?: boolean
    /** What went wrong with the last load. Loading stops until "Try again" is pressed. */
    error?: string
    /** Ask only when the button is pressed, never by scrolling: for pages with a footer people need to reach. */
    manual?: boolean
    /** How far before the end to start loading, as a CSS margin. */
    margin?: string
    loadLabel?: string
    loadingLabel?: string
    retryLabel?: string
    doneLabel?: string
  }>(),
  { margin: '300px', loadLabel: 'Load more', loadingLabel: 'Loading more…', retryLabel: 'Try again', doneLabel: 'You have reached the end.' },
)

const emit = defineEmits<{
  /** The end is in view, or the button was pressed. Fetch the next page and add it to your list. */
  load: []
}>()

const sentinel = ref<HTMLElement | null>(null)
const inView = ref(false)
let observer: IntersectionObserver | undefined

const idle = () => !props.loading && !props.done && !props.error

function load() {
  if (!props.loading && !props.done) emit('load')
}

onMounted(() => {
  // Without an observer (a very old browser) the button is still there, so the list can always be continued.
  if (!sentinel.value || !('IntersectionObserver' in window)) return
  observer = new IntersectionObserver(
    ([entry]) => {
      if (!entry) return
      inView.value = entry.isIntersecting
      if (entry.isIntersecting && !props.manual && idle()) emit('load')
    },
    { rootMargin: props.margin },
  )
  observer.observe(sentinel.value)
})

onBeforeUnmount(() => observer?.disconnect())

// A short page can leave the end in view after it loads: ask again rather than wait for a scroll that cannot happen.
watch(
  () => props.loading,
  (loading) => {
    if (!loading && inView.value && !props.manual && idle()) emit('load')
  },
  { flush: 'post' },
)
</script>

<template>
  <div class="er-infinite-scroll">
    <slot />
    <div ref="sentinel" class="er-infinite-scroll-end">
      <!-- Always in the page, so each change of state is announced. -->
      <p class="er-infinite-scroll-status" role="status">
        <template v-if="loading"><Spinner label="" /> {{ loadingLabel }}</template>
        <template v-else-if="error">{{ error }}</template>
        <!-- What to say when there is no more. -->
        <slot v-else-if="done" name="done">{{ doneLabel }}</slot>
      </p>
      <Button v-if="!loading && !done && (error || manual)" variant="outline" size="sm" @click="load">{{ error ? retryLabel : loadLabel }}</Button>
    </div>
  </div>
</template>

<style>
@layer components {
  .er-infinite-scroll-end {
    display: grid;
    justify-items: center;
    gap: 0.5rem;
    min-height: 1px;
    padding-block: 1rem;
  }

  .er-infinite-scroll-status {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin: 0;
    color: var(--er-muted-foreground);
    font-size: 0.875rem;
  }

  .er-infinite-scroll-status:empty {
    display: none;
  }
}
</style>
index.ts 3 lines
/** Infinite scroll — asks for the next page of a list as its end comes into view, with a spinner, a retry after a failure, and a line when there is no more. */
export { default as InfiniteScroll } from './InfiniteScroll.vue'