<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const pagination = tv({
slots: {
nav: "inline-flex max-w-full items-center gap-2xs",
track: "relative typography-paragraph-regular tabular-nums [--gap:0.25rem]",
plate: "pointer-events-none absolute inset-y-0 left-0 w-(--slot) rounded-md bg-background-default edge-ring-inset",
list: "relative flex gap-(--gap)",
slot: "w-(--slot)",
page: "w-full px-none",
ellipsis: "flex h-8 items-center justify-center text-foreground-subtle select-none",
label: "hidden sm:inline"
},
variants: {
selected: {
true: { page: "font-medium text-foreground-default hover:bg-transparent" },
false: { page: "text-foreground-muted hover:text-foreground-default" }
},
labels: {
true: { nav: "gap-lg" },
false: {}
}
},
defaultVariants: {
selected: false,
labels: false
}
});
export type PaginationVariants = VariantProps<typeof pagination>;
export type PaginationItem = number | "gap-start" | "gap-end";
const range = (from: number, to: number): number[] =>
Array.from({ length: Math.max(to - from + 1, 0) }, (_, i) => from + i);
export function paginate(page: number, count: number, slots = 7): PaginationItem[] {
if (count <= slots) return range(1, count);
const reach = (slots - 5) / 2;
if (page <= reach + 2) return [...range(1, slots - 2), "gap-end", count];
if (page >= count - reach - 1)
return [1, "gap-start", ...range(count - (slots - 3), count)];
return [1, "gap-start", ...range(page - reach, page + reach), "gap-end", count];
}
const NARROW = "(width < 30rem)";
</script>
<script lang="ts">
import ChevronLeft from "$icon/chevron-left";
import ChevronRight from "$icon/chevron-right";
import { untrack } from "svelte";
import { Spring, prefersReducedMotion } from "svelte/motion";
import Button from "#lib/components/ui/Button.svelte";
interface Props {
page: number;
total: number;
onchange?: (page: number) => void;
class?: string;
labels?: boolean;
disabled?: boolean;
}
let {
page = $bindable(1),
total,
onchange,
class: className,
labels,
disabled = false
}: Props = $props();
const count = $derived(Math.max(total, 1));
const current = $derived(Math.min(Math.max(page, 1), count));
let narrow = $state(false);
$effect(() => {
const query = matchMedia(NARROW);
narrow = query.matches;
const update = () => (narrow = query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
});
const items = $derived(paginate(current, count, narrow ? 5 : 7));
const index = $derived(items.indexOf(current));
const digits = $derived(String(count).length);
const plate = new Spring(
untrack(() => index),
{ stiffness: 0.139, damping: 0.61 }
);
let direction = $state(1);
let rolled = $state(false);
let previous = untrack(() => current);
$effect.pre(() => {
if (current !== previous) {
direction = current > previous ? 1 : -1;
previous = current;
rolled = true;
}
plate.set(index, { instant: prefersReducedMotion.current });
});
let spoken = $state("");
$effect(() => {
const message = `Page ${current} of ${count}`;
const timer = setTimeout(() => (spoken = message), 500);
return () => clearTimeout(timer);
});
function goTo(target: number) {
if (disabled) return;
const next = Math.min(Math.max(target, 1), count);
if (next === current) return;
page = next;
onchange?.(next);
}
const spent = (can: boolean) => (disabled || !can ? "true" : undefined);
const style = $derived(pagination({ labels }));
</script>
<nav
aria-label="Pagination"
aria-busy={disabled || undefined}
class={style.nav({ class: className })}
>
<Button
variant="ghost"
square={!labels}
icon={ChevronLeft}
aria-disabled={spent(current > 1)}
data-testid="previousPage"
aria-label="Previous page"
onclick={() => goTo(current - 1)}
>
{#if labels}
<span class={style.label()}>Previous</span>
{/if}
</Button>
<div class={style.track()} style="--slot: max(2.25rem, calc({digits} * 1ch + 0.75rem))">
<span
aria-hidden="true"
class={style.plate()}
style="translate: calc({plate.current} * (var(--slot) + var(--gap)))"
></span>
<ol class={style.list()}>
{#each items as item, slot (slot)}
<li class={style.slot()}>
{#if typeof item === "number"}
{@const isSelected = item === current}
<Button
data-testid="page"
data-page={item}
variant="ghost"
class={style.page({ selected: isSelected })}
aria-current={isSelected ? "page" : undefined}
aria-disabled={disabled || undefined}
aria-label="Page {item}"
onclick={() => goTo(item)}
>
{#key item}
<span
class:roll={untrack(() => rolled)}
style="--roll: {untrack(() => direction)}">{item}</span
>
{/key}
</Button>
{:else}
<span aria-hidden="true" class={style.ellipsis()}>…</span>
{/if}
</li>
{/each}
</ol>
</div>
<Button
variant="ghost"
square={!labels}
icon={ChevronRight}
iconSide="right"
aria-disabled={spent(current < count)}
data-testid="nextPage"
aria-label="Next page"
onclick={() => goTo(current + 1)}
>
{#if labels}
<span class={style.label()}>Next</span>
{/if}
</Button>
<span role="status" class="sr-only">{spoken}</span>
</nav>
<style>
@keyframes roll {
from {
opacity: 0;
translate: calc(var(--roll) * 0.5rem);
}
}
.roll {
animation: roll 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
@media (prefers-reduced-motion: reduce) {
.roll {
animation: none;
}
}
</style>