This repository has no description
1<script module lang="ts">
2 import { tv, type VariantProps } from "tailwind-variants";
3
4 export const pagination = tv({
5 slots: {
6 nav: "inline-flex items-center gap-1",
7 track: "relative typography-paragraph-regular tabular-nums [--gap:0.25rem]",
8 plate:
9 "pointer-events-none absolute inset-y-0 left-0 w-(--slot) rounded-sm border border-border-default bg-background-default",
10 list: "relative flex gap-(--gap)",
11 slot: "w-(--slot)",
12 page: "w-full px-0",
13 ellipsis: "flex h-9 items-center justify-center text-foreground-subtle select-none",
14 label: "hidden sm:inline"
15 },
16 variants: {
17 selected: {
18 true: { page: "font-medium text-foreground-default hover:bg-transparent" },
19 false: { page: "text-foreground-muted hover:text-foreground-default" }
20 }
21 },
22 defaultVariants: {
23 selected: false
24 }
25 });
26
27 export type PaginationVariants = VariantProps<typeof pagination>;
28
29 export type PaginationItem = number | "gap-start" | "gap-end";
30
31 const range = (from: number, to: number): number[] =>
32 Array.from({ length: Math.max(to - from + 1, 0) }, (_, i) => from + i);
33
34 // both ends, the current page and its two neighbours, and the two gaps
35 const SLOTS = 7;
36
37 // the row is always SLOTS long once a gap is needed, so both gaps are permanent
38 // residents. that is what lets the plate be placed from an index rather than
39 // measured, and why paging the middle of a long range moves the numbers, not the row
40 export function paginate(page: number, count: number): PaginationItem[] {
41 if (count <= SLOTS) return range(1, count);
42 // near either end a gap would hide fewer pages than the slots it costs, so the
43 // window opens flush against that end and only the far gap is drawn
44 if (page < 4) return [...range(1, 5), "gap-end", count];
45 if (page > count - 3) return [1, "gap-start", ...range(count - 4, count)];
46 return [1, "gap-start", ...range(page - 1, page + 1), "gap-end", count];
47 }
48</script>
49
50<script lang="ts">
51 import ChevronLeft from "$icon/chevron-left";
52 import ChevronRight from "$icon/chevron-right";
53 import { untrack } from "svelte";
54 import { Spring, prefersReducedMotion } from "svelte/motion";
55 import Button from "./Button.svelte";
56
57 interface Props {
58 page: number;
59 total: number;
60 onchange?: (page: number) => void;
61 class?: string;
62 labels?: boolean;
63 disabled?: boolean;
64 }
65
66 let {
67 page = $bindable(1),
68 total,
69 onchange,
70 class: className,
71 labels,
72 disabled = false
73 }: Props = $props();
74
75 const count = $derived(Math.max(total, 1));
76 const current = $derived(Math.min(Math.max(page, 1), count));
77 const items = $derived(paginate(current, count));
78 const index = $derived(items.indexOf(current));
79
80 const digits = $derived(String(count).length);
81
82 const plate = new Spring(
83 untrack(() => index),
84 { stiffness: 0.139, damping: 0.61 }
85 );
86
87 let direction = $state(1);
88 let rolled = $state(false);
89 let previous = untrack(() => current);
90 $effect.pre(() => {
91 if (current !== previous) {
92 direction = current > previous ? 1 : -1;
93 previous = current;
94 rolled = true;
95 }
96 plate.set(index, { instant: prefersReducedMotion.current });
97 });
98
99 let spoken = $state("");
100 $effect(() => {
101 const message = `Page ${current} of ${count}`;
102 const timer = setTimeout(() => (spoken = message), 500);
103 return () => clearTimeout(timer);
104 });
105
106 function goTo(target: number) {
107 if (disabled) return;
108 const next = Math.min(Math.max(target, 1), count);
109 if (next === current) return;
110 page = next;
111 onchange?.(next);
112 }
113
114 const spent = (can: boolean) => (disabled || !can ? "true" : undefined);
115
116 const style = $derived(pagination());
117</script>
118
119<nav
120 aria-label="Pagination"
121 aria-busy={disabled || undefined}
122 class={style.nav({ class: className })}
123>
124 <Button
125 variant="ghost"
126 icon={ChevronLeft}
127 aria-disabled={spent(current > 1)}
128 aria-label="Previous page"
129 onclick={() => goTo(current - 1)}
130 >
131 {#if labels}
132 <span class={style.label()}>Previous</span>
133 {/if}
134 </Button>
135
136 <div class={style.track()} style="--slot: max(2.25rem, calc({digits} * 1ch + 0.75rem))">
137 <span
138 aria-hidden="true"
139 class={style.plate()}
140 style="translate: calc({plate.current} * (var(--slot) + var(--gap)))"
141 ></span>
142 <ol class={style.list()}>
143 {#each items as item, slot (slot)}
144 <li class={style.slot()}>
145 {#if typeof item === "number"}
146 {@const isSelected = item === current}
147 <Button
148 variant="ghost"
149 class={style.page({ selected: isSelected })}
150 aria-current={isSelected ? "page" : undefined}
151 aria-disabled={disabled || undefined}
152 aria-label="Page {item}"
153 onclick={() => goTo(item)}
154 >
155 {#key item}
156 <span class:roll={untrack(() => rolled)} style="--roll: {untrack(() => direction)}"
157 >{item}</span
158 >
159 {/key}
160 </Button>
161 {:else}
162 <span aria-hidden="true" class={style.ellipsis()}>…</span>
163 {/if}
164 </li>
165 {/each}
166 </ol>
167 </div>
168
169 <Button
170 variant="ghost"
171 icon={ChevronRight}
172 iconSide="right"
173 aria-disabled={spent(current < count)}
174 aria-label="Next page"
175 onclick={() => goTo(current + 1)}
176 >
177 {#if labels}
178 <span class={style.label()}>Next</span>
179 {/if}
180 </Button>
181
182 <span role="status" class="sr-only">{spoken}</span>
183</nav>
184
185<style>
186 @keyframes roll {
187 from {
188 opacity: 0;
189 translate: calc(var(--roll) * 0.5rem);
190 }
191 }
192
193 .roll {
194 animation: roll 180ms cubic-bezier(0.23, 1, 0.32, 1);
195 }
196
197 @media (prefers-reduced-motion: reduce) {
198 .roll {
199 animation: none;
200 }
201 }
202</style>