feat(ui): Create content pattern components - DataTable, FilterBar, EmptyState, LoadingState

- Add LoadingState with table, card, list, and text skeleton patterns
- Add EmptyState with customizable icon, title, description, and action slot
- Add FilterBar with search input, clear button, and custom filter slot
- Add DataTable with TanStack Table integration, sorting, and row click
- Create barrel export index.ts for common components
- Install tanstack-table-8-svelte-5 for Svelte 5 compatibility
- Sync auth spec with authenticated user redirect requirements
- Archive p03-dashboard-enhancement

Refs: openspec/changes/p04-content-patterns
Closes: p04-content-patterns
This commit is contained in:
2026-02-18 18:40:47 -05:00
parent 96f1d0a6e5
commit 8e7bfbe517
16 changed files with 386 additions and 51 deletions

View File

@@ -12,6 +12,7 @@
"lucide-svelte": "^0.574.0",
"recharts": "^2.15.1",
"sveltekit-superforms": "^2.24.0",
"tanstack-table-8-svelte-5": "^0.1.2",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -5417,6 +5418,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/tanstack-table-8-svelte-5": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tanstack-table-8-svelte-5/-/tanstack-table-8-svelte-5-0.1.2.tgz",
"integrity": "sha512-wMRu7Y709GpRrbPSN6uiYPCsNk5J/ZjvNuHGCbSUNNZEs1u4q09qnoTbY1EcwGAb3RkDEHEyrE9ArJNT4w0HOg==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "^8.20.5"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",

View File

@@ -49,6 +49,7 @@
"lucide-svelte": "^0.574.0",
"recharts": "^2.15.1",
"sveltekit-superforms": "^2.24.0",
"tanstack-table-8-svelte-5": "^0.1.2",
"zod": "^3.24.2"
}
}

View File

@@ -0,0 +1,120 @@
<script lang="ts" generics="T extends Record<string, any">
import {
createSvelteTable,
getCoreRowModel,
getSortedRowModel,
type ColumnDef,
type SortingState,
type TableOptions
} from 'tanstack-table-8-svelte-5';
import { writable } from 'svelte/store';
import EmptyState from './EmptyState.svelte';
import LoadingState from './LoadingState.svelte';
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-svelte';
interface Props {
data: T[];
columns: ColumnDef<T>[];
loading?: boolean;
emptyTitle?: string;
emptyDescription?: string;
onRowClick?: (row: T) => void;
}
let {
data,
columns,
loading = false,
emptyTitle = 'No data',
emptyDescription = 'No records found.',
onRowClick
}: Props = $props();
const sorting = writable<SortingState>([]);
const options: TableOptions<T> = {
data,
columns,
state: {
get sorting() {
return $sorting;
}
},
onSortingChange: (updater) => {
if (typeof updater === 'function') {
sorting.update(updater);
} else {
sorting.set(updater);
}
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
};
const table = createSvelteTable(options);
let rows = $derived($table.getRowModel().rows);
let isEmpty = $derived(!loading && rows.length === 0);
let headerGroups = $derived($table.getHeaderGroups());
function getSortIcon(columnId: string) {
const sort = $sorting.find(s => s.id === columnId);
if (!sort) return ChevronsUpDown;
return sort.desc ? ChevronDown : ChevronUp;
}
function getSortIconClass(columnId: string) {
const sort = $sorting.find(s => s.id === columnId);
return sort ? '' : 'opacity-50';
}
</script>
<div class="data-table overflow-x-auto">
{#if loading}
<LoadingState type="table" rows={5} columns={columns.length} />
{:else if isEmpty}
<EmptyState title={emptyTitle} description={emptyDescription} />
{:else}
<table class="table table-zebra table-pin-rows w-full">
<thead>
{#each headerGroups as headerGroup}
<tr>
{#each headerGroup.headers as header}
<th
class={header.column.getCanSort() ? 'cursor-pointer select-none' : ''}
onclick={header.column.getToggleSortingHandler()}
>
<div class="flex items-center gap-2">
<span>{header.column.columnDef.header as string}</span>
{#if header.column.getCanSort()}
<span class={getSortIconClass(header.column.id)}>
<svelte:component this={getSortIcon(header.column.id)} size={14} />
</span>
{/if}
</div>
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each rows as row}
<tr
class={onRowClick ? 'cursor-pointer hover:bg-base-200' : ''}
onclick={() => onRowClick?.(row.original)}
>
{#each row.getVisibleCells() as cell}
<td>
{#if typeof cell.column.columnDef.cell === 'function'}
{cell.column.columnDef.cell(cell.getContext())}
{:else}
{cell.getValue()}
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Inbox } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { ComponentType, SvelteComponent } from 'svelte';
interface Props {
title?: string;
description?: string;
icon?: ComponentType<SvelteComponent>;
children?: Snippet;
}
let {
title = 'No data',
description = 'No records found.',
icon: Icon = Inbox,
children
}: Props = $props();
</script>
<div class="empty-state flex flex-col items-center justify-center py-12 text-center">
<div class="text-base-content/30 mb-4">
<svelte:component this={Icon} size={48} />
</div>
<h3 class="text-lg font-medium text-base-content/70">{title}</h3>
<p class="text-sm text-base-content/50 mt-1 max-w-sm">{description}</p>
{#if children}
<div class="mt-4">
{@render children()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { Search, X } from 'lucide-svelte';
import type { Snippet } from 'svelte';
interface Props {
searchValue?: string;
searchPlaceholder?: string;
onSearchChange?: (value: string) => void;
onClear?: () => void;
children?: Snippet;
}
let {
searchValue = '',
searchPlaceholder = 'Search...',
onSearchChange,
onClear,
children
}: Props = $props();
let hasFilters = $derived(searchValue !== '' || children !== undefined);
</script>
<div class="filter-bar flex flex-wrap items-center gap-3 mb-4">
<!-- Search Input -->
<div class="join">
<input
type="text"
class="input input-sm join-item w-64"
placeholder={searchPlaceholder}
value={searchValue}
oninput={(e) => onSearchChange?.(e.currentTarget.value)}
/>
<button class="btn btn-sm join-item" aria-label="Search">
<Search size={16} />
</button>
</div>
<!-- Custom Filters Slot -->
{#if children}
{@render children()}
{/if}
<!-- Clear Button -->
{#if hasFilters}
<button
class="btn btn-ghost btn-sm gap-1"
onclick={() => {
onSearchChange?.('');
onClear?.();
}}
>
<X size={14} />
Clear
</button>
{/if}
</div>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
interface Props {
type?: 'table' | 'card' | 'text' | 'list';
rows?: number;
columns?: number;
}
let { type = 'text', rows = 3, columns = 4 }: Props = $props();
</script>
<div class="loading-state" data-type={type}>
{#if type === 'table'}
<div class="space-y-2">
<!-- Header -->
<div class="flex gap-4">
{#each Array(columns) as _}
<div class="skeleton h-8 flex-1"></div>
{/each}
</div>
<!-- Rows -->
{#each Array(rows) as _}
<div class="flex gap-4">
{#each Array(columns) as _}
<div class="skeleton h-10 flex-1"></div>
{/each}
</div>
{/each}
</div>
{:else if type === 'card'}
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="skeleton h-6 w-1/2 mb-2"></div>
<div class="skeleton h-4 w-full mb-1"></div>
<div class="skeleton h-4 w-3/4"></div>
</div>
</div>
{:else if type === 'list'}
<div class="space-y-3">
{#each Array(rows) as _}
<div class="flex items-center gap-3">
<div class="skeleton h-10 w-10 rounded-full"></div>
<div class="flex-1">
<div class="skeleton h-4 w-1/2 mb-1"></div>
<div class="skeleton h-3 w-1/4"></div>
</div>
</div>
{/each}
</div>
{:else}
<!-- text -->
<div class="space-y-2">
<div class="skeleton h-4 w-full"></div>
<div class="skeleton h-4 w-5/6"></div>
<div class="skeleton h-4 w-4/6"></div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,6 @@
// Content Pattern Components
export { default as DataTable } from './DataTable.svelte';
export { default as FilterBar } from './FilterBar.svelte';
export { default as EmptyState } from './EmptyState.svelte';
export { default as LoadingState } from './LoadingState.svelte';
export { default as StatCard } from './StatCard.svelte';

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import DataTable from '$lib/components/common/DataTable.svelte';
describe('DataTable', () => {
it('exports a component module', () => {
expect(DataTable).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import EmptyState from '$lib/components/common/EmptyState.svelte';
describe('EmptyState', () => {
it('exports a component module', () => {
expect(EmptyState).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import FilterBar from '$lib/components/common/FilterBar.svelte';
describe('FilterBar', () => {
it('exports a component module', () => {
expect(FilterBar).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import LoadingState from '$lib/components/common/LoadingState.svelte';
describe('LoadingState', () => {
it('exports a component module', () => {
expect(LoadingState).toBeDefined();
});
});