Data Table

A searchable table with styled rows and cells. Composed from foldcn primitives.

Preview
Style
NameEmailPlanStatus
Ada Lovelaceada@example.comBusinessActive
Grace Hoppergrace@example.comStartupActive
Alan Turingalan@example.comBusinessInvited
Linus Paulinglinus@example.comStartupActive
Barbara Liskovbarbara@example.comEnterpriseInactive
src/demo/views/data-table.ts86 lines
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { dataTable } from '../../generated/registry/blocks/data-table/data-table'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  UpdatedTableSearch: { value: S.String },
})

const INITIAL_ROWS: ReadonlyArray<{
  id: string
  name: string
  email: string
  plan: string
  status: string
}> = [
  { id: '1', name: 'Ada Lovelace', email: 'ada@example.com', plan: 'Business', status: 'Active' },
  { id: '2', name: 'Grace Hopper', email: 'grace@example.com', plan: 'Startup', status: 'Active' },
  { id: '3', name: 'Alan Turing', email: 'alan@example.com', plan: 'Business', status: 'Invited' },
  { id: '4', name: 'Linus Pauling', email: 'linus@example.com', plan: 'Startup', status: 'Active' },
  {
    id: '5',
    name: 'Barbara Liskov',
    email: 'barbara@example.com',
    plan: 'Enterprise',
    status: 'Inactive',
  },
]

/** Filtered rows for the data-table block demo, derived from the search. */
export const filteredRows = (search: string) =>
  INITIAL_ROWS.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase()))

const TABLE_COLUMNS = [
  { key: 'name', title: 'Name' },
  { key: 'email', title: 'Email' },
  { key: 'plan', title: 'Plan', align: 'right' as const },
  { key: 'status', title: 'Status', align: 'right' as const },
]

export const dataTableView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('w-full rounded-xl border border-border')],
    [
      dataTable<AppMessage>(
        {
          columns: TABLE_COLUMNS,
          rows: filteredRows(model.tableSearch).map((row) => ({
            id: row.id,
            cells: {
              name: row.name,
              email: row.email,
              plan: row.plan,
              status: row.status,
            },
          })),
          searchValue: model.tableSearch,
          onSearchInput: (value) => Message.UpdatedTableSearch({ value }),
        },
        h,
      ),
    ],
  )

const fields = { tableSearch: S.String }

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: { tableSearch: '' },
  messages: [Message.UpdatedTableSearch],
  handlers: (model: State) => ({
    UpdatedTableSearch: ({ value }: typeof Message.UpdatedTableSearch.Type): UpdateReturn => ({
      model: evo(model, { tableSearch: () => value }),
    }),
  }),
  samples: [Message.UpdatedTableSearch({ value: 'ada' })],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/data-table

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/blocks/data-table/data-table.ts123 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

type Child = Html | string

import { button } from '@/components/ui/button'
import { input } from '@/components/ui/input'
import { cn } from '@/lib/utils'

export const tableClass = 'w-full caption-bottom text-sm'

export const tableHeaderClass = '[&_tr]:border-b [&_tr]:border-border'

export const tableBodyClass = '[&_tr:last-child]:border-0'

export const tableRowClass =
  'border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted'

export const tableHeadClass = 'h-10 px-2 text-left align-middle font-medium text-muted-foreground'

export const tableCellClass = 'p-2 align-middle'

export type DataTableColumn = Readonly<{
  key: string
  title: string
  align?: 'left' | 'right'
}>

export type DataTableRow = Readonly<{
  id: string
  cells: Readonly<Record<string, Child>>
}>

export type DataTableConfig<M> = Readonly<{
  columns: ReadonlyArray<DataTableColumn>
  rows: ReadonlyArray<DataTableRow>
  searchValue?: string
  onSearchInput?: (value: string) => M
  onRowClick?: (rowId: string) => M
  className?: string
}>

/** Data table block: search input + styled table composed from foldcn
 *  primitives. Pagination and sorting are left to the consumer's update. */
export const dataTable = <M>(config: DataTableConfig<M>, h: HtmlBuilder<M>): Html =>
  h.div(
    [h.Class('w-full')],
    [
      h.div(
        [h.Class('flex items-center justify-between gap-4 py-4')],
        [
          input<M>(
            {
              id: 'data-table-search',
              label: 'Search',
              value: config.searchValue ?? '',
              onInput: config.onSearchInput,
              placeholder: 'Filter rows...',
              wrapperClass: 'max-w-sm',
            },
            h,
          ),
          button<M>({ variant: 'outline', size: 'sm' }, 'Export', h),
        ],
      ),
      h.div(
        [h.Class('overflow-hidden rounded-md border border-border')],
        [
          h.div(
            [h.Class('relative w-full overflow-auto')],
            [
              h.table(
                [h.Class(tableClass)],
                [
                  h.thead(
                    [h.Class(tableHeaderClass)],
                    [
                      h.tr(
                        [],
                        config.columns.map((column) =>
                          h.th(
                            [
                              h.Class(
                                cn(tableHeadClass, column.align === 'right' ? 'text-right' : ''),
                              ),
                            ],
                            [column.title],
                          ),
                        ),
                      ),
                    ],
                  ),
                  h.tbody(
                    [h.Class(tableBodyClass)],
                    config.rows.map((row) =>
                      h.tr(
                        [
                          h.Class(tableRowClass),
                          ...(config.onRowClick === undefined
                            ? []
                            : [h.OnClick(config.onRowClick(row.id))]),
                        ],
                        config.columns.map((column) =>
                          h.td(
                            [
                              h.Class(
                                cn(tableCellClass, column.align === 'right' ? 'text-right' : ''),
                              ),
                            ],
                            [row.cells[column.key] ?? ''],
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  )