Add reusable pagination composable and PaginationBar component

Generic usePagination composable with page navigation, size control,
and auto-reset on data change. PaginationBar renders prev/next with
page size selector. Both support i18n.
This commit is contained in:
Pier-Jean Malandrino 2026-04-02 13:41:50 +02:00
parent 13ac8a11f0
commit 102a11765a
6 changed files with 429 additions and 8 deletions

View file

@ -8,7 +8,6 @@
"name": "docling-studio",
"version": "0.1.0",
"dependencies": {
"@vitest/mocker": "^4.1.2",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -19,6 +18,7 @@
"@eslint/js": "^9.0.0",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/mocker": "^4.1.2",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0",
@ -78,6 +78,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
@ -93,6 +94,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -108,6 +110,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -123,6 +126,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -138,6 +142,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -153,6 +158,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -168,6 +174,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -183,6 +190,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -198,6 +206,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -213,6 +222,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -228,6 +238,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -243,6 +254,7 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -258,6 +270,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -273,6 +286,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -288,6 +302,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -303,6 +318,7 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -318,6 +334,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -333,6 +350,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@ -348,6 +366,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@ -363,6 +382,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@ -378,6 +398,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@ -393,6 +414,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
@ -408,6 +430,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@ -423,6 +446,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -438,6 +462,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -453,6 +478,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -661,6 +687,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -673,6 +700,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -685,6 +713,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -697,6 +726,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -709,6 +739,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -721,6 +752,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -733,6 +765,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -745,6 +778,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -757,6 +791,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -769,6 +804,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -781,6 +817,7 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -793,6 +830,7 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -805,6 +843,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -817,6 +856,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -829,6 +869,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -841,6 +882,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -853,6 +895,7 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -865,6 +908,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -877,6 +921,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -889,6 +934,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@ -901,6 +947,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
@ -913,6 +960,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -925,6 +973,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -937,6 +986,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -949,6 +999,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -989,7 +1040,8 @@
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
@ -1307,6 +1359,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"dev": true,
"dependencies": {
"@vitest/spy": "4.1.2",
"estree-walker": "^3.0.3",
@ -1372,6 +1425,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"dev": true,
"funding": {
"url": "https://opencollective.com/vitest"
}
@ -1828,7 +1882,7 @@
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"devOptional": true,
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@ -2055,6 +2109,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
@ -2099,7 +2154,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=12.0.0"
},
@ -2163,6 +2218,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@ -2540,7 +2596,7 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=12"
},
@ -2655,7 +2711,7 @@
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"devOptional": true,
"dev": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -2797,7 +2853,7 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"devOptional": true,
"dev": true,
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
@ -2909,7 +2965,7 @@
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"devOptional": true,
"dev": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest'
import { ref, nextTick } from 'vue'
import { usePagination } from './usePagination'
function makeItems(n: number): string[] {
return Array.from({ length: n }, (_, i) => `item-${i + 1}`)
}
describe('usePagination', () => {
it('returns all items when total fits in one page', () => {
const items = ref(makeItems(5))
const { paginatedItems, pageCount, page } = usePagination(items, { pageSize: 20 })
expect(paginatedItems.value).toHaveLength(5)
expect(pageCount.value).toBe(1)
expect(page.value).toBe(1)
})
it('slices items according to page size', () => {
const items = ref(makeItems(25))
const { paginatedItems, pageCount } = usePagination(items, { pageSize: 10 })
expect(paginatedItems.value).toHaveLength(10)
expect(paginatedItems.value[0]).toBe('item-1')
expect(pageCount.value).toBe(3)
})
it('navigates with next and prev', () => {
const items = ref(makeItems(30))
const { paginatedItems, page, next, prev } = usePagination(items, { pageSize: 10 })
next()
expect(page.value).toBe(2)
expect(paginatedItems.value[0]).toBe('item-11')
next()
expect(page.value).toBe(3)
expect(paginatedItems.value[0]).toBe('item-21')
prev()
expect(page.value).toBe(2)
expect(paginatedItems.value[0]).toBe('item-11')
})
it('clamps page within valid range', () => {
const items = ref(makeItems(10))
const { page, prev, next, goTo } = usePagination(items, { pageSize: 5 })
prev()
expect(page.value).toBe(1)
next()
next()
expect(page.value).toBe(2)
goTo(99)
expect(page.value).toBe(2)
goTo(0)
expect(page.value).toBe(1)
})
it('goTo navigates to a specific page', () => {
const items = ref(makeItems(50))
const { page, paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
goTo(3)
expect(page.value).toBe(3)
expect(paginatedItems.value[0]).toBe('item-21')
goTo(5)
expect(page.value).toBe(5)
expect(paginatedItems.value[0]).toBe('item-41')
})
it('resets to page 1 when items change', async () => {
const items = ref(makeItems(30))
const { page, next } = usePagination(items, { pageSize: 10 })
next()
next()
expect(page.value).toBe(3)
items.value = makeItems(5)
await nextTick()
expect(page.value).toBe(1)
})
it('resets to page 1 when page size changes', async () => {
const items = ref(makeItems(50))
const { page, next, setPageSize } = usePagination(items, { pageSize: 10 })
next()
next()
expect(page.value).toBe(3)
setPageSize(20)
await nextTick()
expect(page.value).toBe(1)
})
it('handles last page with fewer items', () => {
const items = ref(makeItems(23))
const { paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
goTo(3)
expect(paginatedItems.value).toHaveLength(3)
expect(paginatedItems.value[0]).toBe('item-21')
})
it('handles empty items', () => {
const items = ref<string[]>([])
const { paginatedItems, pageCount, totalItems } = usePagination(items)
expect(paginatedItems.value).toHaveLength(0)
expect(pageCount.value).toBe(1)
expect(totalItems.value).toBe(0)
})
it('exposes totalItems as reactive count', async () => {
const items = ref(makeItems(10))
const { totalItems } = usePagination(items)
expect(totalItems.value).toBe(10)
items.value = makeItems(42)
await nextTick()
expect(totalItems.value).toBe(42)
})
it('defaults page size to 20', () => {
const items = ref(makeItems(50))
const { pageSize, paginatedItems } = usePagination(items)
expect(pageSize.value).toBe(20)
expect(paginatedItems.value).toHaveLength(20)
})
it('ignores invalid page size', () => {
const items = ref(makeItems(10))
const { pageSize, setPageSize } = usePagination(items, { pageSize: 5 })
setPageSize(0)
expect(pageSize.value).toBe(5)
setPageSize(-10)
expect(pageSize.value).toBe(5)
})
})

View file

@ -0,0 +1,71 @@
import { ref, computed, watch, type Ref } from 'vue'
export interface PaginationOptions {
pageSize?: number
}
export interface PaginationReturn<T> {
page: Ref<number>
pageSize: Ref<number>
pageCount: Ref<number>
paginatedItems: Ref<T[]>
totalItems: Ref<number>
next: () => void
prev: () => void
goTo: (target: number) => void
setPageSize: (size: number) => void
}
export function usePagination<T>(
items: Ref<T[]>,
options: PaginationOptions = {},
): PaginationReturn<T> {
const page = ref(1)
const pageSize = ref(options.pageSize ?? 20)
const totalItems = computed(() => items.value.length)
const pageCount = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize.value)))
const paginatedItems = computed(() => {
const start = (page.value - 1) * pageSize.value
return items.value.slice(start, start + pageSize.value)
})
// Reset to page 1 when source data or page size changes
watch([items, pageSize], () => {
page.value = 1
})
function clamp(target: number): number {
return Math.max(1, Math.min(target, pageCount.value))
}
function goTo(target: number): void {
page.value = clamp(target)
}
function next(): void {
goTo(page.value + 1)
}
function prev(): void {
goTo(page.value - 1)
}
function setPageSize(size: number): void {
if (size > 0) pageSize.value = size
}
return {
page,
pageSize,
pageCount,
paginatedItems,
totalItems,
next,
prev,
goTo,
setPageSize,
}
}

View file

@ -106,6 +106,11 @@ const messages: Messages = {
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
// Pagination
'pagination.pageOf': 'Page {current} sur {total}',
'pagination.perPage': '/ page',
// Settings
'settings.title': 'Paramètres',
@ -207,6 +212,10 @@ const messages: Messages = {
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Run chunking to prepare segments.',
'chunking.noChunksOnPage': 'No chunks on this page.',
'pagination.pageOf': 'Page {current} of {total}',
'pagination.perPage': '/ page',
'settings.title': 'Settings',
'settings.apiUrl': 'API URL',

View file

@ -0,0 +1,135 @@
<template>
<div v-if="pageCount > 1" class="pagination-bar">
<div class="pagination-nav">
<button class="pagination-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
<span class="pagination-info">
{{ t('pagination.pageOf', { current: String(page), total: String(pageCount) }) }}
</span>
<button
class="pagination-btn"
:disabled="page >= pageCount"
@click="$emit('update:page', page + 1)"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="pagination-size">
<select
:value="pageSize"
class="pagination-select"
@change="$emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="size in pageSizeOptions" :key="size" :value="size">
{{ size }} {{ t('pagination.perPage') }}
</option>
</select>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../i18n'
defineProps<{
page: number
pageCount: number
pageSize: number
}>()
defineEmits<{
'update:page': [value: number]
'update:pageSize': [value: number]
}>()
const { t } = useI18n()
const pageSizeOptions = [5, 10, 20, 50]
</script>
<style scoped>
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-top: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
}
.pagination-nav {
display: flex;
align-items: center;
gap: 4px;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.pagination-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.pagination-btn:disabled {
opacity: 0.35;
cursor: default;
}
.pagination-icon {
width: 14px;
height: 14px;
}
.pagination-info {
font-size: 12px;
color: var(--text-muted);
padding: 0 6px;
white-space: nowrap;
}
.pagination-size {
flex-shrink: 0;
}
.pagination-select {
font-size: 11px;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text-muted);
cursor: pointer;
}
.pagination-select:hover {
border-color: var(--accent);
}
</style>

View file

@ -1 +1,2 @@
export { default as AppSidebar } from './AppSidebar.vue'
export { default as PaginationBar } from './PaginationBar.vue'