<script module lang="ts">
	import { tv, type VariantProps } from "tailwind-variants";

	export const pagination = tv({
		slots: {
			nav: "inline-flex items-center gap-1",
			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-0",
			ellipsis: "flex h-9 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);

	// both ends, the current page and its two neighbours, and the two gaps
	const SLOTS = 7;

	// the row is always SLOTS long once a gap is needed, so both gaps are permanent
	// residents. that is what lets the plate be placed from an index rather than
	// measured, and why paging the middle of a long range moves the numbers, not the row
	export function paginate(page: number, count: number): PaginationItem[] {
		if (count <= SLOTS) return range(1, count);
		// near either end a gap would hide fewer pages than the slots it costs, so the
		// window opens flush against that end and only the far gap is drawn
		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 "./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()}>&hellip;</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>
