1
0
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:
Elias Schneider
2025-10-13 11:12:55 +02:00
committed by GitHub
parent 24ca6a106d
commit c20e93b55c
76 changed files with 1948 additions and 1434 deletions

View File

@@ -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>

View 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>

View File

@@ -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>

View 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}