Infinite list ​

WInfiniteList loads a paginated query page by page as it is scrolled, and renders each item with its default slot. It lays out nothing itself, so it fits any item layout — WList and the async selects are built on it. You give it:

  • useQueryFn and queryParams — a paginated query. Each page is its own query with page and size added to the params.
  • pageLength — the page size the query returns.
  • A default slot for an item. While a page loads, the slot renders placeholders with skeleton set.

A change of queryParams, such as a new search, resets the list to the first page. Only a few pages stay rendered (maxPages). Scrolling further drops pages at the other end and keeps their height, so the scrollbar stays true to the whole list.

0 plants
1
2
3
4
5
6
7
8
9
10
vue
<template>
  <WInfiniteListScrollingElement class="h-96 overflow-y-auto overscroll-contain rounded-xl border border-solid border-line-subtle">
    <WInfiniteList
      :use-query-fn="plantModelApi.paginated.use"
      :query-params="{search}"
      :page-length="10"
      page-class="grid"
      empty-stub="No plants found"
      min-height-only
      @update:count="count = $event"
    >
      <template #header>
        <div class="flex items-center gap-4 px-4 pb-3">
          <WInput
            v-model="search"
            placeholder="Search by name or species"
            class="flex-1"
            no-margin
          />

          <span class="text-description text-sm whitespace-nowrap">
            {{ count }} plants
          </span>
        </div>
      </template>

      <template #default="{item, skeleton, position}">
        <div class="flex items-baseline gap-3 border-t border-solid border-line-subtle px-4 py-2">
          <span class="text-description w-6 text-right text-sm">
            {{ position + 1 }}
          </span>

          <WSkeleton v-if="skeleton" />

          <span v-else>
            {{ item.name }} <span class="text-description italic">— {{ item.species }}</span>
          </span>
        </div>
      </template>
    </WInfiniteList>
  </WInfiniteListScrollingElement>
</template>

<script lang="ts" setup>
import {ref} from 'vue'

import WInfiniteList from 'eco-vue-js/dist/components/InfiniteList/WInfiniteList.vue'
import WInfiniteListScrollingElement from 'eco-vue-js/dist/components/InfiniteList/WInfiniteListScrollingElement.vue'
import WInput from 'eco-vue-js/dist/components/Input/WInput.vue'
import WSkeleton from 'eco-vue-js/dist/components/Skeleton/WSkeleton.vue'

// The `use` of any paginated query — here the plant model from the list recipe, 10 plants a page.
import {plantModelApi} from '../../../../../docs/examples/recipes/plant-list/api/Plant'

const search = ref<string>()
const count = ref(0)
</script>

Scrolling inside an element ​

By default the list loads as the page scrolls. WInfiniteListScrollingElement makes an element the scroll container instead, for the lists and sticky headers inside it — give it a height and overflow-y-auto, as above. Modals are scroll containers of their own already.

The header slot sticks above the items while they scroll: under the app header on a page, and at the top of a modal or scroll container. It gets refetchAll, isFetching and isRefetchingAll for a toolbar with a refresh button. updateHeader re-measures it right away, when its content changes in a way the list has to know about at once.

WInfiniteListWrapper is the same sticky header for any content, without a list:

vue
<WInfiniteListWrapper>
  <template #header>
    <PageToolbar />
  </template>

  <ReportSections />
</WInfiniteListWrapper>

Items ​

Besides item and skeleton, the default slot gets:

  • setter — writes a changed item to the query cache, which updates it in every loaded page and every other query of the same model. Called with nothing, it removes the item.
  • refetch — refetches this page and the pages after it, e.g. after an item moved.
  • previous and next — the neighbouring items, across page borders; first and last for the ends of the list.
  • position — the item's index in the whole list; page and index — its page and index in the page.

Pages ​

A template ref gives refetchAll, resetPage(page) to start over from a page, and goto(page, index) to scroll to an item. A page that isn't rendered is rendered in its place, with the pages around it; before the list knows the page count, it starts over from the page instead.

update:page reports the page reached by scrolling. Keeping it in the URL and passing it back as page in queryParams opens the list at that page after a reload — the pages before it load when scrolling up.

Long lists ​

However long the query, the list keeps only maxPages pages in the DOM. Pages scrolled past are dropped and leave a space of their height, and their queries stay cached, so scrolling back renders them again at once. Spacers above and below the rendered pages stand in for the rest, sized by the number of pages times the average height of those rendered, so the scrollbar covers the whole list from the first page on. Browsers cap the height of an element, so for a list too long to fit under 15M px — about a million rows — the spacers shrink to fit, and the scrollbar moves through the list faster than the rows scroll.

The demo below pages through 10,000 events, 50 at a time. The counter shows how many rows are in the DOM while scrolling. "Go to row" calls goto with the row's page and its index in the page, and the pages around it load in place.

0 of 0 rows rendered
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
vue
<template>
  <div
    ref="container"
    class="grid gap-3"
  >
    <div class="flex flex-wrap items-center gap-x-6 gap-y-2">
      <form
        class="flex items-center gap-2"
        @submit.prevent="jump"
      >
        <WInput
          v-model="row"
          type="number"
          :min="1"
          :max="EVENT_COUNT"
          placeholder="Row"
          class="w-28"
          no-margin
        />

        <WButton
          :semantic-type="SemanticType.SECONDARY"
          type="submit"
        >
          Go to row
        </WButton>
      </form>

      <span class="text-description text-sm tabular-nums">
        {{ rendered }} of {{ count.toLocaleString('en') }} rows rendered
      </span>
    </div>

    <WInfiniteListScrollingElement class="h-96 overflow-y-auto overscroll-contain rounded-xl border border-solid border-line-subtle">
      <WInfiniteList
        ref="list"
        :use-query-fn="eventModelApi.paginated.use"
        :query-params="{}"
        :page-length="PAGE_LENGTH"
        page-class="grid"
        min-height-only
        @update:count="count = $event"
      >
        <template #default="{item, skeleton, position}">
          <div
            class="event-row flex h-9 items-center gap-3 border-b border-solid border-line-subtle px-4 text-sm"
          >
            <span class="text-description w-12 text-right tabular-nums">
              {{ position + 1 }}
            </span>

            <WSkeleton v-if="skeleton" />

            <template v-else>
              <span
                class="w-16 font-semibold"
                :class="levelClass[item.level]"
              >
                {{ item.level }}
              </span>

              <span class="text-description w-12 tabular-nums">
                {{ timeFormatter.format(item.at) }}
              </span>

              <span class="truncate">{{ item.message }}</span>
            </template>
          </div>
        </template>
      </WInfiniteList>
    </WInfiniteListScrollingElement>
  </div>
</template>

<script lang="ts" setup>
import {onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue'

import {SemanticType} from 'eco-vue-js/dist/utils/SemanticType'

import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'
import WInfiniteList from 'eco-vue-js/dist/components/InfiniteList/WInfiniteList.vue'
import WInfiniteListScrollingElement from 'eco-vue-js/dist/components/InfiniteList/WInfiniteListScrollingElement.vue'
import WInput from 'eco-vue-js/dist/components/Input/WInput.vue'
import WSkeleton from 'eco-vue-js/dist/components/Skeleton/WSkeleton.vue'

import {EVENT_COUNT, EventLevel, eventModelApi} from './api/Event'

const PAGE_LENGTH = 50

const levelClass: Record<EventLevel, string> = {
  [EventLevel.INFO]: 'tone-info text-tone',
  [EventLevel.WARNING]: 'tone-warning text-tone',
  [EventLevel.ERROR]: 'tone-negative text-tone',
}

const timeFormatter = Intl.DateTimeFormat('en', {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'})

const listRef = useTemplateRef('list')
const containerRef = useTemplateRef('container')

const count = ref(0)
const row = ref<number>()

// Scrolls to a loaded page, or starts the list over from the row's page.
const jump = () => {
  if (!row.value) return

  const index = Math.min(Math.max(row.value, 1), count.value || EVENT_COUNT) - 1

  listRef.value?.goto(Math.floor(index / PAGE_LENGTH) + 1, index % PAGE_LENGTH)
}

// Only for the demo: counts the rows in the DOM, to show that only a few pages are rendered at a time.
const rendered = ref(0)

let observer: MutationObserver | undefined

onMounted(() => {
  observer = new MutationObserver(() => {
    rendered.value = containerRef.value?.querySelectorAll('.event-row').length ?? 0
  })

  if (containerRef.value) observer.observe(containerRef.value, {childList: true, subtree: true})
})

onBeforeUnmount(() => observer?.disconnect())
</script>

API ​

WInfiniteList ​

ts
import WInfiniteList from 'eco-vue-js/dist/components/InfiniteList/WInfiniteList.vue'

Props ​

PropTypeDefaultDescription
useQueryFnUseQueryDefault<PaginatedResponse<Data>, QueryParams>requiredPaginated query the pages are loaded with. Each page gets queryParams with its page and size.
queryParamsQueryParamsrequiredParams of the query. A change resets the list to the first page. A page starts the list at that page.
skeletonLengthnumber—Number of skeleton items while the first pages load, e.g. the known total count. Defaults to a full page.
transitionboolean—Animates items as they are added or removed.
minHeightboolean—Renders without the sticky header and fills the height of its container, e.g. for a list inside a dropdown.
minHeightOnlyboolean—Drops the full-screen minimum height and the bottom padding, for a list inside other content.
noHeaderUpdateboolean—Keeps the sticky header out of the app header bar's padding while scrolled.
excludeParams(keyof QueryParams)[]—Params whose change refetches the loaded pages in place instead of resetting the list to the first page.
emptyStubstring—Text shown when the query returns no items. The empty slot replaces it.
pageClassstring—Class of the element holding the items of all loaded pages, e.g. a grid layout. The pages render no element of their own, so a grid row runs on from one page into the next.
pageStyleStyleValue—Style of the element holding the items, e.g. the columns of a grid.
maxPagesnumber—Number of pages kept rendered, 5 by default. Scrolling further drops pages at the other end, keeping their height as space.
refetchIntervalnumber | false—Refetches the pages in view every this many ms.
queryOptionsDefaultQueryOptions<PaginatedResponse<Data>>—Options for every page query.
pageLengthnumber24Page size, sent to the query as size. Must match the size the query returns.
valueGetter((data: Data) => Model)(item as unknown as { id: Model; }).idUnique value of an item, for the item's key and the value slot prop. Defaults to its id.

Events ​

EventPayloadDescription
update:page(number | undefined)The page reached by scrolling, for keeping it in the URL. undefined after a reset to the first page.
update:count(number)Total number of items, from the query's count.
update:error(ApiError<{}, ErrorResponse<{}>>)A page query failed. A page that answers 404 or 400 is dropped, and the list ends before it.

Slots ​

SlotPropsDescription
default{ item: Data; setter: (newItem?: Data | undefined) => void; skeleton: boolean; refetch: () => void; previous: Data | undefined; next: Data | undefined; first: boolean; last: boolean; resetting: boolean; page: number; index: number; position: number; value: Model; results: Data[] | undefined; intersecting: boolean; }An item, or a skeleton placeholder while its page loads. setter writes a changed item to the query cache, or removes it when called with nothing; position is the item's index in the whole list.
headerInfiniteListHeaderScope & Partial<InfiniteListScope>Sticky content above the items, such as a toolbar. Gets refetchAll, resetPage, goto, isFetching and isRefetchingAll, and updateHeader to re-measure it right away.
empty—Shown when the query returns no items. Replaces emptyStub.
error{ error: Error; retry: () => Promise<void>; isFetching: boolean; }Shown instead of a page that failed to load and has nothing cached. Gets the error, retry to load the page again, and isFetching. Replaces the message and Retry button.

WInfiniteListScrollingElement ​

ts
import WInfiniteListScrollingElement from 'eco-vue-js/dist/components/InfiniteList/WInfiniteListScrollingElement.vue'

Props ​

PropTypeDefaultDescription
parentboolean—Uses the parent element as the scroll container instead of this one.

Slots ​

SlotPropsDescription
default—Content with infinite lists and sticky headers that scroll inside this element instead of the page.

WInfiniteListWrapper ​

ts
import WInfiniteListWrapper from 'eco-vue-js/dist/components/InfiniteList/WInfiniteListWrapper.vue'

Props ​

PropTypeDefaultDescription
initIsIntersectingboolean—Whether the top of the content starts in view, before the first scroll is observed. false when opening at a later page.
noHeaderUpdateboolean—Keeps the sticky header out of the app header bar's padding while scrolled.

Slots ​

SlotPropsDescription
headerInfiniteListHeaderScopeContent that sticks to the top while the page scrolls. updateHeader re-measures it right away.
defaultInfiniteListHeaderScopeThe scrolling content. Gets the header's headerHeight and headerTop.