style(ui): responsive card based styling for table comps

This commit is contained in:
Sam Chau
2026-01-29 01:11:59 +10:30
parent 5720bd6d3c
commit 984b092c32
2 changed files with 455 additions and 251 deletions

View File

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

View File

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