Files
headroom/openspec/changes/archive/2026-02-18-p04-content-patterns/design.md

8.6 KiB

Design: Content Patterns

DataTable Component

src/lib/components/common/DataTable.svelte

<script lang="ts" generics="T extends Record<string, any>">
  import { 
    createTable, 
    createRender,
    type ColumnDef,
    type SortingState
  } from '@tanstack/svelte-table';
  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;
    selectable?: boolean;
    selectedIds?: string[];
    onSelectionChange?: (ids: string[]) => void;
  }

  let { 
    data, 
    columns, 
    loading = false,
    emptyTitle = 'No data',
    emptyDescription = 'No records found.',
    onRowClick,
    selectable = false,
    selectedIds = [],
    onSelectionChange
  }: Props = $props();

  const sorting = writable<SortingState>([]);
  
  const table = createTable({
    data,
    columns,
    state: {
      sorting,
    },
    onSortingChange: (updater) => {
      sorting.update(updater);
    },
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  $derived rows = $table.getRowModel().rows;
  $derived isEmpty = !loading && rows.length === 0;
</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">
      <thead>
        {#each $table.getHeaderGroups() 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">
                  {header.column.columnDef.header}
                  {#if header.column.getCanSort()}
                    {#if $sorting.find(s => s.id === header.column.id)?.desc === true}
                      <ChevronDown size={14} />
                    {:else if $sorting.find(s => s.id === header.column.id)?.desc === false}
                      <ChevronUp size={14} />
                    {:else}
                      <ChevronsUpDown size={14} class="opacity-50" />
                    {/if}
                  {/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>
                {@html cell.renderCell()}
              </td>
            {/each}
          </tr>
        {/each}
      </tbody>
    </table>
  {/if}
</div>

FilterBar Component

src/lib/components/common/FilterBar.svelte

<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; // Custom filters
  }

  let { 
    searchValue = '', 
    searchPlaceholder = 'Search...',
    onSearchChange,
    onClear,
    children 
  }: Props = $props();

  $derived hasFilters = searchValue || children;
</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>

EmptyState Component

src/lib/components/common/EmptyState.svelte

<script lang="ts">
  import { Inbox } from 'lucide-svelte';
  import type { Snippet } from 'svelte';

  interface Props {
    title?: string;
    description?: string;
    icon?: typeof Inbox;
    children?: Snippet; // Action button
  }

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

LoadingState Component

src/lib/components/common/LoadingState.svelte

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

File Structure

src/lib/components/common/
├── DataTable.svelte      # NEW
├── FilterBar.svelte      # NEW
├── EmptyState.svelte     # NEW
├── LoadingState.svelte   # NEW
└── StatCard.svelte       # (from p03)

Usage Examples

DataTable Usage

<script>
  import DataTable from '$lib/components/common/DataTable.svelte';
  import type { ColumnDef } from '@tanstack/svelte-table';

  const columns: ColumnDef<TeamMember>[] = [
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'role', header: 'Role' },
    { accessorKey: 'hourlyRate', header: 'Rate' },
  ];

  let data = $state([]);
  let loading = $state(true);

  onMount(async () => {
    data = await fetchTeamMembers();
    loading = false;
  });
</script>

<DataTable 
  {data} 
  {columns} 
  {loading}
  emptyTitle="No team members"
  emptyDescription="Add your first team member to get started."
  onRowClick={(row) => navigate(`/team-members/${row.id}`)}
/>

FilterBar Usage

<script>
  import FilterBar from '$lib/components/common/FilterBar.svelte';

  let search = $state('');
  let statusFilter = $state('all');

  const statuses = [
    { value: 'all', label: 'All Statuses' },
    { value: 'active', label: 'Active' },
    { value: 'inactive', label: 'Inactive' },
  ];
</script>

<FilterBar 
  searchValue={search}
  searchPlaceholder="Search team members..."
  onSearchChange={(v) => search = v}
>
  <select 
    class="select select-sm"
    bind:value={statusFilter}
  >
    {#each statuses as status}
      <option value={status.value}>{status.label}</option>
    {/each}
  </select>
</FilterBar>