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.0 kB 196 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 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()}>&hellip;</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>