This repository has no description
0

Configure Feed

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

web/components: add animations to Pagination

Signed-off-by: eti <eti@eti.tf>

+132 -72
+8
web/src/lib/components/ui/Pagination.stories.svelte
··· 9 9 argTypes: { 10 10 page: { control: { type: "number" } }, 11 11 total: { control: { type: "number" } }, 12 + siblings: { control: { type: "number" } }, 13 + boundaries: { control: { type: "number" } }, 12 14 labels: { control: { type: "boolean" } }, 13 15 disabled: { control: { type: "boolean" } } 14 16 }, ··· 27 29 <Story name="ManyPages" args={{ page: 12, total: 40 }} /> 28 30 <Story name="WithLabels" args={{ page: 5, total: 10, labels: true }} /> 29 31 <Story name="Disabled" args={{ page: 5, total: 10, disabled: true }} /> 32 + 33 + <!-- slots are sized from `total`, so a four-digit range keeps one width across the 34 + whole row instead of widening as the current page gains a digit --> 35 + <Story name="FourDigitRange" args={{ page: 1234, total: 5000 }} /> 36 + <Story name="WideWindow" args={{ page: 20, total: 40, siblings: 2, boundaries: 2 }} /> 37 + <Story name="NarrowWindow" args={{ page: 20, total: 40, siblings: 0 }} />
+124 -72
web/src/lib/components/ui/Pagination.svelte
··· 1 1 <script module lang="ts"> 2 - import { tv } from "tailwind-variants"; 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 3 4 4 export const pagination = tv({ 5 - base: "flex items-center gap-1" 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 + } 6 25 }); 7 26 8 - type Item = number | "start-ellipsis" | "end-ellipsis"; 9 - 10 - function range(start: number, end: number): number[] { 11 - return Array.from({ length: Math.max(end - start + 1, 0) }, (_, i) => start + i); 12 - } 13 - 14 - // Windowing per MUI's usePagination: always show the boundary pages 15 - // (first/last) plus a sibling window around the current page, collapsing 16 - // any gap into a single ellipsis. 17 - function getPageItems(current: number, count: number): Item[] { 18 - const siblingCount = 1; 19 - const boundaryCount = 1; 20 - 21 - const startPages = range(1, Math.min(boundaryCount, count)); 22 - const endPages = range(Math.max(count - boundaryCount + 1, boundaryCount + 1), count); 23 - 24 - const siblingsStart = Math.max( 25 - Math.min(current - siblingCount, count - boundaryCount - siblingCount * 2 - 1), 26 - boundaryCount + 2 27 - ); 28 - const siblingsEnd = Math.min( 29 - Math.max(current + siblingCount, boundaryCount + siblingCount * 2 + 2), 30 - endPages.length > 0 ? endPages[0] - 2 : count - 1 31 - ); 32 - 33 - return [ 34 - ...startPages, 35 - ...(siblingsStart > boundaryCount + 2 36 - ? (["start-ellipsis"] as const) 37 - : boundaryCount + 1 < count - boundaryCount 38 - ? [boundaryCount + 1] 39 - : []), 40 - ...range(siblingsStart, siblingsEnd), 41 - ...(siblingsEnd < count - boundaryCount - 1 42 - ? (["end-ellipsis"] as const) 43 - : count - boundaryCount > boundaryCount 44 - ? [count - boundaryCount] 45 - : []), 46 - ...endPages 47 - ]; 48 - } 27 + export type PaginationVariants = VariantProps<typeof pagination>; 49 28 </script> 50 29 51 30 <script lang="ts"> 52 31 import ChevronLeft from "$icon/chevron-left"; 53 32 import ChevronRight from "$icon/chevron-right"; 33 + import { untrack } from "svelte"; 34 + import { Spring, prefersReducedMotion } from "svelte/motion"; 54 35 import Button from "./Button.svelte"; 36 + import { paginate } from "./paginate"; 55 37 56 38 interface Props { 57 39 page: number; 58 40 total: number; 41 + siblings?: number; 42 + boundaries?: number; 59 43 onchange?: (page: number) => void; 60 44 class?: string; 61 45 labels?: boolean; ··· 65 49 let { 66 50 page = $bindable(1), 67 51 total, 52 + siblings = 1, 53 + boundaries = 1, 68 54 onchange, 69 55 class: className, 70 56 labels, ··· 72 58 }: Props = $props(); 73 59 74 60 const count = $derived(Math.max(total, 1)); 75 - const items = $derived(getPageItems(Math.min(Math.max(page, 1), count), count)); 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 + }); 76 87 77 88 function goTo(target: number) { 78 89 if (disabled) return; 79 90 const next = Math.min(Math.max(target, 1), count); 80 - if (next === page) return; 91 + if (next === current) return; 81 92 page = next; 82 93 onchange?.(next); 83 94 } 95 + 96 + const spent = (can: boolean) => (disabled || !can ? "true" : undefined); 97 + 98 + const style = $derived(pagination()); 84 99 </script> 85 100 86 101 <nav 87 102 aria-label="Pagination" 88 103 aria-busy={disabled || undefined} 89 - class={pagination({ class: className })} 104 + class={style.nav({ class: className })} 90 105 > 91 106 <Button 92 107 variant="ghost" 93 108 icon={ChevronLeft} 94 - disabled={disabled || page <= 1} 109 + aria-disabled={spent(current > 1)} 95 110 aria-label="Previous page" 96 - onclick={() => goTo(page - 1)} 111 + onclick={() => goTo(current - 1)} 97 112 > 98 - <span class="hidden sm:inline">{labels ? "Previous" : ""}</span> 113 + {#if labels} 114 + <span class={style.label()}>Previous</span> 115 + {/if} 99 116 </Button> 100 117 101 - {#each items as item, i (typeof item === "number" ? item : `${item}-${i}`)} 102 - {#if typeof item === "number"} 103 - <Button 104 - variant={item === page ? "default" : "ghost"} 105 - {disabled} 106 - aria-current={item === page ? "page" : undefined} 107 - aria-label={`Page ${item}`} 108 - onclick={() => goTo(item)} 109 - > 110 - {item} 111 - </Button> 112 - {:else} 113 - <span 114 - class="pointer-events-none flex size-8 items-center justify-center text-foreground-subtle select-none" 115 - aria-hidden="true" 116 - > 117 - &hellip; 118 - </span> 119 - {/if} 120 - {/each} 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> 121 150 122 151 <Button 123 152 variant="ghost" 124 153 icon={ChevronRight} 125 154 iconSide="right" 126 - disabled={disabled || page >= count} 155 + aria-disabled={spent(current < count)} 127 156 aria-label="Next page" 128 - onclick={() => goTo(page + 1)} 157 + onclick={() => goTo(current + 1)} 129 158 > 130 - <span class="hidden sm:inline">{labels ? "Next" : ""}</span> 159 + {#if labels} 160 + <span class={style.label()}>Next</span> 161 + {/if} 131 162 </Button> 163 + 164 + <span role="status" class="sr-only">{spoken}</span> 132 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>