feat: pin table row actions and add them to details pages

This commit is contained in:
Elias Schneider
2026-10-04 23:04:17 +02:00
parent 1be9ba9991
commit 3e201b5bb2
23 changed files with 469 additions and 258 deletions
@@ -0,0 +1,51 @@
<script lang="ts" generics="T">
import {
buttonVariants,
type ButtonSize,
type ButtonVariant
} from '#lib/components/ui/button/index.ts';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import { m } from '#lib/paraglide/messages.js';
import type { AdvancedTableAction } from '#lib/types/advanced-table.type.ts';
import { LucideEllipsis } from '@lucide/svelte';
let {
item,
actions,
label = m.toggle_menu(),
variant = 'ghost',
size = 'icon'
}: {
item: T;
actions: AdvancedTableAction<T>[];
label?: string;
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
let visibleActions = $derived(actions.filter((a) => !a.hidden));
</script>
{#if visibleActions.length > 0}
<DropdownMenu.Root>
<DropdownMenu.Trigger class={buttonVariants({ variant, size })}>
<LucideEllipsis class="size-4" />
<span class="sr-only">{label}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#each visibleActions as action (action.label)}
<DropdownMenu.Item
onclick={() => action.onClick(item)}
disabled={action.disabled}
variant={action.variant === 'danger' ? 'destructive' : 'default'}
>
{#if action.icon}
{@const Icon = action.icon}
<Icon class="mr-2 size-4" />
{/if}
{action.label}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts" generics="T extends {id:string}">
import { goto } from '$app/navigation';
import { page as currentPage } from '$app/state';
import ActionsMenu from '#lib/components/actions-menu.svelte';
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import * as Select from '#lib/components/ui/select/index.ts';
@@ -13,12 +14,11 @@
} from '#lib/types/advanced-table.type.ts';
import type { ListRequestOptions, Paginated, SortRequest } from '#lib/types/list-request.type.ts';
import { cn } from '#lib/utils/style.ts';
import { ChevronDown, LucideEllipsis } from '@lucide/svelte';
import { ChevronDown } 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 Button from '../ui/button/button.svelte';
import { Skeleton } from '../ui/skeleton';
import AdvancedTableToolbar from './advanced-table-toolbar.svelte';
@@ -213,7 +213,7 @@
</div>
{:else}
<div in:fade>
<Table.Root class="min-w-full table-auto overflow-x-auto">
<Table.Root class="min-w-full table-auto">
<Table.Header>
<Table.Row>
{#if selectedIds}
@@ -263,7 +263,7 @@
</Table.Head>
{/each}
{#if actions}
<Table.Head align="right" class="w-12">
<Table.Head align="right" class="bg-card sticky right-0 z-10 w-12">
<span class="sr-only">{m.actions()}</span>
</Table.Head>
{/if}
@@ -273,6 +273,7 @@
{#each items.data as item (item.id)}
<Table.Row
class={{
'group/row': true,
'bg-muted/20': selectedIds?.includes(item.id),
'cursor-pointer': getPrimaryAction(item) || onRowClick
}}
@@ -301,30 +302,12 @@
</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 (action.label)}
<DropdownMenu.Item
onclick={() => action.onClick(item)}
disabled={action.disabled}
class={action.variant === 'danger' ? 'text-red-500!' : ''}
>
{#if action.icon}
{@const Icon = action.icon}
<Icon class="mr-2 size-4" />
{/if}
{action.label}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Pinned to the right edge so the actions stay reachable when the table scrolls horizontally -->
<Table.Cell
align="right"
class="bg-card sticky right-0 z-10 w-12 py-0 transition-colors group-hover/row:bg-[color-mix(in_oklab,var(--color-muted)_50%,var(--color-card))]"
>
<ActionsMenu {item} actions={actions(item)} />
</Table.Cell>
{/if}
</Table.Row>
@@ -23,7 +23,7 @@
{sideOffset}
{align}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! relative z-50 min-w-48 animate-none! overflow-x-hidden overflow-y-auto rounded-3xl p-1.5 shadow-lg ring-1 duration-100 outline-none before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 data-closed:overflow-hidden',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:data-highlighted:bg-destructive/10! dark:**:data-[variant=destructive]:data-highlighted:bg-destructive/20! relative z-50 min-w-48 animate-none! overflow-x-hidden overflow-y-auto rounded-3xl p-1.5 shadow-lg ring-1 duration-100 outline-none before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 data-closed:overflow-hidden',
className
)}
{...restProps}
@@ -13,7 +13,7 @@
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! relative w-auto min-w-36 animate-none! rounded-3xl p-1.5 shadow-lg ring-1 duration-100 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 text-popover-foreground bg-popover/70 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:data-highlighted:bg-destructive/10! dark:**:data-[variant=destructive]:data-highlighted:bg-destructive/20! relative w-auto min-w-36 animate-none! rounded-3xl p-1.5 shadow-lg ring-1 duration-100 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150',
className
)}
{...restProps}