Files
shd.build/src/lib/components/RegistryPagination.svelte
T

93 lines
4.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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)}/>
<span class="pagination-search-state" class:pending={searchPending} aria-live="polite">{searchPending ? 'SEARCHING…' : 'LIVE SEARCH'}</span>
{#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>