208 lines
5.6 KiB
Svelte
208 lines
5.6 KiB
Svelte
<script lang="ts" generics="T">
|
|
import type { Snippet } from 'svelte';
|
|
|
|
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Label } from '$lib/components/ui/label';
|
|
import * as Popover from '$lib/components/ui/popover';
|
|
import * as Table from '$lib/components/ui/table';
|
|
import { PersistedState } from 'runed';
|
|
|
|
interface I18n {
|
|
display: string;
|
|
filterTitle: string;
|
|
next: () => string;
|
|
pageOf: (p: { page: number; pages: number; total: number }) => string;
|
|
previous: () => string;
|
|
rowsPerPage: string;
|
|
}
|
|
|
|
let {
|
|
actions,
|
|
activeFilterCount = $bindable(0),
|
|
columns,
|
|
defaultPageSize = $bindable(25),
|
|
description,
|
|
emptyMessage,
|
|
filterContent,
|
|
i18n,
|
|
items,
|
|
loading,
|
|
onrefresh,
|
|
onsearchinput,
|
|
page = $bindable(1),
|
|
pageSizeKey,
|
|
pageSizePresets = $bindable([10, 25, 50, 100, 200]),
|
|
row,
|
|
search = $bindable(''),
|
|
searchHint,
|
|
searchPlaceholder,
|
|
title
|
|
}: {
|
|
actions?: Snippet;
|
|
activeFilterCount?: number;
|
|
columns: Snippet;
|
|
defaultPageSize?: number;
|
|
description: string;
|
|
emptyMessage: string;
|
|
filterContent?: Snippet;
|
|
i18n: I18n;
|
|
items: T[];
|
|
loading: boolean;
|
|
onrefresh?: () => void;
|
|
onsearchinput?: (e: Event) => void;
|
|
page?: number;
|
|
pageSizeKey: string;
|
|
pageSizePresets?: number[];
|
|
row: Snippet<[T, number]>;
|
|
search?: string;
|
|
searchHint?: string;
|
|
searchPlaceholder: string;
|
|
title: string;
|
|
} = $props();
|
|
|
|
const id = $props.id();
|
|
|
|
// svelte-ignore state_referenced_locally
|
|
const pageSize = new PersistedState<number>(pageSizeKey, defaultPageSize);
|
|
|
|
const totalPages = $derived(Math.max(1, Math.ceil(items.length / pageSize.current)));
|
|
const paginated = $derived(items.slice((page - 1) * pageSize.current, page * pageSize.current));
|
|
|
|
$effect(() => {
|
|
if (page > totalPages) page = totalPages;
|
|
});
|
|
</script>
|
|
|
|
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
|
<div
|
|
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
|
>
|
|
<div class="flex flex-col gap-0.5 min-w-0">
|
|
<h1 class="text-2xl font-semibold tracking-tight truncate">{title}</h1>
|
|
<p class="text-muted-foreground text-sm truncate">{description}</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{#if onrefresh}
|
|
<Button variant="outline" size="icon" title={i18n.previous()} onclick={onrefresh}>
|
|
<RefreshCwIcon class="size-4" />
|
|
</Button>
|
|
{/if}
|
|
<Popover.Root>
|
|
<Popover.Trigger>
|
|
{#snippet child({ props })}
|
|
<Button {...props} variant="outline" class="relative">
|
|
<ListFilterIcon class="size-4" />
|
|
{i18n.filterTitle}
|
|
{#if activeFilterCount > 0}
|
|
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
|
{/if}
|
|
</Button>
|
|
{/snippet}
|
|
</Popover.Trigger>
|
|
<Popover.Content class="w-72 p-0" align="end">
|
|
<div class="border-b p-2">
|
|
<h3 class="text-sm font-semibold">{i18n.filterTitle}</h3>
|
|
</div>
|
|
{#if filterContent}
|
|
<div class="flex flex-col gap-3 p-2">
|
|
{@render filterContent()}
|
|
</div>
|
|
{/if}
|
|
<div class="flex flex-col gap-2 border-t p-2">
|
|
<Label class="text-xs">{i18n.display}</Label>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm">{i18n.rowsPerPage}</span>
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
max="500"
|
|
list="rpp-presets-{id}"
|
|
class="h-9 w-24"
|
|
value={pageSize.current}
|
|
onchange={(e) => {
|
|
const n = Number((e.target as HTMLInputElement).value);
|
|
if (n >= 1) {
|
|
pageSize.current = n;
|
|
page = 1;
|
|
}
|
|
}}
|
|
/>
|
|
<datalist id="rpp-presets-{id}">
|
|
{#each pageSizePresets as n (n)}
|
|
<option value={n}></option>
|
|
{/each}
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
</Popover.Content>
|
|
</Popover.Root>
|
|
{#if actions}
|
|
{@render actions()}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 px-4">
|
|
<Input
|
|
placeholder={searchPlaceholder}
|
|
value={search}
|
|
oninput={onsearchinput}
|
|
class="max-w-sm"
|
|
/>
|
|
{#if searchHint}
|
|
<span class="text-muted-foreground text-xs">{searchHint}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="p-4">
|
|
<div class="rounded-md border">
|
|
<Table.Root>
|
|
<Table.Header>
|
|
<Table.Row>
|
|
{@render columns()}
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{#if loading}
|
|
<Table.Row>
|
|
<Table.Cell colspan={99} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
|
</Table.Row>
|
|
{:else if !paginated.length}
|
|
<Table.Row>
|
|
<Table.Cell colspan={99} class="text-muted-foreground py-8 text-center"
|
|
>{emptyMessage}</Table.Cell
|
|
>
|
|
</Table.Row>
|
|
{:else}
|
|
{#each paginated as item, i (i)}
|
|
<Table.Row>
|
|
{@render row(item, i)}
|
|
</Table.Row>
|
|
{/each}
|
|
{/if}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
|
<div class="flex items-center gap-2">
|
|
{#if items.length > 0}
|
|
<span class="text-muted-foreground text-sm">
|
|
{i18n.pageOf({ page, pages: totalPages, total: totalPages })}
|
|
</span>
|
|
{/if}
|
|
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
|
{i18n.previous()}
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
|
{i18n.next()}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|