Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197<script module lang="ts"> import { tv, type VariantProps } from "tailwind-variants";
export const pagination = tv({ slots: { nav: "inline-flex 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-sm border border-border-default bg-background-default", 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" } } }, defaultVariants: { selected: 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);
const SLOTS = 7;
export function paginate(page: number, count: number): PaginationItem[] { if (count <= SLOTS) return range(1, count); if (page < 4) return [...range(1, 5), "gap-end", count]; if (page > count - 3) return [1, "gap-start", ...range(count - 4, count)]; return [1, "gap-start", ...range(page - 1, page + 1), "gap-end", count]; }</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)); const items = $derived(paginate(current, count)); 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());</script>
<nav aria-label="Pagination" aria-busy={disabled || undefined} class={style.nav({ class: className })}> <Button variant="ghost" icon={ChevronLeft} aria-disabled={spent(current > 1)} 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 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" icon={ChevronRight} iconSide="right" aria-disabled={spent(current < count)} 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>