Files
nadir-webui/src/lib/components/dashboard/data-table.svelte
T
2026-06-25 23:11:32 +02:00

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>