120 lines
3.8 KiB
Svelte
120 lines
3.8 KiB
Svelte
<!-- #region UI.Pagination [C:2] [TYPE Component] [SEMANTICS pagination, paging, navigation, ui, atom] -->
|
|
<!-- @ingroup UI -->
|
|
<!-- @BRIEF Reusable pagination controls with prev/next, page numbers, and "Showing X-Y of Z" summary. -->
|
|
<!-- @LAYER UI -->
|
|
<!-- @UX_STATE First -> Prev disabled, page 1 highlighted. -->
|
|
<!-- @UX_STATE Middle -> Prev/Next enabled, current page highlighted. -->
|
|
<!-- @UX_STATE Last -> Next disabled, last page highlighted. -->
|
|
<!-- @UX_STATE Single -> Only one page, both buttons disabled, no page numbers shown. -->
|
|
<script lang="ts">
|
|
import { t } from '$lib/i18n/index.svelte.js';
|
|
import { Button } from "$lib/ui";
|
|
|
|
let {
|
|
page = $bindable(0),
|
|
pageSize = 20,
|
|
total = 0,
|
|
showingText = '',
|
|
pageSizeOptions = [] as number[],
|
|
onPageSizeChange,
|
|
class: className = "",
|
|
} = $props();
|
|
|
|
let totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)));
|
|
let start = $derived(total === 0 ? 0 : page * pageSize + 1);
|
|
let end = $derived(Math.min((page + 1) * pageSize, total));
|
|
|
|
function goToPage(p: number) {
|
|
if (p >= 0 && p < totalPages) page = p;
|
|
}
|
|
|
|
// Generate page number range (max 7 buttons with ellipsis)
|
|
let pageNumbers = $derived.by(() => {
|
|
const pages: (number | "...")[] = [];
|
|
if (totalPages <= 7) {
|
|
for (let i = 0; i < totalPages; i++) pages.push(i);
|
|
} else {
|
|
pages.push(0);
|
|
if (page > 2) pages.push("...");
|
|
const start = Math.max(1, page - 1);
|
|
const end = Math.min(totalPages - 2, page + 1);
|
|
for (let i = start; i <= end; i++) pages.push(i);
|
|
if (page < totalPages - 3) pages.push("...");
|
|
pages.push(totalPages - 1);
|
|
}
|
|
return pages;
|
|
});
|
|
</script>
|
|
|
|
<div class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between {className}">
|
|
<!-- Summary -->
|
|
<div class="min-w-0 text-sm text-text-muted">
|
|
{#if total > 0}
|
|
{(showingText || $t.dashboard?.showing || "Showing {start}-{end} of {total}")
|
|
.replace("{start}", String(start))
|
|
.replace("{end}", String(end))
|
|
.replace("{total}", String(total))}
|
|
{:else}
|
|
{$t.common?.no_data || "No items"}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
|
<!-- Page size selector -->
|
|
{#if pageSizeOptions.length > 0}
|
|
<select
|
|
class="text-sm border border-border rounded px-2 py-1 bg-surface-card text-text"
|
|
value={pageSize}
|
|
onchange={(e) => {
|
|
const val = Number(e.currentTarget.value);
|
|
if (onPageSizeChange) onPageSizeChange(val);
|
|
}}
|
|
>
|
|
{#each pageSizeOptions as opt (opt)}
|
|
<option value={opt}>{opt}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
|
|
<!-- Page number buttons (only when > 1 page) -->
|
|
{#if totalPages > 1}
|
|
<div class="flex max-w-full flex-wrap gap-1">
|
|
{#each pageNumbers as p, i (String(p) + "-" + i)}
|
|
{#if p === "..."}
|
|
<span class="px-2 py-1 text-sm text-text-muted">…</span>
|
|
{:else}
|
|
<button
|
|
class="px-2 py-1 text-sm rounded transition-colors
|
|
{p === page
|
|
? 'bg-primary text-white'
|
|
: 'text-text hover:bg-surface-muted'}"
|
|
onclick={() => goToPage(p)}
|
|
>
|
|
{p + 1}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Prev / Next -->
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={page === 0}
|
|
onclick={() => goToPage(page - 1)}
|
|
>
|
|
{$t.dashboard?.previous || "Prev"}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={page >= totalPages - 1}
|
|
onclick={() => goToPage(page + 1)}
|
|
>
|
|
{$t.dashboard?.next || "Next"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<!-- #endregion UI.Pagination -->
|