<!-- htmlhint doctype-first:false, tag-pair:false -->
<!-- Reusable Pagination Controls for Admin UI -->
<!--
Usage:
Set variables before including this template:
- pagination: dict with page, per_page, total_items, total_pages, has_next, has_prev
- base_url: string like '/admin/tools'
- hx_target: string like '#tools-list'
- hx_indicator: string like '#tools-loading'
- query_params: optional dict of additional query parameters
-->
<div x-data="{
currentPage: {{ pagination.page }},
perPage: {{ pagination.per_page }},
totalItems: {{ pagination.total_items }},
totalPages: {{ pagination.total_pages }},
hasNext: {{ 'true' if pagination.has_next else 'false' }},
hasPrev: {{ 'true' if pagination.has_prev else 'false' }},
targetSelector: '{{ hx_target|default("#tools-table") }}',
swapStyle: '{{ hx_swap|default("innerHTML") }}',
// Navigate to specific page
goToPage(page) {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page;
this.loadPage(page);
}
},
// Navigate to previous page
prevPage() {
if (this.hasPrev) {
this.goToPage(this.currentPage - 1);
}
},
// Navigate to next page
nextPage() {
if (this.hasNext) {
this.goToPage(this.currentPage + 1);
}
},
// Change page size
changePageSize(size) {
this.perPage = size;
this.currentPage = 1;
this.loadPage(1);
},
// Load page via HTMX
loadPage(page) {
const url = new URL('{{ base_url }}', window.location.origin);
url.searchParams.set('page', page);
url.searchParams.set('per_page', this.perPage);
// Preserve other query parameters
{% if query_params %}
{% for key, value in query_params.items() %}
url.searchParams.set('{{ key }}', '{{ value }}');
{% endfor %}
{% endif %}
// Scroll to the top of the table before loading new content
const targetElement = document.querySelector(this.targetSelector);
if (targetElement) {
// Find the parent section/panel to scroll to
const panel = targetElement.closest('.tab-panel, .bg-white, .shadow');
if (panel) {
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// Trigger HTMX request - use innerHTML so we only replace tbody content (rows)
// The OOB swap will handle the pagination controls separately
htmx.ajax('GET', url.toString(), {
target: this.targetSelector,
swap: this.swapStyle,
indicator: '{{ hx_indicator|default("#loading") }}'
});
}
}" class="flex flex-col sm:flex-row items-center justify-between gap-4 py-4 border-t border-gray-200 dark:border-gray-700">
<!-- Page Size Selector -->
<div class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>Show:</span>
<select
x-model="perPage"
@change="changePageSize($event.target.value)"
class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400"
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
<span>per page</span>
</div>
<!-- Page Info -->
<div class="text-sm text-gray-700 dark:text-gray-300">
<span x-text="`Showing ${Math.min((currentPage - 1) * perPage + 1, totalItems)} - ${Math.min(currentPage * perPage, totalItems)} of ${totalItems.toLocaleString()} items`"></span>
</div>
<!-- Page Navigation -->
<div class="flex items-center gap-2">
<!-- First Page Button -->
<button
@click="goToPage(1)"
:disabled="!hasPrev"
:class="hasPrev ? 'text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20' : 'text-gray-400 dark:text-gray-600 cursor-not-allowed'"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-50 transition-colors"
title="First Page"
>
⏮️
</button>
<!-- Previous Page Button -->
<button
@click="prevPage()"
:disabled="!hasPrev"
:class="hasPrev ? 'text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20' : 'text-gray-400 dark:text-gray-600 cursor-not-allowed'"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-50 transition-colors"
title="Previous Page"
>
◀️ Prev
</button>
<!-- Page Number Display -->
<div class="flex items-center gap-1">
<!-- Show first page if not near start -->
<template x-if="currentPage > 3">
<button
@click="goToPage(1)"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-gray-700 dark:text-gray-300"
>
1
</button>
</template>
<!-- Ellipsis if needed -->
<template x-if="currentPage > 4">
<span class="px-2 text-gray-500 dark:text-gray-500">...</span>
</template>
<!-- Show 2 pages before current -->
<template x-for="i in [currentPage - 2, currentPage - 1]" :key="i">
<button
x-show="i >= 1"
@click="goToPage(i)"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-gray-700 dark:text-gray-300"
x-text="i"
></button>
</template>
<!-- Current Page (highlighted) -->
<button
class="px-3 py-1 rounded-md border-2 border-indigo-600 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-900/20 font-semibold text-indigo-700 dark:text-indigo-300"
disabled
x-text="currentPage"
></button>
<!-- Show 2 pages after current -->
<template x-for="i in [currentPage + 1, currentPage + 2]" :key="i">
<button
x-show="i <= totalPages"
@click="goToPage(i)"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-gray-700 dark:text-gray-300"
x-text="i"
></button>
</template>
<!-- Ellipsis if needed -->
<template x-if="currentPage < totalPages - 3">
<span class="px-2 text-gray-500 dark:text-gray-500">...</span>
</template>
<!-- Show last page if not near end -->
<template x-if="currentPage < totalPages - 2">
<button
@click="goToPage(totalPages)"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-gray-700 dark:text-gray-300"
x-text="totalPages"
></button>
</template>
</div>
<!-- Next Page Button -->
<button
@click="nextPage()"
:disabled="!hasNext"
:class="hasNext ? 'text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20' : 'text-gray-400 dark:text-gray-600 cursor-not-allowed'"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-50 transition-colors"
title="Next Page"
>
Next ▶️
</button>
<!-- Last Page Button -->
<button
@click="goToPage(totalPages)"
:disabled="!hasNext"
:class="hasNext ? 'text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20' : 'text-gray-400 dark:text-gray-600 cursor-not-allowed'"
class="px-3 py-1 rounded-md border border-gray-300 dark:border-gray-600 disabled:opacity-50 transition-colors"
title="Last Page"
>
⏭️
</button>
</div>
<!-- Keyboard Shortcuts Helper (Optional) -->
<div class="hidden sm:block text-xs text-gray-500 dark:text-gray-500">
<span class="inline-flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded">←</kbd>
<span>/</span>
<kbd class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded">→</kbd>
<span>to navigate</span>
</span>
</div>
</div>
<!-- Keyboard Navigation Support -->
<script>
document.addEventListener('alpine:init', () => {
// Add keyboard shortcuts for pagination
document.addEventListener('keydown', (e) => {
// Only handle if not in an input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
// Trigger previous page - the Alpine.js component will check hasPrev
const prevBtn = document.querySelector('[\\@click="prevPage()"]');
if (prevBtn && !prevBtn.disabled) prevBtn.click();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
// Trigger next page - the Alpine.js component will check hasNext
const nextBtn = document.querySelector('[\\@click="nextPage()"]');
if (nextBtn && !nextBtn.disabled) nextBtn.click();
}
});
});
</script>