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 const SLOTS = 7;
35
36 export function paginate(page: number, count: number): PaginationItem[] {
37 if (count <= SLOTS) return range(1, count);
38 if (page < 4) return [...range(1, 5), "gap-end", count];
39 if (page > count - 3) return [1, "gap-start", ...range(count - 4, count)];
40 return [1, "gap-start", ...range(page - 1, page + 1), "gap-end", count];
41 }
42</script>
43
44<script lang="ts">
45 import ChevronLeft from "$icon/chevron-left";
46 import ChevronRight from "$icon/chevron-right";
47 import { untrack } from "svelte";
48 import { Spring, prefersReducedMotion } from "svelte/motion";
49 import Button from "./Button.svelte";
50
51 interface Props {
52 page: number;
53 total: number;
54 onchange?: (page: number) => void;
55 class?: string;
56 labels?: boolean;
57 disabled?: boolean;
58 }
59
60 let {
61 page = $bindable(1),
62 total,
63 onchange,
64 class: className,
65 labels,
66 disabled = false
67 }: Props = $props();
68
69 const count = $derived(Math.max(total, 1));
70 const current = $derived(Math.min(Math.max(page, 1), count));
71 const items = $derived(paginate(current, count));
72 const index = $derived(items.indexOf(current));
73
74 const digits = $derived(String(count).length);
75
76 const plate = new Spring(
77 untrack(() => index),
78 { stiffness: 0.139, damping: 0.61 }
79 );
80
81 let direction = $state(1);
82 let rolled = $state(false);
83 let previous = untrack(() => current);
84 $effect.pre(() => {
85 if (current !== previous) {
86 direction = current > previous ? 1 : -1;
87 previous = current;
88 rolled = true;
89 }
90 plate.set(index, { instant: prefersReducedMotion.current });
91 });
92
93 let spoken = $state("");
94 $effect(() => {
95 const message = `Page ${current} of ${count}`;
96 const timer = setTimeout(() => (spoken = message), 500);
97 return () => clearTimeout(timer);
98 });
99
100 function goTo(target: number) {
101 if (disabled) return;
102 const next = Math.min(Math.max(target, 1), count);
103 if (next === current) return;
104 page = next;
105 onchange?.(next);
106 }
107
108 const spent = (can: boolean) => (disabled || !can ? "true" : undefined);
109
110 const style = $derived(pagination());
111</script>
112
113<nav
114 aria-label="Pagination"
115 aria-busy={disabled || undefined}
116 class={style.nav({ class: className })}
117>
118 <Button
119 variant="ghost"
120 icon={ChevronLeft}
121 aria-disabled={spent(current > 1)}
122 aria-label="Previous page"
123 onclick={() => goTo(current - 1)}
124 >
125 {#if labels}
126 <span class={style.label()}>Previous</span>
127 {/if}
128 </Button>
129
130 <div class={style.track()} style="--slot: max(2.25rem, calc({digits} * 1ch + 0.75rem))">
131 <span
132 aria-hidden="true"
133 class={style.plate()}
134 style="translate: calc({plate.current} * (var(--slot) + var(--gap)))"
135 ></span>
136 <ol class={style.list()}>
137 {#each items as item, slot (slot)}
138 <li class={style.slot()}>
139 {#if typeof item === "number"}
140 {@const isSelected = item === current}
141 <Button
142 variant="ghost"
143 class={style.page({ selected: isSelected })}
144 aria-current={isSelected ? "page" : undefined}
145 aria-disabled={disabled || undefined}
146 aria-label="Page {item}"
147 onclick={() => goTo(item)}
148 >
149 {#key item}
150 <span class:roll={untrack(() => rolled)} style="--roll: {untrack(() => direction)}"
151 >{item}</span
152 >
153 {/key}
154 </Button>
155 {:else}
156 <span aria-hidden="true" class={style.ellipsis()}>…</span>
157 {/if}
158 </li>
159 {/each}
160 </ol>
161 </div>
162
163 <Button
164 variant="ghost"
165 icon={ChevronRight}
166 iconSide="right"
167 aria-disabled={spent(current < count)}
168 aria-label="Next page"
169 onclick={() => goTo(current + 1)}
170 >
171 {#if labels}
172 <span class={style.label()}>Next</span>
173 {/if}
174 </Button>
175
176 <span role="status" class="sr-only">{spoken}</span>
177</nav>
178
179<style>
180 @keyframes roll {
181 from {
182 opacity: 0;
183 translate: calc(var(--roll) * 0.5rem);
184 }
185 }
186
187 .roll {
188 animation: roll 180ms cubic-bezier(0.23, 1, 0.32, 1);
189 }
190
191 @media (prefers-reduced-motion: reduce) {
192 .roll {
193 animation: none;
194 }
195 }
196</style>