mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-31 06:40:50 +01:00
style(ui): responsive card based styling for table comps
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" generics="T extends Record<string, any>">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
|
||||
import type { Column, SortState } from './types';
|
||||
|
||||
@@ -12,6 +13,32 @@
|
||||
export let flushBottom: boolean = false;
|
||||
export let expandedRows: Set<string | number> = new Set();
|
||||
export let chevronPosition: 'left' | 'right' = 'left';
|
||||
// Mobile responsive mode - switches to card layout on small screens
|
||||
export let responsive: boolean = false;
|
||||
|
||||
let isMobile = false;
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (responsive && typeof window !== 'undefined') {
|
||||
mediaQuery = window.matchMedia('(max-width: 767px)');
|
||||
isMobile = mediaQuery.matches;
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
}
|
||||
});
|
||||
|
||||
function handleMediaChange(e: MediaQueryListEvent) {
|
||||
isMobile = e.matches;
|
||||
}
|
||||
|
||||
$: useMobileLayout = responsive && isMobile;
|
||||
|
||||
let sortState: SortState | null = defaultSort;
|
||||
|
||||
function toggleRow(id: string | number) {
|
||||
@@ -101,152 +128,229 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800 {flushBottom
|
||||
? 'rounded-b-none border-b-0'
|
||||
: ''}"
|
||||
>
|
||||
<table class="w-full">
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
<!-- Expand column (left) -->
|
||||
{#if chevronPosition === 'left'}
|
||||
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
|
||||
{/if}
|
||||
{#each columns as column}
|
||||
<th
|
||||
class="{compact
|
||||
? 'px-4 py-2'
|
||||
: 'px-6 py-3'} text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300 {getAlignClass(
|
||||
column.align
|
||||
)} {column.width || ''}"
|
||||
>
|
||||
{#if column.sortable}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => handleSort(column)}
|
||||
class="inline-flex items-center gap-1 transition-colors hover:text-neutral-900 dark:hover:text-neutral-100 {column.align ===
|
||||
'right'
|
||||
? 'flex-row-reverse'
|
||||
: ''}"
|
||||
>
|
||||
{column.header}
|
||||
{#if sortState?.key === column.key}
|
||||
{#if sortState.direction === 'asc'}
|
||||
<ArrowUp size={12} class="text-accent-500" />
|
||||
{:else}
|
||||
<ArrowDown size={12} class="text-accent-500" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDown size={12} class="opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{column.header}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
{#if $$slots.actions}
|
||||
<th
|
||||
class="{compact
|
||||
? 'px-4 py-2'
|
||||
: 'px-6 py-3'} w-20 text-right text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
{/if}
|
||||
<!-- Expand column (right) -->
|
||||
{#if chevronPosition === 'right'}
|
||||
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if sortedData.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={columns.length + 1 + ($$slots.actions ? 1 : 0)}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each sortedData as row, index}
|
||||
{@const rowId = getRowId(row)}
|
||||
|
||||
<!-- Main Row -->
|
||||
<tr
|
||||
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
|
||||
{#if useMobileLayout}
|
||||
<!-- Mobile Card Layout -->
|
||||
<div class="space-y-3">
|
||||
{#if sortedData.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-8 text-center text-sm text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{:else}
|
||||
{#each sortedData as row, index}
|
||||
{@const rowId = getRowId(row)}
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Card Header - clickable to expand -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left"
|
||||
on:click={() => toggleRow(rowId)}
|
||||
>
|
||||
<!-- Expand Icon (left) -->
|
||||
{#if chevronPosition === 'left'}
|
||||
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-neutral-400">
|
||||
{#if expandedRows.has(rowId)}
|
||||
<ChevronUp size={16} />
|
||||
{:else}
|
||||
<ChevronDown size={16} />
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
|
||||
{#each columns as column}
|
||||
<td
|
||||
class="{compact
|
||||
? 'px-4 py-2'
|
||||
: 'px-6 py-4'} text-sm text-neutral-900 dark:text-neutral-100 {getAlignClass(
|
||||
column.align
|
||||
)} {column.width || ''}"
|
||||
>
|
||||
<slot name="cell" {row} {column} {index} expanded={expandedRows.has(rowId)}>
|
||||
{getCellValue(row, column.key)}
|
||||
<!-- Primary row: first column as title + actions + chevron -->
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div class="min-w-0 flex-1 font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<slot name="cell" {row} column={columns[0]} {index} expanded={expandedRows.has(rowId)}>
|
||||
{getCellValue(row, columns[0].key)}
|
||||
</slot>
|
||||
</td>
|
||||
{/each}
|
||||
{#if $$slots.actions}
|
||||
<td
|
||||
class="{compact ? 'px-4 py-2' : 'px-6 py-4'} text-right text-sm"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<slot name="actions" {row} />
|
||||
</td>
|
||||
{/if}
|
||||
|
||||
<!-- Expand Icon (right) -->
|
||||
{#if chevronPosition === 'right'}
|
||||
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-right text-neutral-400">
|
||||
{#if expandedRows.has(rowId)}
|
||||
<ChevronUp size={16} class="inline-block" />
|
||||
{:else}
|
||||
<ChevronDown size={16} class="inline-block" />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#if $$slots.actions}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div on:click|stopPropagation>
|
||||
<slot name="actions" {row} />
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{#if expandedRows.has(rowId)}
|
||||
<ChevronUp size={18} class="text-neutral-400" />
|
||||
{:else}
|
||||
<ChevronDown size={18} class="text-neutral-400" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Row -->
|
||||
<!-- Secondary columns as label-value pairs -->
|
||||
{#if columns.length > 1}
|
||||
<div class="space-y-2 border-t border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||
{#each columns.slice(1) as column, colIndex}
|
||||
<div class="flex items-center justify-between gap-4 text-sm">
|
||||
<span class="shrink-0 text-neutral-500 dark:text-neutral-400">{column.header}</span>
|
||||
<span class="min-w-0 text-right text-neutral-700 dark:text-neutral-300">
|
||||
<slot name="cell" {row} {column} index={colIndex + 1} expanded={expandedRows.has(rowId)}>
|
||||
{getCellValue(row, column.key)}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Expanded Content -->
|
||||
{#if expandedRows.has(rowId)}
|
||||
<tr class="bg-neutral-50 dark:bg-neutral-800/30">
|
||||
<td
|
||||
colspan={columns.length + 1 + ($$slots.actions ? 1 : 0)}
|
||||
class={flushExpanded ? '' : compact ? 'px-4 py-3' : 'px-6 py-4'}
|
||||
>
|
||||
<div class={flushExpanded ? '' : 'ml-6'}>
|
||||
<slot name="expanded" {row}>
|
||||
<!-- Default expanded content -->
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No additional details
|
||||
</div>
|
||||
</slot>
|
||||
<div class="border-t border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800/30">
|
||||
<slot name="expanded" {row}>
|
||||
<div class="p-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No additional details
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</slot>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop Table Layout -->
|
||||
<div
|
||||
class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800 {flushBottom
|
||||
? 'rounded-b-none border-b-0'
|
||||
: ''}"
|
||||
>
|
||||
<table class="w-full">
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
<!-- Expand column (left) -->
|
||||
{#if chevronPosition === 'left'}
|
||||
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
|
||||
{/if}
|
||||
{#each columns as column}
|
||||
<th
|
||||
class="{compact
|
||||
? 'px-4 py-2'
|
||||
: 'px-6 py-3'} text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300 {getAlignClass(
|
||||
column.align
|
||||
)} {column.width || ''}"
|
||||
>
|
||||
{#if column.sortable}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => handleSort(column)}
|
||||
class="inline-flex items-center gap-1 transition-colors hover:text-neutral-900 dark:hover:text-neutral-100 {column.align ===
|
||||
'right'
|
||||
? 'flex-row-reverse'
|
||||
: ''}"
|
||||
>
|
||||
{column.header}
|
||||
{#if sortState?.key === column.key}
|
||||
{#if sortState.direction === 'asc'}
|
||||
<ArrowUp size={12} class="text-accent-500" />
|
||||
{:else}
|
||||
<ArrowDown size={12} class="text-accent-500" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDown size={12} class="opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{column.header}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
{#if $$slots.actions}
|
||||
<th
|
||||
class="{compact
|
||||
? 'px-4 py-2'
|
||||
: 'px-6 py-3'} w-20 text-right text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
{/if}
|
||||
<!-- Expand column (right) -->
|
||||
{#if chevronPosition === 'right'}
|
||||
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if sortedData.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={columns.length + 1 + ($$slots.actions ? 1 : 0)}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each sortedData as row, index}
|
||||
{@const rowId = getRowId(row)}
|
||||
|
||||
<!-- Main Row -->
|
||||
<tr
|
||||
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
|
||||
on:click={() => toggleRow(rowId)}
|
||||
>
|
||||
<!-- Expand Icon (left) -->
|
||||
{#if chevronPosition === 'left'}
|
||||
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-neutral-400">
|
||||
{#if expandedRows.has(rowId)}
|
||||
<ChevronUp size={16} />
|
||||
{:else}
|
||||
<ChevronDown size={16} />
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
|
||||
{#each columns as column}
|
||||
<td
|
||||
class="{compact
|
||||
? 'px-4 py-2'
|
||||
: 'px-6 py-4'} text-sm text-neutral-900 dark:text-neutral-100 {getAlignClass(
|
||||
column.align
|
||||
)} {column.width || ''}"
|
||||
>
|
||||
<slot name="cell" {row} {column} {index} expanded={expandedRows.has(rowId)}>
|
||||
{getCellValue(row, column.key)}
|
||||
</slot>
|
||||
</td>
|
||||
{/each}
|
||||
{#if $$slots.actions}
|
||||
<td
|
||||
class="{compact ? 'px-4 py-2' : 'px-6 py-4'} text-right text-sm"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<slot name="actions" {row} />
|
||||
</td>
|
||||
{/if}
|
||||
|
||||
<!-- Expand Icon (right) -->
|
||||
{#if chevronPosition === 'right'}
|
||||
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-right text-neutral-400">
|
||||
{#if expandedRows.has(rowId)}
|
||||
<ChevronUp size={16} class="inline-block" />
|
||||
{:else}
|
||||
<ChevronDown size={16} class="inline-block" />
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
|
||||
<!-- Expanded Row -->
|
||||
{#if expandedRows.has(rowId)}
|
||||
<tr class="bg-neutral-50 dark:bg-neutral-800/30">
|
||||
<td
|
||||
colspan={columns.length + 1 + ($$slots.actions ? 1 : 0)}
|
||||
class={flushExpanded ? '' : compact ? 'px-4 py-3' : 'px-6 py-4'}
|
||||
>
|
||||
<div class={flushExpanded ? '' : 'ml-6'}>
|
||||
<slot name="expanded" {row}>
|
||||
<!-- Default expanded content -->
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No additional details
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" generics="T extends Record<string, any>">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { Column, SortDirection, SortState } from './types';
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,31 @@
|
||||
export let initialSort: SortState | null = null;
|
||||
export let onSortChange: ((sort: SortState | null) => void) | undefined = undefined;
|
||||
export let actionsHeader: string = 'Actions';
|
||||
// Mobile responsive mode - switches to card layout on small screens
|
||||
export let responsive: boolean = false;
|
||||
|
||||
let isMobile = false;
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (responsive && typeof window !== 'undefined') {
|
||||
mediaQuery = window.matchMedia('(max-width: 767px)');
|
||||
isMobile = mediaQuery.matches;
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
}
|
||||
});
|
||||
|
||||
function handleMediaChange(e: MediaQueryListEvent) {
|
||||
isMobile = e.matches;
|
||||
}
|
||||
|
||||
$: useMobileLayout = responsive && isMobile;
|
||||
|
||||
let sortKey: string | null = initialSort?.key ?? null;
|
||||
let sortDirection: SortDirection = initialSort?.direction ?? 'asc';
|
||||
@@ -110,124 +136,198 @@
|
||||
$: (sortKey, sortDirection, (sortedData = sortData(data)));
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<!-- Header -->
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300 ${getAlignClass(column.align)} ${column.width || ''}`}
|
||||
>
|
||||
{#if column.sortable}
|
||||
<button
|
||||
type="button"
|
||||
class={`group flex w-full items-center gap-1.5 text-xs font-medium tracking-wider uppercase ${
|
||||
column.align === 'center'
|
||||
? 'justify-center'
|
||||
: column.align === 'right'
|
||||
? 'justify-end'
|
||||
: 'justify-start'
|
||||
}`}
|
||||
on:click={() => toggleSort(column)}
|
||||
>
|
||||
{#if column.headerIcon}
|
||||
<svelte:component this={column.headerIcon} size={14} />
|
||||
{#if useMobileLayout}
|
||||
<!-- Mobile Card Layout -->
|
||||
<div class="space-y-3">
|
||||
{#if sortedData.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-8 text-center text-sm text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{:else}
|
||||
{#each sortedData as row, rowIndex}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900 {onRowClick ? 'cursor-pointer' : ''}"
|
||||
on:click={() => onRowClick && onRowClick(row)}
|
||||
>
|
||||
<!-- Primary row: first column as title + actions -->
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div class="min-w-0 flex-1 font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{#if columns[0].cell}
|
||||
{@const rendered = columns[0].cell(row)}
|
||||
{#if typeof rendered === 'string'}
|
||||
{rendered}
|
||||
{:else if typeof rendered === 'object' && 'html' in rendered}
|
||||
{@html rendered.html}
|
||||
{:else}
|
||||
<svelte:component this={rendered} {row} />
|
||||
{/if}
|
||||
<span>{column.header}</span>
|
||||
<span
|
||||
class="text-[0.6rem] text-neutral-400 transition-opacity group-hover:text-neutral-600 group-hover:dark:text-neutral-200"
|
||||
>
|
||||
{#if sortKey === column.key}
|
||||
{sortDirection === 'asc' ? '▲' : '▼'}
|
||||
{:else}
|
||||
⇅
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class={`flex items-center gap-1.5 ${
|
||||
column.align === 'center'
|
||||
? 'justify-center'
|
||||
: column.align === 'right'
|
||||
? 'justify-end'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{#if column.headerIcon}
|
||||
<svelte:component this={column.headerIcon} size={14} />
|
||||
{/if}
|
||||
{column.header}
|
||||
{:else}
|
||||
<slot name="cell" {row} column={columns[0]} {rowIndex}>
|
||||
{getCellValue(row, columns[0].key)}
|
||||
</slot>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $$slots.actions}
|
||||
<div class="shrink-0">
|
||||
<slot name="actions" {row} {rowIndex} />
|
||||
</div>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
<!-- Actions column slot -->
|
||||
{#if $$slots.actions}
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-right text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300`}
|
||||
>
|
||||
{actionsHeader}
|
||||
</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if sortedData.length === 0}
|
||||
<!-- Secondary columns as label-value pairs -->
|
||||
{#if columns.length > 1}
|
||||
<div class="space-y-2 border-t border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||
{#each columns.slice(1) as column, colIndex}
|
||||
<div class="flex items-center justify-between gap-4 text-sm">
|
||||
<span class="shrink-0 text-neutral-500 dark:text-neutral-400">{column.header}</span>
|
||||
<span class="min-w-0 text-right text-neutral-700 dark:text-neutral-300">
|
||||
{#if column.cell}
|
||||
{@const rendered = column.cell(row)}
|
||||
{#if typeof rendered === 'string'}
|
||||
{rendered}
|
||||
{:else if typeof rendered === 'object' && 'html' in rendered}
|
||||
{@html rendered.html}
|
||||
{:else}
|
||||
<svelte:component this={rendered} {row} />
|
||||
{/if}
|
||||
{:else}
|
||||
<slot name="cell" {row} {column} rowIndex={colIndex + 1}>
|
||||
{getCellValue(row, column.key)}
|
||||
</slot>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop Table Layout -->
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<!-- Header -->
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
colspan={columns.length + ($$slots.actions ? 1 : 0)}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each sortedData as row, rowIndex}
|
||||
<tr
|
||||
class="{hoverable
|
||||
? 'transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900'
|
||||
: ''} {onRowClick ? 'cursor-pointer' : ''}"
|
||||
on:click={() => onRowClick && onRowClick(row)}
|
||||
>
|
||||
{#each columns as column}
|
||||
<td
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-neutral-900 dark:text-neutral-100 ${getAlignClass(column.align)} ${column.width || ''}`}
|
||||
>
|
||||
{#if column.cell}
|
||||
{@const rendered = column.cell(row)}
|
||||
{#if typeof rendered === 'string'}
|
||||
{rendered}
|
||||
{:else if typeof rendered === 'object' && 'html' in rendered}
|
||||
{@html rendered.html}
|
||||
{:else}
|
||||
<svelte:component this={rendered} {row} />
|
||||
{#each columns as column}
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300 ${getAlignClass(column.align)} ${column.width || ''}`}
|
||||
>
|
||||
{#if column.sortable}
|
||||
<button
|
||||
type="button"
|
||||
class={`group flex w-full items-center gap-1.5 text-xs font-medium tracking-wider uppercase ${
|
||||
column.align === 'center'
|
||||
? 'justify-center'
|
||||
: column.align === 'right'
|
||||
? 'justify-end'
|
||||
: 'justify-start'
|
||||
}`}
|
||||
on:click={() => toggleSort(column)}
|
||||
>
|
||||
{#if column.headerIcon}
|
||||
<svelte:component this={column.headerIcon} size={14} />
|
||||
{/if}
|
||||
{:else}
|
||||
<slot name="cell" {row} {column} {rowIndex}>
|
||||
{getCellValue(row, column.key)}
|
||||
</slot>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
<span>{column.header}</span>
|
||||
<span
|
||||
class="text-[0.6rem] text-neutral-400 transition-opacity group-hover:text-neutral-600 group-hover:dark:text-neutral-200"
|
||||
>
|
||||
{#if sortKey === column.key}
|
||||
{sortDirection === 'asc' ? '▲' : '▼'}
|
||||
{:else}
|
||||
⇅
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class={`flex items-center gap-1.5 ${
|
||||
column.align === 'center'
|
||||
? 'justify-center'
|
||||
: column.align === 'right'
|
||||
? 'justify-end'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{#if column.headerIcon}
|
||||
<svelte:component this={column.headerIcon} size={14} />
|
||||
{/if}
|
||||
{column.header}
|
||||
</div>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
<!-- Actions column slot -->
|
||||
{#if $$slots.actions}
|
||||
<th
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-right text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300`}
|
||||
>
|
||||
{actionsHeader}
|
||||
</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<!-- Actions slot -->
|
||||
{#if $$slots.actions}
|
||||
<td class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-right text-sm`}>
|
||||
<slot name="actions" {row} {rowIndex} />
|
||||
</td>
|
||||
{/if}
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
{#if sortedData.length === 0}
|
||||
<tr>
|
||||
<td
|
||||
colspan={columns.length + ($$slots.actions ? 1 : 0)}
|
||||
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
{#each sortedData as row, rowIndex}
|
||||
<tr
|
||||
class="{hoverable
|
||||
? 'transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900'
|
||||
: ''} {onRowClick ? 'cursor-pointer' : ''}"
|
||||
on:click={() => onRowClick && onRowClick(row)}
|
||||
>
|
||||
{#each columns as column}
|
||||
<td
|
||||
class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-neutral-900 dark:text-neutral-100 ${getAlignClass(column.align)} ${column.width || ''}`}
|
||||
>
|
||||
{#if column.cell}
|
||||
{@const rendered = column.cell(row)}
|
||||
{#if typeof rendered === 'string'}
|
||||
{rendered}
|
||||
{:else if typeof rendered === 'object' && 'html' in rendered}
|
||||
{@html rendered.html}
|
||||
{:else}
|
||||
<svelte:component this={rendered} {row} />
|
||||
{/if}
|
||||
{:else}
|
||||
<slot name="cell" {row} {column} {rowIndex}>
|
||||
{getCellValue(row, column.key)}
|
||||
</slot>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<!-- Actions slot -->
|
||||
{#if $$slots.actions}
|
||||
<td class={`${compact ? 'px-4 py-2' : 'px-6 py-4'} text-right text-sm`}>
|
||||
<slot name="actions" {row} {rowIndex} />
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
td :global(ul) {
|
||||
|
||||
Reference in New Issue
Block a user