GrPagination

Package: @feugene/granularitycoreGroup: navigation

Splits a long list into pages and manages navigation between them.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the list does not fit on the screen — page numbers with the middle truncated;
  • the user chooses the page sizeshowPageSize with the set from pageSizes;
  • the volume has to be knownshowTotal prints “41–60 of 137” next to the navigation;
  • there is little roomcompact replaces the numbers with a “current / total” indicator;
  • there are many pagesshowJumper gives a jump by number instead of stepping through.

When to take something else

NeedTake
The list is loaded by scrollingGrList with virtual and source
There are few rows and they all fitGrTable
Sections are switched rather than pages of dataGrTabs
The course of a long operation has to be shownGrProgressBar

page, pageSize and total arrive from the outside: the component only asks for them to be changed. It knows nothing about the data and cannot know — the application loads it.

A controlled component

page, pageSize and total arrive from the outside, and the component only asks for them to be changed:

<GrPagination
  v-model:page="page"
  v-model:page-size="pageSize"
  :total="total"
/>

Precisely v-model:page and not v-model: the page is a named model, and the component has no modelValue prop. A miss is visible as a modelvalue attribute on the root <div> — an undeclared prop travels there through fallthrough; in dev mode the component additionally prints a warning.

The number of pages is computed from total and pageSize. The numbers are truncated by the boundary/sibling algorithm: the first and the last pages are always visible plus siblingCount neighbours around the current one; boundaryCount sets how many of the edge ones to show. A single skipped number is drawn as a number rather than as an ellipsis — the row does not jump.

A non-numeric input is replaced by the default: a page that did not arrive gives the first page, total gives zero and pageSize gives one, and every case is explained with a warning in dev mode. Without that an undefined would spread across the numbers and the status as NaN, while the markup would stay plausible to the eye.

A page out of range is rendered clamped to [1, pageCount]: until the parent has pulled the value up, there is an active button all the same. When the number of pages has decreased (total fell or pageSize grew), the component additionally emits update:page with the last available page — if page lives in the URL or in a store, that navigation will happen by itself.

What to show

PropWhat it adds
showPageSizea select for the page size; off by default — basic pagination is numbers only
showTotal“41–60 of 137” to the left of the navigation
showJumpera “go to page” field: Enter or the loss of focus applies the number, and one out of range is clamped
compacta “current / total” indicator instead of the numbers — for mobile and for table toolbars

The range as a whole is replaced by the #total slot — it receives from, to and total:

<GrPagination :total="total" show-total>
  <template #total="{ from, to, total }">
    Orders {{ from }}–{{ to }} of {{ total }}
  </template>
</GrPagination>

disabled dims everything at once: the numbers, the buttons, the select and the jump field.

Accessibility

The root is a role="navigation" with a name from the locale (gr.pagination.label). If there are two paginations on the page (above and below a table), set ariaLabel — otherwise there will be two identical landmarks in the overview of a screen reader.

The numbers lie in a list (<ul role="list">), so a screen reader reports their number; the ellipses are excluded from it (aria-hidden). The current page is marked aria-current="page".

A change of page is announced by a live area: in the compact mode it is carried by the visible indicator, and in the ordinary one by a hidden “Page N of M” string (gr.pagination.status).

The sizes

size (xslg) is read from GrConfigProvider (componentDefaults.GrPagination.size). The navigation buttons take their size from the GrButton scale rather than from one of their own: they are obliged to stand in one row with the numbers.

Playground 13

Loading…

Code
<GrPagination />

Install

npm i @feugene/granularity

Import

import { GrPagination } from '@feugene/granularity/components/GrPagination'

API

Props

PropTypedefaultDescription
disabledboolean | undefinedfalseDims the whole pagination: the numbers, the buttons, the page-size select and the jump field.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefinedThe name of the navigation landmark. By default — `gr.pagination.label`; it is worth setting where there are several paginations on the page and they have to be told apart.
compactboolean | undefinedfalseThe compact variant: instead of numbered pages a "current / total" indicator is shown — convenient in tight places (mobile, table toolbars).
pageSizesnumber[] | undefined[10, 20, 50]
siblingCountnumber | undefined1How many neighbouring pages to show around the current one. `1` by default.
boundaryCountnumber | undefined1How many edge pages to always show at each edge. `1` by default.
showJumperboolean | undefinedfalseShow a "go to page" field with a quick jump by the entered number.
showPageSizeboolean | undefinedfalseShow the page-size select.
showTotalboolean | undefinedfalseShow the range of the items displayed — "1–20 of 137". The `#total` slot is stronger.
jumperLabelstring | undefinedundefinedThe i18n label before the jump field. By default — `gr.pagination.jumpTo`.
pagerequirednumber
pageSizerequirednumber
totalrequirednumber

Slots

SlotTypeDescription
total{ from: number; to: number; total: number; }The range of the items displayed as a whole — instead of the string from the locale.

Events

EventTypeDescription
update:page[value: number]
update:pageSize[value: number]

Examples 5

Basic paging feedback loop

Page 3 Page size 10

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

import { GrBadge, GrPagination } from '@feugene/granularity'

const total = ref(137)
const page = ref(3)
const pageSize = ref(10)
</script>

<template>
  <div class="grid gap-3">
    <GrPagination v-model:page="page" v-model:page-size="pageSize" :total="total" show-total />

    <div class="flex flex-wrap gap-2">
      <GrBadge>
        Page {{ page }}
      </GrBadge>
      <GrBadge>
        Page size {{ pageSize }}
      </GrBadge>
    </div>
  </div>
</template>

Page-size changes with page clamping

Total 58 Last available page 5 Active page 5

Page Size
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrBadge, GrButton, GrPagination } from '@feugene/granularity'

const total = ref(58)
const page = ref(5)
const pageSize = ref(12)
const pageSizes = [6, 12, 24]

const pageCount = computed(() => {
  return Math.max(1, Math.ceil(total.value / pageSize.value))
})

function clampPage() {
  page.value = Math.min(page.value, pageCount.value)
}

function setTotal(nextTotal: number) {
  total.value = nextTotal
  clampPage()
}
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap gap-2">
      <GrButton size="sm" :variant="total === 58 ? 'primary' : 'outline'" @click="setTotal(58)">
        58 items
      </GrButton>
      <GrButton size="sm" :variant="total === 23 ? 'primary' : 'outline'" @click="setTotal(23)">
        23 items
      </GrButton>
      <GrButton size="sm" :variant="total === 8 ? 'primary' : 'outline'" @click="setTotal(8)">
        8 items
      </GrButton>
    </div>

    <GrPagination
      v-model:page="page"
      v-model:page-size="pageSize"
      :page-sizes="pageSizes"
      show-page-size
      :total="total"
      @update:page-size="clampPage"
    />

    <div class="flex flex-wrap gap-2">
      <GrBadge>
        Total {{ total }}
      </GrBadge>
      <GrBadge>
        Last available page {{ pageCount }}
      </GrBadge>
      <GrBadge>
        Active page {{ page }}
      </GrBadge>
    </div>
  </div>
</template>

Composition with GrDataTable

Actions
Customer 1Scaleattention
Customer 2Starterhealthy
Customer 3Scalehealthy
Customer 4Starterattention
Customer 5Scalehealthy
No row action yet

Table Composition
<script setup lang="ts">
import { computed, ref } from 'vue'

import {
  GrBadge,
  GrButton,
  GrDataTable,
  GrPagination,
} from '@feugene/granularity'

const page = ref(1)
const pageSize = ref(5)
const pageSizes = [5, 10, 20]
const lastAction = ref('No row action yet')

const rows = Array.from({ length: 18 }, (_, index) => ({
  id: index + 1,
  customer: `Customer ${index + 1}`,
  plan: index % 2 === 0 ? 'Scale' : 'Starter',
  status: index % 3 === 0 ? 'attention' : 'healthy',
}))

const columns = [
  { key: 'customer', label: 'Customer', sortable: true },
  { key: 'plan', label: 'Plan', sortable: true },
  { key: 'status', label: 'Status', sortable: true },
  { key: 'actions', label: 'Actions', align: 'right' as const },
]

const pagedRows = computed(() => {
  const start = (page.value - 1) * pageSize.value
  return rows.slice(start, start + pageSize.value)
})

const pageCount = computed(() => Math.max(1, Math.ceil(rows.length / pageSize.value)))

function clampPage() {
  page.value = Math.min(page.value, pageCount.value)
}
</script>

<template>
  <div class="grid gap-4">
    <GrDataTable :rows="pagedRows" :columns="columns" row-key="id">
      <template #cell-status="{ row }">
        <GrBadge :tone="row.status === 'healthy' ? 'success' : 'warning'">
          {{ row.status }}
        </GrBadge>
      </template>

      <template #cell-actions="{ row }">
        <div class="flex justify-end">
          <GrButton size="sm" variant="ghost" @click="lastAction = `Opened ${row.customer}`">
            Open
          </GrButton>
        </div>
      </template>
    </GrDataTable>

    <GrPagination
      v-model:page="page"
      v-model:page-size="pageSize"
      :page-sizes="pageSizes"
      show-page-size
      :total="rows.length"
      @update:page-size="clampPage"
    />

    <GrBadge>
      {{ lastAction }}
    </GrBadge>
  </div>
</template>

Compact variant and page jumper


Page 7Page size 20Total 482

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

import { GrBadge, GrDivider, GrPagination } from '@feugene/granularity'

const total = ref(482)
const page = ref(7)
const pageSize = ref(20)
</script>

<template>
  <div class="grid gap-4">
    <GrDivider label="compact" align="start" />
    <GrPagination v-model:page="page" v-model:page-size="pageSize" :total="total" compact />

    <GrDivider label="show-jumper" align="start" />
    <GrPagination v-model:page="page" v-model:page-size="pageSize" :total="total" show-jumper />

    <GrDivider label="compact + show-jumper" align="start" />
    <GrPagination v-model:page="page" v-model:page-size="pageSize" :total="total" compact show-jumper />

    <GrDivider />
    <div class="flex flex-wrap gap-2">
      <GrBadge>Page {{ page }}</GrBadge>
      <GrBadge>Page size {{ pageSize }}</GrBadge>
      <GrBadge>Total {{ total }}</GrBadge>
    </div>
  </div>
</template>

Sizes

size="xs"
size="sm"
size="md"
size="lg"

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

import { GrPagination } from '@feugene/granularity'

const sizes = ['xs', 'sm', 'md', 'lg'] as const

const page = ref(4)
const pageSize = ref(20)
</script>

<template>
  <div class="grid gap-4">
    <div v-for="size in sizes" :key="size" class="grid gap-2">
      <div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
        size="{{ size }}"
      </div>

      <GrPagination
        v-model:page="page"
        v-model:page-size="pageSize"
        :total="240"
        :size="size"
      />
    </div>
  </div>
</template>

Accessibility

APG pattern

Full keyboard contract of the package

Component documentationAll components