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 base: "flex items-center gap-1"
6 });
7
8 export const paginationNav = tv({
9 base: "flex size-8 items-center justify-center rounded text-foreground-muted transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-background-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus disabled:pointer-events-none disabled:opacity-50"
10 });
11
12 export const paginationItem = tv({
13 base: "flex size-8 items-center justify-center rounded border text-sm transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus",
14 variants: {
15 active: {
16 true: "border-border-strong bg-background-inset font-medium text-foreground-default",
17 false: "border-border-default text-foreground-muted hover:bg-background-subtle"
18 }
19 },
20 defaultVariants: {
21 active: false
22 }
23 });
24
25 export type PaginationItemVariants = VariantProps<typeof paginationItem>;
26
27 type Item = number | "start-ellipsis" | "end-ellipsis";
28
29 function range(start: number, end: number): number[] {
30 return Array.from({ length: Math.max(end - start + 1, 0) }, (_, i) => start + i);
31 }
32
33 // Windowing per MUI's usePagination: always show the boundary pages
34 // (first/last) plus a sibling window around the current page, collapsing
35 // any gap into a single ellipsis.
36 function getPageItems(current: number, count: number): Item[] {
37 const siblingCount = 1;
38 const boundaryCount = 1;
39
40 const startPages = range(1, Math.min(boundaryCount, count));
41 const endPages = range(Math.max(count - boundaryCount + 1, boundaryCount + 1), count);
42
43 const siblingsStart = Math.max(
44 Math.min(current - siblingCount, count - boundaryCount - siblingCount * 2 - 1),
45 boundaryCount + 2
46 );
47 const siblingsEnd = Math.min(
48 Math.max(current + siblingCount, boundaryCount + siblingCount * 2 + 2),
49 endPages.length > 0 ? endPages[0] - 2 : count - 1
50 );
51
52 return [
53 ...startPages,
54 ...(siblingsStart > boundaryCount + 2
55 ? (["start-ellipsis"] as const)
56 : boundaryCount + 1 < count - boundaryCount
57 ? [boundaryCount + 1]
58 : []),
59 ...range(siblingsStart, siblingsEnd),
60 ...(siblingsEnd < count - boundaryCount - 1
61 ? (["end-ellipsis"] as const)
62 : count - boundaryCount > boundaryCount
63 ? [count - boundaryCount]
64 : []),
65 ...endPages
66 ];
67 }
68</script>
69
70<script lang="ts">
71 import ChevronLeft from "$icon/chevron-left";
72 import ChevronRight from "$icon/chevron-right";
73
74 interface Props {
75 page: number;
76 total: number;
77 onchange?: (page: number) => void;
78 class?: string;
79 }
80
81 let { page = $bindable(1), total, onchange, class: className }: Props = $props();
82
83 const count = $derived(Math.max(total, 1));
84 const items = $derived(getPageItems(Math.min(Math.max(page, 1), count), count));
85
86 function goTo(target: number) {
87 const next = Math.min(Math.max(target, 1), count);
88 if (next === page) return;
89 page = next;
90 onchange?.(next);
91 }
92</script>
93
94<nav aria-label="Pagination" class={pagination({ class: className })}>
95 <button
96 type="button"
97 class={paginationNav()}
98 disabled={page <= 1}
99 aria-label="Previous page"
100 onclick={() => goTo(page - 1)}
101 >
102 <ChevronLeft class="size-4" aria-hidden="true" />
103 </button>
104
105 {#each items as item, i (typeof item === "number" ? item : `${item}-${i}`)}
106 {#if typeof item === "number"}
107 <button
108 type="button"
109 class={paginationItem({ active: item === page })}
110 aria-current={item === page ? "page" : undefined}
111 aria-label={`Page ${item}`}
112 onclick={() => goTo(item)}
113 >
114 {item}
115 </button>
116 {:else}
117 <span
118 class="flex size-8 items-center justify-center text-foreground-subtle"
119 aria-hidden="true"
120 >
121 …
122 </span>
123 {/if}
124 {/each}
125
126 <button
127 type="button"
128 class={paginationNav()}
129 disabled={page >= count}
130 aria-label="Next page"
131 onclick={() => goTo(page + 1)}
132 >
133 <ChevronRight class="size-4" aria-hidden="true" />
134 </button>
135</nav>