mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-09 07:19:16 +00:00
feat: add various improvements to the table component (#961)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" generics="TData extends Record<string, any>">
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
|
||||
let {
|
||||
columns,
|
||||
selectedColumns = $bindable([])
|
||||
}: { columns: AdvancedTableColumn<TData>[]; selectedColumns: string[] } = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
class: 'ml-auto h-8'
|
||||
})}
|
||||
>
|
||||
<Settings2Icon />
|
||||
<span class="hidden md:flex">{m.view()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label>{m.toggle_columns()}</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
{#each columns as column (column)}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selectedColumns.includes(column.column ?? column.key!)}
|
||||
onCheckedChange={(v) => {
|
||||
const key = column.column ?? column.key!;
|
||||
if (v) {
|
||||
selectedColumns = [...selectedColumns, key];
|
||||
} else {
|
||||
selectedColumns = selectedColumns.filter((c) => c !== key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
128
frontend/src/lib/components/table/advanced-table-filter.svelte
Normal file
128
frontend/src/lib/components/table/advanced-table-filter.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Command from '$lib/components/ui/command/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
options,
|
||||
selectedValues = new Set<string | boolean>(),
|
||||
showCheckboxes = true,
|
||||
onChanged = (selected: Set<string | boolean>) => {}
|
||||
}: {
|
||||
title: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string | boolean;
|
||||
icon?: Component;
|
||||
}[];
|
||||
selectedValues?: Set<string | boolean>;
|
||||
showCheckboxes?: boolean;
|
||||
onChanged?: (selected: Set<string | boolean>) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 border-dashed"
|
||||
data-testid={`facet-${title.toLowerCase()}-trigger`}
|
||||
>
|
||||
<ListFilterIcon />
|
||||
{title}
|
||||
{#if selectedValues.size > 0}
|
||||
<Separator orientation="vertical" class="mx-2 h-4" />
|
||||
<Badge variant="secondary" class="rounded-sm px-1 font-normal lg:hidden">
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div class="hidden space-x-1 lg:flex">
|
||||
{#if selectedValues.size > 2}
|
||||
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
|
||||
Count: {selectedValues.size}
|
||||
</Badge>
|
||||
{:else}
|
||||
{#each options.filter((opt) => selectedValues.has(opt.value)) as option (option)}
|
||||
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
|
||||
{option.label}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="w-[200px] p-0"
|
||||
align="start"
|
||||
data-testid={`facet-${title.toLowerCase()}-content`}
|
||||
>
|
||||
<Command.Root>
|
||||
<Command.List>
|
||||
<Command.Empty>{m.no_items_found()}</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each options as option (option)}
|
||||
{@const isSelected = selectedValues.has(option.value)}
|
||||
<Command.Item
|
||||
data-testid={`facet-${title.toLowerCase()}-option-${String(option.value)}`}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues = new Set([...selectedValues].filter((v) => v !== option.value));
|
||||
} else {
|
||||
selectedValues = new Set([...selectedValues, option.value]);
|
||||
}
|
||||
onChanged(selectedValues);
|
||||
}}
|
||||
>
|
||||
{#if showCheckboxes}
|
||||
<div
|
||||
class={cn(
|
||||
'border-primary mr-2 flex size-4 items-center justify-center rounded-sm border',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon class="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if option.icon}
|
||||
{@const Icon = option.icon}
|
||||
<Icon class="text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span>{option.label}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{#if selectedValues.size > 0}
|
||||
<Command.Separator />
|
||||
<Command.Group>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
selectedValues = new Set();
|
||||
onChanged(selectedValues);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
{m.clear_filters()}
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" generics="TData extends Record<string, any>">
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { ListRequestOptions } from '$lib/types/list-request.type';
|
||||
import { debounced } from '$lib/utils/debounce-util';
|
||||
import AdvancedTableColumnSelection from './advanced-table-column-selection.svelte';
|
||||
import AdvancedTableFilter from './advanced-table-filter.svelte';
|
||||
|
||||
let {
|
||||
columns,
|
||||
visibleColumns = $bindable(),
|
||||
requestOptions,
|
||||
searchValue = $bindable(),
|
||||
withoutSearch = false,
|
||||
onFilterChange,
|
||||
refresh
|
||||
}: {
|
||||
columns: AdvancedTableColumn<TData>[];
|
||||
visibleColumns: string[];
|
||||
requestOptions: ListRequestOptions;
|
||||
searchValue?: string;
|
||||
withoutSearch?: boolean;
|
||||
onFilterChange?: (selected: Set<string | boolean>, column: string) => void;
|
||||
refresh: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let filterableColumns = $derived(
|
||||
columns
|
||||
.filter((c) => c.filterableValues)
|
||||
.map((c) => ({
|
||||
name: c.label!,
|
||||
column: c.column!,
|
||||
options: c.filterableValues!
|
||||
}))
|
||||
);
|
||||
|
||||
const onSearch = debounced(async (search: string) => {
|
||||
requestOptions.search = search;
|
||||
await refresh();
|
||||
searchValue = search;
|
||||
}, 300);
|
||||
</script>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-2">
|
||||
<div class="flex flex-1 items-center gap-2 has-[>:nth-child(3)]:flex-wrap">
|
||||
{#if !withoutSearch}
|
||||
<Input
|
||||
value={searchValue}
|
||||
class="relative z-50 w-full sm:max-w-xs"
|
||||
placeholder={m.search()}
|
||||
type="text"
|
||||
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each filterableColumns as col}
|
||||
<AdvancedTableFilter
|
||||
title={col.name}
|
||||
options={col.options}
|
||||
selectedValues={new Set(requestOptions.filters?.[col.column] || [])}
|
||||
onChanged={(selected) => onFilterChange?.(selected, col.column)}
|
||||
/>
|
||||
{/each}
|
||||
<AdvancedTableColumnSelection {columns} bind:selectedColumns={visibleColumns} />
|
||||
</div>
|
||||
</div>
|
||||
356
frontend/src/lib/components/table/advanced-table.svelte
Normal file
356
frontend/src/lib/components/table/advanced-table.svelte
Normal file
@@ -0,0 +1,356 @@
|
||||
<script lang="ts" generics="T extends {id:string}">
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import Empty from '$lib/icons/empty.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type {
|
||||
AdvancedTableColumn,
|
||||
CreateAdvancedTableActions
|
||||
} from '$lib/types/advanced-table.type';
|
||||
import type { ListRequestOptions, Paginated, SortRequest } from '$lib/types/list-request.type';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { ChevronDown, LucideEllipsis } from '@lucide/svelte';
|
||||
import { PersistedState } from 'runed';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Button, { buttonVariants } from '../ui/button/button.svelte';
|
||||
import * as DropdownMenu from '../ui/dropdown-menu/index.js';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import AdvancedTableToolbar from './advanced-table-toolbar.svelte';
|
||||
|
||||
let {
|
||||
id,
|
||||
selectedIds = $bindable(),
|
||||
withoutSearch = false,
|
||||
selectionDisabled = false,
|
||||
fetchCallback,
|
||||
defaultSort,
|
||||
columns,
|
||||
actions
|
||||
}: {
|
||||
id: string;
|
||||
selectedIds?: string[];
|
||||
withoutSearch?: boolean;
|
||||
selectionDisabled?: boolean;
|
||||
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
|
||||
defaultSort?: SortRequest;
|
||||
columns: AdvancedTableColumn<T>[];
|
||||
actions?: CreateAdvancedTableActions<T>;
|
||||
} = $props();
|
||||
|
||||
let items: Paginated<T> | undefined = $state();
|
||||
let searchValue = $state('');
|
||||
|
||||
const availablePageSizes: number[] = [20, 50, 100];
|
||||
|
||||
type TablePreferences = {
|
||||
visibleColumns: string[];
|
||||
paginationLimit: number;
|
||||
sort?: SortRequest;
|
||||
filters?: Record<string, (string | boolean)[]>;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
const tablePreferences = new PersistedState<TablePreferences>(`table-${id}-preferences`, {
|
||||
visibleColumns: columns.filter((c) => !c.hidden).map((c) => c.column ?? c.key!),
|
||||
paginationLimit: 20,
|
||||
filters: initializeFilters()
|
||||
});
|
||||
|
||||
const requestOptions = $state<ListRequestOptions>({
|
||||
sort: tablePreferences.current.sort ?? defaultSort,
|
||||
pagination: { limit: tablePreferences.current.paginationLimit, page: 1 },
|
||||
filters: tablePreferences.current.filters
|
||||
});
|
||||
|
||||
let visibleColumns = $derived(
|
||||
columns.filter(
|
||||
(c) => tablePreferences.current.visibleColumns?.includes(c.column ?? c.key!) ?? []
|
||||
)
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const page = parseInt(urlParams.get(`${id}-page`) ?? '') || undefined;
|
||||
if (page) {
|
||||
requestOptions.pagination!.page = page;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
let allChecked = $derived.by(() => {
|
||||
if (!selectedIds || !items || items.data.length === 0) return false;
|
||||
for (const item of items!.data) {
|
||||
if (!selectedIds.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onAllCheck(checked: boolean) {
|
||||
const pageIds = items!.data.map((item) => item.id);
|
||||
const current = selectedIds ?? [];
|
||||
|
||||
if (checked) {
|
||||
selectedIds = Array.from(new Set([...current, ...pageIds]));
|
||||
} else {
|
||||
selectedIds = current.filter((id) => !pageIds.includes(id));
|
||||
}
|
||||
}
|
||||
|
||||
async function onCheck(checked: boolean, id: string) {
|
||||
const current = selectedIds ?? [];
|
||||
if (checked) {
|
||||
selectedIds = Array.from(new Set([...current, id]));
|
||||
} else {
|
||||
selectedIds = current.filter((selectedId) => selectedId !== id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onPageChange(page: number) {
|
||||
changePageState(page);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onPageSizeChange(size: number) {
|
||||
requestOptions.pagination = { limit: size, page: 1 };
|
||||
tablePreferences.current.paginationLimit = size;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onFilterChange(selected: Set<string | boolean>, column: string) {
|
||||
requestOptions.filters = {
|
||||
...requestOptions.filters,
|
||||
[column]: Array.from(selected)
|
||||
};
|
||||
tablePreferences.current.filters = requestOptions.filters;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onSort(column?: string) {
|
||||
if (!column) return;
|
||||
|
||||
const isSameColumn = requestOptions.sort?.column === column;
|
||||
const nextDirection: 'asc' | 'desc' =
|
||||
isSameColumn && requestOptions.sort?.direction === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
requestOptions.sort = { column, direction: nextDirection };
|
||||
tablePreferences.current.sort = requestOptions.sort;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function changePageState(page: number) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(`${id}-page`, page.toString());
|
||||
history.replaceState(history.state, '', url.toString());
|
||||
requestOptions.pagination!.page = page;
|
||||
}
|
||||
|
||||
function updateListLength(totalItems: number) {
|
||||
tablePreferences.current.length =
|
||||
totalItems > tablePreferences.current.paginationLimit
|
||||
? tablePreferences.current.paginationLimit
|
||||
: totalItems;
|
||||
}
|
||||
|
||||
function initializeFilters() {
|
||||
const filters: Record<string, (string | boolean)[]> = {};
|
||||
columns.forEach((c) => {
|
||||
if (c.filterableValues) {
|
||||
filters[c.column!] = [];
|
||||
}
|
||||
});
|
||||
return filters;
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
items = await fetchCallback(requestOptions);
|
||||
changePageState(items.pagination.currentPage);
|
||||
updateListLength(items.pagination.totalItems);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdvancedTableToolbar
|
||||
{columns}
|
||||
bind:visibleColumns={tablePreferences.current.visibleColumns}
|
||||
{requestOptions}
|
||||
{searchValue}
|
||||
{withoutSearch}
|
||||
{refresh}
|
||||
{onFilterChange}
|
||||
/>
|
||||
|
||||
{#if (items?.pagination.totalItems === 0 && searchValue === '') || tablePreferences.current.length === 0}
|
||||
<div class="my-5 flex flex-col items-center">
|
||||
<Empty class="text-muted-foreground h-20" />
|
||||
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !items}
|
||||
<div>
|
||||
{#each Array((tablePreferences.current.length || 10) + 1) as _}
|
||||
<div>
|
||||
<Skeleton class="mt-3 h-[45px] w-full rounded-lg" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade>
|
||||
<Table.Root class="min-w-full table-auto overflow-x-auto">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#if selectedIds}
|
||||
<Table.Head class="w-12">
|
||||
<Checkbox
|
||||
disabled={selectionDisabled}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
|
||||
/>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
|
||||
{#each visibleColumns as column}
|
||||
<Table.Head class={cn(column.sortable && 'p-0')}>
|
||||
{#if column.sortable}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-12 w-full justify-start px-4 font-medium hover:bg-transparent"
|
||||
onclick={() => onSort(column.column)}
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{column.label}
|
||||
<ChevronDown
|
||||
class={cn(
|
||||
'ml-2 size-4 transition-all',
|
||||
requestOptions.sort?.column === column.column
|
||||
? requestOptions.sort?.direction === 'asc'
|
||||
? 'rotate-180 opacity-100'
|
||||
: 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
{:else}
|
||||
{column.label}
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
{#if actions}
|
||||
<Table.Head align="right" class="w-12">
|
||||
<span class="sr-only">{m.actions()}</span>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items.data as item}
|
||||
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
||||
{#if selectedIds}
|
||||
<Table.Cell class="w-12">
|
||||
<Checkbox
|
||||
disabled={selectionDisabled}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{#each visibleColumns as column}
|
||||
<Table.Cell>
|
||||
{#if column.value}
|
||||
{column.value(item)}
|
||||
{:else if column.cell}
|
||||
{@render column.cell({ item })}
|
||||
{:else if column.column && typeof item[column.column] === 'boolean'}
|
||||
{item[column.column] ? m.enabled() : m.disabled()}
|
||||
{:else if column.column}
|
||||
{item[column.column]}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
{#if actions}
|
||||
<Table.Cell align="right" class="w-12 py-0">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
|
||||
>
|
||||
<LucideEllipsis class="size-4" />
|
||||
<span class="sr-only">{m.toggle_menu()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#each actions(item).filter((a) => !a.hidden) as action}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => action.onClick(item)}
|
||||
disabled={action.disabled}
|
||||
class={action.variant === 'danger'
|
||||
? 'text-red-500 focus:!text-red-700'
|
||||
: ''}
|
||||
>
|
||||
{#if action.icon}
|
||||
{@const Icon = action.icon}
|
||||
<Icon class="mr-2 size-4" />
|
||||
{/if}
|
||||
{action.label}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={items?.pagination.itemsPerPage.toString()}
|
||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||
>
|
||||
<Select.Trigger class="h-9 w-[80px]">
|
||||
{items?.pagination.itemsPerPage}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availablePageSizes as size}
|
||||
<Select.Item value={size.toString()}>{size}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={items?.pagination.totalItems || 0}
|
||||
perPage={items?.pagination.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={items?.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-end">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user