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