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
4.6 kB 184 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</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()}>&hellip;</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>