mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 02:23:34 -06:00
feat: add page selector dropdown with animated pagination controls
Replace Previous/Next buttons with 5-slot page navigation centered in pagination bar. Current page becomes a dropdown trigger allowing direct page jumps. Side slots animate on page transitions.
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Select } from "bits-ui";
|
||||||
|
import { ChevronUp, ChevronDown } from "@lucide/svelte";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
totalCount,
|
totalCount,
|
||||||
offset,
|
offset,
|
||||||
@@ -11,32 +15,148 @@ let {
|
|||||||
onPageChange: (newOffset: number) => void;
|
onPageChange: (newOffset: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const currentPage = $derived(Math.floor(offset / limit) + 1);
|
||||||
|
const totalPages = $derived(Math.ceil(totalCount / limit));
|
||||||
const start = $derived(offset + 1);
|
const start = $derived(offset + 1);
|
||||||
const end = $derived(Math.min(offset + limit, totalCount));
|
const end = $derived(Math.min(offset + limit, totalCount));
|
||||||
const hasPrev = $derived(offset > 0);
|
|
||||||
const hasNext = $derived(offset + limit < totalCount);
|
// Track direction for slide animation
|
||||||
|
let prevPage = $state(1);
|
||||||
|
let direction = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const page = currentPage;
|
||||||
|
if (page !== prevPage) {
|
||||||
|
direction = page > prevPage ? 1 : -1;
|
||||||
|
prevPage = page;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5 page slots: current-2, current-1, current, current+1, current+2
|
||||||
|
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
|
||||||
|
|
||||||
|
function isSlotVisible(page: number): boolean {
|
||||||
|
return page >= 1 && page <= totalPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
onPageChange((page - 1) * limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build items array for the Select dropdown
|
||||||
|
const pageItems = $derived(
|
||||||
|
Array.from({ length: totalPages }, (_, i) => ({
|
||||||
|
value: String(i + 1),
|
||||||
|
label: String(i + 1),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectValue = $derived(String(currentPage));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totalCount > 0}
|
{#if totalCount > 0 && totalPages > 1}
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center text-sm">
|
||||||
<span class="text-muted-foreground">
|
<!-- Left zone: result count -->
|
||||||
Showing {start}–{end} of {totalCount} courses
|
<div class="flex-1">
|
||||||
</span>
|
<span class="text-muted-foreground">
|
||||||
<div class="flex gap-2">
|
Showing {start}–{end} of {totalCount} courses
|
||||||
<button
|
</span>
|
||||||
disabled={!hasPrev}
|
|
||||||
onclick={() => onPageChange(offset - limit)}
|
|
||||||
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={!hasNext}
|
|
||||||
onclick={() => onPageChange(offset + limit)}
|
|
||||||
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Center zone: page buttons -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#key currentPage}
|
||||||
|
{#each pageSlots as page, i (i)}
|
||||||
|
{#if i === 2}
|
||||||
|
<!-- Center slot: current page with dropdown trigger -->
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={selectValue}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) goToPage(Number(v));
|
||||||
|
}}
|
||||||
|
items={pageItems}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
|
||||||
|
rounded-md text-sm font-medium tabular-nums
|
||||||
|
border border-border bg-card text-foreground
|
||||||
|
hover:bg-muted/50 active:bg-muted transition-colors
|
||||||
|
cursor-pointer select-none outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
aria-label="Page {currentPage} of {totalPages}, click to select page"
|
||||||
|
>
|
||||||
|
<span in:fly={{ x: direction * 20, duration: 200 }}>{currentPage}</span>
|
||||||
|
<ChevronUp class="size-3 text-muted-foreground" />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content
|
||||||
|
class="border border-border bg-card shadow-md outline-hidden z-50
|
||||||
|
max-h-72 min-w-16 w-auto
|
||||||
|
select-none rounded-md p-1
|
||||||
|
data-[state=open]:animate-in data-[state=closed]:animate-out
|
||||||
|
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
||||||
|
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
||||||
|
data-[side=top]:slide-in-from-bottom-2
|
||||||
|
data-[side=bottom]:slide-in-from-top-2"
|
||||||
|
side="top"
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
<Select.ScrollUpButton class="flex w-full items-center justify-center py-0.5">
|
||||||
|
<ChevronUp class="size-3.5 text-muted-foreground" />
|
||||||
|
</Select.ScrollUpButton>
|
||||||
|
<Select.Viewport class="p-0.5">
|
||||||
|
{#each pageItems as item (item.value)}
|
||||||
|
<Select.Item
|
||||||
|
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
|
||||||
|
justify-center px-3 text-sm tabular-nums
|
||||||
|
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
|
||||||
|
data-[selected]:font-semibold"
|
||||||
|
value={item.value}
|
||||||
|
label={item.label}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Viewport>
|
||||||
|
<Select.ScrollDownButton class="flex w-full items-center justify-center py-0.5">
|
||||||
|
<ChevronDown class="size-3.5 text-muted-foreground" />
|
||||||
|
</Select.ScrollDownButton>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select.Root>
|
||||||
|
{:else}
|
||||||
|
<!-- Side slot: navigable page button or invisible placeholder -->
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center w-9 h-9
|
||||||
|
rounded-md text-sm tabular-nums
|
||||||
|
text-muted-foreground
|
||||||
|
hover:bg-muted/50 hover:text-foreground active:bg-muted transition-colors
|
||||||
|
cursor-pointer select-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background
|
||||||
|
{isSlotVisible(page) ? '' : 'invisible pointer-events-none'}"
|
||||||
|
onclick={() => goToPage(page)}
|
||||||
|
aria-label="Go to page {page}"
|
||||||
|
aria-hidden={!isSlotVisible(page)}
|
||||||
|
tabindex={isSlotVisible(page) ? 0 : -1}
|
||||||
|
disabled={!isSlotVisible(page)}
|
||||||
|
in:fly={{ x: direction * 20, duration: 200 }}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right zone: spacer for centering -->
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
</div>
|
||||||
|
{:else if totalCount > 0}
|
||||||
|
<!-- Single page: just show the count, no pagination controls -->
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
Showing {start}–{end} of {totalCount} courses
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user