This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / components / ui / Pagination.svelte
5.5 kB 202 lines
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()}>&hellip;</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>