93 lines
4.7 KiB
Svelte
93 lines
4.7 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { navigating, page as currentPage } from '$app/state';
|
||
import { onDestroy } from 'svelte';
|
||
let {
|
||
pagination,
|
||
namespace = '',
|
||
search,
|
||
searchPlaceholder = 'SEARCH BY NAME'
|
||
}: {
|
||
pagination: { page: number; perPage: number; total: number; pageCount: number };
|
||
namespace?: string;
|
||
search?: string;
|
||
searchPlaceholder?: string;
|
||
} = $props();
|
||
const pageKey = $derived(namespace ? `${namespace}Page` : 'page');
|
||
const perPageKey = $derived(namespace ? `${namespace}PerPage` : 'perPage');
|
||
const searchKey = $derived(namespace ? `${namespace}Q` : 'q');
|
||
let draftSearch = $state<string | null>(null);
|
||
const searchValue = $derived(draftSearch ?? search ?? '');
|
||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||
const searchPending = $derived(Boolean(navigating.to) && navigating.to?.url.searchParams.get(searchKey) !== currentPage.url.searchParams.get(searchKey));
|
||
$effect(() => {
|
||
if (draftSearch !== null && draftSearch.trim() === (search ?? '').trim()) draftSearch = null;
|
||
});
|
||
const searchHref = (query: string) => {
|
||
const params = new URLSearchParams(currentPage.url.searchParams);
|
||
const normalized = query.trim();
|
||
if (normalized) params.set(searchKey, normalized);
|
||
else params.delete(searchKey);
|
||
params.delete(pageKey);
|
||
const serialized = params.toString();
|
||
return `${currentPage.url.pathname}${serialized ? `?${serialized}` : ''}`;
|
||
};
|
||
const submitSearch = (query = searchValue) => {
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
if (query.trim() === (search ?? '').trim()) return;
|
||
void goto(searchHref(query), { replaceState: true, keepFocus: true, noScroll: true });
|
||
};
|
||
const scheduleSearch = (query: string) => {
|
||
draftSearch = query;
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => submitSearch(query), 350);
|
||
};
|
||
onDestroy(() => {
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
});
|
||
const href = (targetPage: number, perPage = pagination.perPage) => {
|
||
const params = new URLSearchParams(currentPage.url.searchParams);
|
||
params.set(pageKey, String(targetPage));
|
||
params.set(perPageKey, String(perPage));
|
||
return `${currentPage.url.pathname}?${params}`;
|
||
};
|
||
const clearSearchHref = $derived.by(() => {
|
||
const params = new URLSearchParams(currentPage.url.searchParams);
|
||
params.delete(searchKey);
|
||
params.delete(pageKey);
|
||
const query = params.toString();
|
||
return `${currentPage.url.pathname}${query ? `?${query}` : ''}`;
|
||
});
|
||
const retainedParams = $derived([...currentPage.url.searchParams].filter(([key]) => key !== searchKey && key !== pageKey));
|
||
const visiblePages = $derived(
|
||
Array.from({ length: pagination.pageCount }, (_, index) => index + 1)
|
||
.filter((page) => page === 1 || page === pagination.pageCount || Math.abs(page - pagination.page) <= 2)
|
||
);
|
||
const start = $derived(pagination.total ? (pagination.page - 1) * pagination.perPage + 1 : 0);
|
||
const end = $derived(Math.min(pagination.total, pagination.page * pagination.perPage));
|
||
const hasMultiplePages = $derived(pagination.pageCount > 1);
|
||
</script>
|
||
|
||
<nav class="registry-pagination" aria-label="Registry pagination">
|
||
{#if search !== undefined}
|
||
<form class="pagination-search" role="search" onsubmit={(event) => { event.preventDefault(); submitSearch(); }}>
|
||
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
|
||
<input type="search" name={searchKey} value={searchValue} maxlength="80" placeholder={searchPlaceholder} aria-label={`${searchPlaceholder} — results update as you type`} oninput={(event) => scheduleSearch(event.currentTarget.value)}/>
|
||
{#if searchPending}<span class="pagination-search-state pending" aria-live="polite">SEARCHING…</span>{/if}
|
||
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
|
||
</form>
|
||
{/if}
|
||
<div class="pagination-range"><span>DISPLAYING</span><b>{start}–{end}</b><span>OF {pagination.total}</span></div>
|
||
{#if hasMultiplePages}
|
||
<div class="pagination-pages">
|
||
<a class:disabled={pagination.page === 1} aria-disabled={pagination.page === 1} href={href(Math.max(1, pagination.page - 1))}>← PREV</a>
|
||
{#each visiblePages as page, index}
|
||
{#if index > 0 && page - visiblePages[index - 1] > 1}<span class="pagination-gap">···</span>{/if}
|
||
<a class:active={page === pagination.page} aria-current={page === pagination.page ? 'page' : undefined} href={href(page)}>{page}</a>
|
||
{/each}
|
||
<a class:disabled={pagination.page === pagination.pageCount} aria-disabled={pagination.page === pagination.pageCount} href={href(Math.min(pagination.pageCount, pagination.page + 1))}>NEXT →</a>
|
||
</div>
|
||
<div class="pagination-sizes"><span>ROWS</span>{#each [10, 25, 50, 100] as size}<a class:active={size === pagination.perPage} href={href(1, size)}>{size}</a>{/each}</div>
|
||
{/if}
|
||
</nav>
|