pagination — Pagination
Pagination is the standard navigation for when “the amount of data exceeds one screen”. Its core difficulty is not turning pages but the display of the page-number range: with 100 pages you cannot draw all 100 buttons; how to use the first/last pages plus the current window and ellipses to convey position while still offering quick jumps is something every library solves in its own way. core/pagination implements the common “window shrinking” algorithm and normalizes all kinds of invalid input.
When to use
Section titled “When to use”- Each framework implements
LhPagination: table footer pagination, search-result pagination, album pagination. - Scenarios where data is paginated and the user explicitly controls the page number.
- Don’t use it: infinite scroll loading (use
virtual-listor a scroll listener); data small enough to fit on one screen (not paginating is simpler).
Import
Section titled “Import”import { getPageCount, clampPage, getPaginationRange, getPaginationItemAria, formatMessage,} from '@lh-ui/share/core/pagination'import type { LhPaginationRangeItem, LhPaginationRangeState, LhPaginationMessageParams,} from '@lh-ui/share/core/pagination'Design intent
Section titled “Design intent”getPageCount dual-parameter sanitization: both total and pageSize pass through shared/sanitize.sanitizeNumber — NaN / Infinity cannot leak through and become a NaN page count. This defense is not redundant: total often comes from an async API and may be undefined while loading, Math.ceil(undefined / 10) is NaN, and NaN would break clampPage and the range algorithm entirely.
clampPage treats NaN and Infinity differently: NaN is treated as invalid and falls back to the first page; ±Infinity is left to clamping — Infinity lands on the last page, -Infinity on the first. This distinction is meaningful in practice: an illegal value passed in from outside under controlled mode (NaN) should return to a safe position, while using Infinity to express “jump to the last page” is legitimate usage.
The range algorithm’s clamping logic: current in getPaginationRange is first normalized by clampPage — a comment records a real scenario: the innerCurrent forwarded by the UI layer may briefly go out of range (under controlled mode the external and internal values are out of sync, or current has not caught up after the total shrank), and without clamping the current page could fall outside the display window and the ellipsis logic would invert.
Ellipses are only emitted when they actually produce a gap: showLeftEllipsis = leftSibling > 2 rather than > 1 — when the window is flush against the first page (e.g. current=3, siblingCount=1), only the single page “2” would be elided on the left, and showing the contiguous 1,2,3,4,5 is clearer than inserting an ellipsis.
Function table
Section titled “Function table”| Function | Signature | Returns | Purpose |
|---|---|---|---|
getPageCount |
(total, pageSize) => number |
total page count (minimum 1) | dual-parameter sanitization |
clampPage |
(page, pageCount) => number |
clamped page number | NaN→1, Infinity→last page |
getPaginationRange |
(state: LhPaginationRangeState) => LhPaginationRangeItem[] |
array of range items | window-shrinking algorithm |
getPaginationItemAria |
(options: { page, current, labelTemplate? }) => LhAriaAttrs |
aria attribute bag | aria-current / aria-label |
formatMessage |
re-exported from shared/format |
interpolated copy | preserves the existing import path |
Type definitions
Section titled “Type definitions”export type LhPaginationRangeItemType = 'page' | 'ellipsis'
export interface LhPaginationRangeItem { type: LhPaginationRangeItemType page: number // an ellipsis item is always 0; its position is its meaning}
export interface LhPaginationRangeState { current: number // current page (1-based) pageCount: number siblingCount?: number // number of page buttons on each side of the current page, default 1}
export type LhPaginationMessageParams = Record<string, string | number>Accessibility
Section titled “Accessibility”getPaginationItemAria({ page: 3, current: 3, labelTemplate: 'Page {page}' })// { 'aria-current': 'page', 'aria-label': 'Page 3' }
getPaginationItemAria({ page: 5, current: 3, labelTemplate: 'Page {page}' })// { 'aria-label': 'Page 5' }
getPaginationItemAria({ page: 5, current: 3 })// {} (no aria-label is emitted without a template; the number inside the button is itself readable)- The current page emits
aria-current="page"— this is the standard APG convention for pagination, letting screen readers announce “current page” instead of treating the page number as a plain link. - Ellipsis buttons must have an
aria-label(e.g. “jump forward 5 pages”) or bedisabled— a bare…text carries no information for screen readers, and this is the most common pagination accessibility flaw. - The pagination as a whole should be wrapped in
<nav aria-label="Pagination">to distinguish it from other page navigation. labelTemplatecomes from the locale pack (pagination.page, etc.), with the{page}placeholder replaced by this function.
Usage examples
Section titled “Usage examples”import { getPageCount, clampPage, getPaginationRange } from '@lh-ui/share/core/pagination'
getPageCount(100, 10) // 10getPageCount(105, 10) // 11getPageCount(0, 10) // 1 (minimum 1 page)getPageCount(NaN, 10) // 1 (total is sanitized to 0, then raised to 1 by Math.max(1, …))getPageCount(100, NaN) // 100 (pageSize is sanitized to the default 1, i.e. 1 item per page)
clampPage(5, 10) // 5clampPage(0, 10) // 1clampPage(15, 10) // 10clampPage(NaN, 10) // 1clampPage(Infinity, 10) // 10 (Infinity lands on the last page)
// siblingCount defaults to 1, total slots = 1*2 + 5 = 7getPaginationRange({ current: 1, pageCount: 10 })// [1, 2, 3, …, 10]getPaginationRange({ current: 5, pageCount: 10 })// [1, …, 4, 5, 6, …, 10]getPaginationRange({ current: 10, pageCount: 10 })// [1, …, 8, 9, 10]getPaginationRange({ current: 5, pageCount: 5 })// [1, 2, 3, 4, 5] (fewer pages than slots: show everything, no ellipses)getPaginationRange({ current: 99, pageCount: 10 })// [1, …, 8, 9, 10] (an out-of-range current is clamped)<!-- Vue3 --><nav class="lh-pagination" aria-label="Pagination"> <button type="button" class="lh-pagination__prev" :disabled="current <= 1" @click="go(current - 1)"> Previous </button> <template v-for="(item, i) in range" :key="i"> <span v-if="item.type === 'ellipsis'" class="lh-pagination__ellipsis">…</span> <button v-else type="button" class="lh-pagination__item" :class="{ 'is-active': item.page === current }" v-bind="getPaginationItemAria({ page: item.page, current, labelTemplate: t('pagination.page') })" @click="go(item.page)" > {{ item.page }} </button> </template> <button type="button" class="lh-pagination__next" :disabled="current >= pageCount" @click="go(current + 1)"> Next </button> <select v-model="pageSize" class="lh-pagination__sizes"> <option :value="10">10 / page</option> <option :value="20">20 / page</option> <option :value="50">50 / page</option> </select> <span class="lh-pagination__total">Total {{ total }}</span></nav>const pageCount = computed(() => getPageCount(props.total, pageSize.value))const range = computed(() => getPaginationRange({ current: current.value, pageCount: pageCount.value }),)function go(page: number) { const next = clampPage(page, pageCount.value) if (next !== current.value) emit('update:current', next)}// React: controlled pagination, current is held by the parent component; clampPage inside go before the callback// uniapp: <view> + <view> iterating the range items; page jumping uses <input type="number"> + a jump buttonStyles and tokens
Section titled “Styles and tokens”| Class name | Semantics |
|---|---|
.lh-pagination |
base class (flex centered) |
.lh-pagination__prev / __next |
previous / next page buttons |
.lh-pagination__item |
page-number button |
.lh-pagination__item.is-active |
current page (primary-color background) |
.lh-pagination__ellipsis |
ellipsis |
.lh-pagination__sizes |
page-size selector |
.lh-pagination__total / __jump |
total-count text / jump input |
The current page uses a --lh-primary-color background + --lh-color-on-primary text; disabled buttons use --lh-text-disabled; button spacing is controlled by --lh-gap-small.
Caveats
Section titled “Caveats”- Page numbers are 1-based:
current,page, andpageCountare all 1-based, unliketable’s row indices (0-based) — mixing them up is easy. - The relationship between
pageCountandtotal: the UI layer usually computes it viagetPageCount(total, pageSize), but you can also passpageCountdirectly (when the server already knows the total page count). Do not use both sources at once, to avoid inconsistency. - Convergence of
currentwhenpageSizechanges: switching from 50 to 10 items per page may leave the current page beyond the newpageCount— callclampPageonce insidewatch(pageSize), otherwise an empty page is shown. - Ellipsis interaction: clicking an ellipsis usually jumps 5 pages (
current ± 5); core does not build this in — the UI layer implements it and gives the ellipsis button anaria-label. siblingCountand slots:totalSlots = siblingCount * 2 + 5(first/last page + current page + two ellipsis slots + siblings on both sides). WhenpageCount <= totalSlots, everything is shown with no ellipses — withsiblingCount: 2the threshold is 9 pages.- Brief out-of-range values in controlled mode:
getPaginationRangeclampscurrentinternally, so rendering never breaks; butaria-currentwill land on the clamped page, and it naturally becomes consistent once the external state syncs. - The
formatMessagere-export: this module re-exportsformatMessagefromshared/formatwith the same binding only to preserve the downstream import path (interpolating copy likepagination.totalthat carries a{total}placeholder); new code should import directly fromcore/shared/format.
@lh-ui/share/core/pagination → getPaginationRange( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue