# Table List

Tabular data that renders as a table on desktop and a grouped list on mobile.

Table List is an open-source pattern from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/table-list

**React**

**Example**

```tsx
import {
  TableList,
  type TableListColumn,
} from "@/components/patterns/table-list"
import { cn } from "@/lib/utils"

type Order = {
  id: number
  product: string
  status: "shipped" | "delivered" | "preparing" | "returned"
  date: string
  total: number
}

const orders: Order[] = [
  {
    id: 1042,
    product: "Wireless earbuds",
    status: "shipped",
    date: "Oct 3",
    total: 249,
  },
  {
    id: 1041,
    product: "Magnetic charger",
    status: "delivered",
    date: "Sep 28",
    total: 49,
  },
  {
    id: 1040,
    product: "Phone case",
    status: "preparing",
    date: "Sep 27",
    total: 59,
  },
  {
    id: 1039,
    product: "Watch band",
    status: "returned",
    date: "Sep 20",
    total: 39,
  },
]

const statusLabel: Record<Order["status"], string> = {
  shipped: "Shipped",
  delivered: "Delivered",
  preparing: "Preparing",
  returned: "Returned",
}

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const columns: TableListColumn<Order>[] = [
  {
    key: "id",
    header: "Order",
    cell: (order) => `#${order.id}`,
    slot: "description",
  },
  {
    key: "product",
    header: "Product",
    cell: (order) => order.product,
    slot: "title",
  },
  {
    key: "status",
    header: "Status",
    cell: (order) => (
      <span
        className={cn(
          "font-semibold text-muted-foreground",
          order.status === "shipped" && "text-primary",
          order.status === "returned" && "text-destructive"
        )}
      >
        {statusLabel[order.status]}
      </span>
    ),
    mobileCell: (order) => statusLabel[order.status],
    slot: "description",
  },
  {
    key: "date",
    header: "Date",
    cell: (order) => order.date,
    slot: "description",
  },
  {
    key: "total",
    header: "Total",
    cell: (order) => currency.format(order.total),
    slot: "trailing",
    align: "end",
  },
]

export default function TableListDemo() {
  return (
    <TableList<Order>
      rows={orders}
      columns={columns}
      getKey={(order: Order) => order.id}
      title="Orders"
      className="mx-auto w-full max-w-3xl"
    />
  )
}
```

`TableList` displays rows of data from a column definition. On desktop it renders a suiss/ui `Table` inside a bordered section, with a header row and one cell per column. On mobile (below `48rem`, via `useIsMobile`), where wide tables do not fit, it renders an inset grouped list: each row shows a title line, a secondary line that joins description columns with a middle dot, and trailing values on the end.

Each column chooses its place in the mobile row through `slot`, and can provide a simpler `mobileCell`. Use it for orders, invoices, transactions and other records that people compare in columns on large screens.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/table-list
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns table-list
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

2. Add the pieces it builds on: [Table](https://ui.suiss.app/docs/components/table), `use-mobile`.

3. Copy the source into `components/patterns/table-list.tsx`:

```tsx title="components/patterns/table-list.tsx"
"use client"

import * as React from "react"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type TableListColumn<T> = {
  key: string
  header: React.ReactNode
  cell: (row: T) => React.ReactNode
  mobileCell?: (row: T) => React.ReactNode
  slot?: "title" | "description" | "trailing" | "hidden"
  align?: "start" | "end"
}

function TableList<T>({
  rows,
  columns,
  getKey = (_, index) => index,
  title,
  emptyText = "No results.",
  className,
  ...props
}: Omit<React.ComponentProps<"section">, "title"> & {
  rows: T[]
  columns: TableListColumn<T>[]
  getKey?: (row: T, index: number) => React.Key
  title?: React.ReactNode
  emptyText?: React.ReactNode
}) {
  const isMobile = useIsMobile()
  const titleId = React.useId()

  if (isMobile) {
    const titleColumn = columns.find((column) => column.slot === "title")
    const trailing = columns.filter((column) => column.slot === "trailing")
    const description = columns.filter(
      (column) => column.slot === "description"
    )
    const mobileCell = (column: TableListColumn<T>, row: T) =>
      (column.mobileCell ?? column.cell)(row)

    return (
      <section
        data-slot="table-list"
        aria-labelledby={title ? titleId : undefined}
        className={cn("flex flex-col gap-3", className)}
        {...props}
      >
        {title && (
          <h2
            id={titleId}
            data-slot="table-list-title"
            className="text-3xl font-bold tracking-tight"
          >
            {title}
          </h2>
        )}
        {rows.length === 0 ? (
          <p className="rounded-2xl bg-muted px-4 py-3 text-base text-muted-foreground">
            {emptyText}
          </p>
        ) : (
          <ul
            data-slot="table-list-items"
            className="overflow-hidden rounded-2xl bg-muted"
          >
            {rows.map((row, index) => (
              <li
                key={getKey(row, index)}
                data-slot="table-list-item"
                className="ms-4 flex min-h-11 items-center gap-4 py-2.75 pe-4 not-first:border-t not-first:border-border"
              >
                <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                  {titleColumn && (
                    <span className="truncate text-base text-foreground">
                      {mobileCell(titleColumn, row)}
                    </span>
                  )}
                  {description.length > 0 && (
                    <span className="truncate text-sm text-muted-foreground">
                      {description.map((column, position) => (
                        <React.Fragment key={column.key}>
                          {position > 0 && " · "}
                          {mobileCell(column, row)}
                        </React.Fragment>
                      ))}
                    </span>
                  )}
                </div>
                {trailing.map((column) => (
                  <span
                    key={column.key}
                    className="shrink-0 text-base text-muted-foreground tabular-nums"
                  >
                    {mobileCell(column, row)}
                  </span>
                ))}
              </li>
            ))}
          </ul>
        )}
      </section>
    )
  }

  return (
    <section
      data-slot="table-list"
      aria-labelledby={title ? titleId : undefined}
      className={cn("overflow-hidden rounded-2xl border", className)}
      {...props}
    >
      {title && (
        <h2
          id={titleId}
          data-slot="table-list-title"
          className="border-b px-6 py-4 text-base font-semibold"
        >
          {title}
        </h2>
      )}
      <div className="px-3 pb-2">
        <Table>
          <TableHeader>
            <TableRow className="hover:bg-transparent">
              {columns.map((column) => (
                <TableHead
                  key={column.key}
                  className={cn(column.align === "end" && "text-end")}
                >
                  {column.header}
                </TableHead>
              ))}
            </TableRow>
          </TableHeader>
          <TableBody>
            {rows.length === 0 ? (
              <TableRow className="hover:bg-transparent">
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center text-muted-foreground"
                >
                  {emptyText}
                </TableCell>
              </TableRow>
            ) : (
              rows.map((row, index) => (
                <TableRow key={getKey(row, index)}>
                  {columns.map((column) => (
                    <TableCell
                      key={column.key}
                      className={cn(column.align === "end" && "text-end")}
                    >
                      {column.cell(row)}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            )}
          </TableBody>
        </Table>
      </div>
    </section>
  )
}

export { TableList, type TableListColumn }
```

4. Update the import paths to match your project.

## Usage

```tsx
import { TableList } from "@/components/patterns/table-list"
```

```tsx
<TableList<Order> rows={orders} columns={columns} getKey={(order: Order) => order.id} title="Orders" className="mx-auto w-full max-w-3xl" />
```

- Each `TableListColumn` has a `key`, a `header` and a `cell` renderer. Optional fields are `mobileCell`, `slot` (`title`, `description`, `trailing` or `hidden`) and `align` (`start` or `end`).
- On mobile, the first `title` column becomes the primary line, `description` columns form the secondary line, and `trailing` columns appear at the end. Columns without a slot, or with `hidden`, are left out on mobile.
- `mobileCell` replaces `cell` on mobile, for example to drop styling that does not fit the list.
- `align: "end"` right-aligns the desktop header and cells.

## Examples

### Without title

Renders the table without a heading or region.

**Example**

```tsx
import {
  TableList,
  type TableListColumn,
} from "@/components/patterns/table-list"
import { cn } from "@/lib/utils"

type Order = {
  id: number
  product: string
  status: "shipped" | "delivered" | "preparing" | "returned"
  date: string
  total: number
}

const orders: Order[] = [
  {
    id: 1042,
    product: "Wireless earbuds",
    status: "shipped",
    date: "Oct 3",
    total: 249,
  },
  {
    id: 1041,
    product: "Magnetic charger",
    status: "delivered",
    date: "Sep 28",
    total: 49,
  },
  {
    id: 1040,
    product: "Phone case",
    status: "preparing",
    date: "Sep 27",
    total: 59,
  },
  {
    id: 1039,
    product: "Watch band",
    status: "returned",
    date: "Sep 20",
    total: 39,
  },
]

const statusLabel: Record<Order["status"], string> = {
  shipped: "Shipped",
  delivered: "Delivered",
  preparing: "Preparing",
  returned: "Returned",
}

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const columns: TableListColumn<Order>[] = [
  {
    key: "id",
    header: "Order",
    cell: (order) => `#${order.id}`,
    slot: "description",
  },
  {
    key: "product",
    header: "Product",
    cell: (order) => order.product,
    slot: "title",
  },
  {
    key: "status",
    header: "Status",
    cell: (order) => (
      <span
        className={cn(
          "font-semibold text-muted-foreground",
          order.status === "shipped" && "text-primary",
          order.status === "returned" && "text-destructive"
        )}
      >
        {statusLabel[order.status]}
      </span>
    ),
    mobileCell: (order) => statusLabel[order.status],
    slot: "description",
  },
  {
    key: "date",
    header: "Date",
    cell: (order) => order.date,
    slot: "description",
  },
  {
    key: "total",
    header: "Total",
    cell: (order) => currency.format(order.total),
    slot: "trailing",
    align: "end",
  },
]

export default function TableListWithoutTitle() {
  return (
    <TableList<Order>
      rows={orders}
      columns={columns}
      getKey={(order: Order) => order.id}
      title={undefined}
      className="mx-auto w-full max-w-3xl"
    />
  )
}
```

### Empty

Shows custom `emptyText` when there are no rows.

**Example**

```tsx
import {
  TableList,
  type TableListColumn,
} from "@/components/patterns/table-list"
import { cn } from "@/lib/utils"

type Order = {
  id: number
  product: string
  status: "shipped" | "delivered" | "preparing" | "returned"
  date: string
  total: number
}

const statusLabel: Record<Order["status"], string> = {
  shipped: "Shipped",
  delivered: "Delivered",
  preparing: "Preparing",
  returned: "Returned",
}

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const columns: TableListColumn<Order>[] = [
  {
    key: "id",
    header: "Order",
    cell: (order) => `#${order.id}`,
    slot: "description",
  },
  {
    key: "product",
    header: "Product",
    cell: (order) => order.product,
    slot: "title",
  },
  {
    key: "status",
    header: "Status",
    cell: (order) => (
      <span
        className={cn(
          "font-semibold text-muted-foreground",
          order.status === "shipped" && "text-primary",
          order.status === "returned" && "text-destructive"
        )}
      >
        {statusLabel[order.status]}
      </span>
    ),
    mobileCell: (order) => statusLabel[order.status],
    slot: "description",
  },
  {
    key: "date",
    header: "Date",
    cell: (order) => order.date,
    slot: "description",
  },
  {
    key: "total",
    header: "Total",
    cell: (order) => currency.format(order.total),
    slot: "trailing",
    align: "end",
  },
]

export default function TableListEmpty() {
  return (
    <TableList<Order>
      rows={[]}
      columns={columns}
      getKey={(order: Order) => order.id}
      title="Orders"
      className="mx-auto w-full max-w-3xl"
      emptyText="No orders yet."
    />
  )
}
```

## API Reference

### TableList

The section that renders the rows as a table or a grouped list.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rows` | `T[]` | Required | The data rows to display. |
| `columns` | `TableListColumn<T>[]` | Required | The column definitions that render each row. |
| `getKey` | `(row: T, index: number) => React.Key` | `(_, index) => index` | Returns the React key for a row. |
| `title` | `React.ReactNode` | - | A heading above the data that also names the section. |
| `emptyText` | `React.ReactNode` | `"No results."` | Content shown when `rows` is empty. |

Also accepts the props of `React.ComponentProps<"section">`.

## Accessibility

- With a `title`, the section is labelled by its heading and exposed as a region.
- On desktop the data is a real table with column headers; an empty state is a single cell that spans every column.
- On mobile the rows are list items in a `ul`.

## Notes

Styling hooks: `data-slot="table-list"` and `table-list-title`; on mobile also `table-list-items` and `table-list-item`. The `TableListColumn` type is exported.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/patterns-table-list--default.light.webp)

```tsx
import { View } from "react-native"
import { TableList, type TableListColumn } from "@/components/patterns/table-list"

type Invoice = {
  id: string
  customer: string
  status: string
  method: string
  amount: string
}

const invoices: Invoice[] = [
  {
    id: "INV001",
    customer: "Olivia Martin",
    status: "Paid",
    method: "Card",
    amount: "$250.00",
  },
  {
    id: "INV002",
    customer: "Jackson Lee",
    status: "Pending",
    method: "PayPal",
    amount: "$150.00",
  },
  {
    id: "INV003",
    customer: "Isabella Nguyen",
    status: "Unpaid",
    method: "Transfer",
    amount: "$350.00",
  },
  {
    id: "INV004",
    customer: "William Kim",
    status: "Paid",
    method: "Card",
    amount: "$450.00",
  },
]

const columns: TableListColumn<Invoice>[] = [
  {
    key: "customer",
    header: "Customer",
    cell: (row) => row.customer,
    slot: "title",
  },
  {
    key: "status",
    header: "Status",
    cell: (row) => row.status,
    slot: "description",
  },
  {
    key: "method",
    header: "Method",
    cell: (row) => row.method,
    slot: "description",
  },
  {
    key: "amount",
    header: "Amount",
    cell: (row) => row.amount,
    slot: "trailing",
    align: "end",
  },
]

export function TableListExample() {
  return (
    <View className="gap-8">
      <TableList
        title="Invoices"
        rows={invoices}
        columns={columns}
        getKey={(row) => row.id}
        onRowPress={() => undefined}
      />
      <TableList rows={[]} columns={columns} emptyText="No invoices yet." />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct TableListExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 32) {
      TableList(tableListInvoices, columns: tableListColumns, title: "Invoices", action: { _ in })
      TableList([TableListInvoice](), columns: tableListColumns, emptyText: "No invoices yet.")
    }
  }
}

struct TableListInvoice: Identifiable {
  let id: String
  let customer: String
  let status: String
  let method: String
  let amount: String
}

let tableListInvoices = [
  TableListInvoice(id: "INV001", customer: "Olivia Martin", status: "Paid", method: "Card", amount: "$250.00"),
  TableListInvoice(id: "INV002", customer: "Jackson Lee", status: "Pending", method: "PayPal", amount: "$150.00"),
  TableListInvoice(id: "INV003", customer: "Isabella Nguyen", status: "Unpaid", method: "Transfer", amount: "$350.00"),
  TableListInvoice(id: "INV004", customer: "William Kim", status: "Paid", method: "Card", amount: "$450.00"),
]

let tableListColumns: [TableListColumn<TableListInvoice>] = [
  TableListColumn("customer", header: "Customer", slot: .title) { Text(verbatim: $0.customer) },
  TableListColumn("status", header: "Status", slot: .description) { Text(verbatim: $0.status) },
  TableListColumn("method", header: "Method", slot: .description) { Text(verbatim: $0.method) },
  TableListColumn("amount", header: "Amount", slot: .trailing) { Text(verbatim: $0.amount) },
]
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.patterns.TableList
import com.esuiss.ui.patterns.TableListAlign
import com.esuiss.ui.patterns.TableListColumn
import com.esuiss.ui.patterns.TableListSlot

class TableListInvoice(
    val id: String,
    val customer: String,
    val status: String,
    val method: String,
    val amount: String,
)

val tableListInvoices = listOf(
    TableListInvoice("INV001", "Olivia Martin", "Paid", "Card", "$250.00"),
    TableListInvoice("INV002", "Jackson Lee", "Pending", "PayPal", "$150.00"),
    TableListInvoice("INV003", "Isabella Nguyen", "Unpaid", "Transfer", "$350.00"),
    TableListInvoice("INV004", "William Kim", "Paid", "Card", "$450.00"),
)

val tableListColumns = listOf(
    TableListColumn<TableListInvoice>("customer", "Customer", slot = TableListSlot.Title) { Text(it.customer, maxLines = 1) },
    TableListColumn<TableListInvoice>("status", "Status", slot = TableListSlot.Description) { Text(it.status, maxLines = 1) },
    TableListColumn<TableListInvoice>("method", "Method", slot = TableListSlot.Description) { Text(it.method, maxLines = 1) },
    TableListColumn<TableListInvoice>("amount", "Amount", slot = TableListSlot.Trailing, align = TableListAlign.End) {
        Text(it.amount, maxLines = 1)
    },
)

@Composable
fun TableListDefault() {
    Column(verticalArrangement = Arrangement.spacedBy(32.dp)) {
        TableList(
            rows = tableListInvoices,
            columns = tableListColumns,
            getKey = { row, _ -> row.id },
            title = "Invoices",
            onRowClick = {},
        )
        TableList(rows = emptyList(), columns = tableListColumns, emptyText = "No invoices yet.")
    }
}
```

`TableList` takes `rows` and a `columns` definition, and renders each row as a `TableListRow` inside one rounded group. Each column has a `slot` that decides where its cell goes: the row title, the description line, the trailing side, or nowhere.

The row parts are also exported, so you can build the same grouped look inside a `FlatList` for long or virtualized lists.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/table-list
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add table-list
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add table-list --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add table-list --platform kotlin
```

The CLI also adds what it builds on: [Item](https://ui.suiss.app/docs/components/item), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Item](https://ui.suiss.app/docs/components/item), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/patterns/table-list.tsx`:

```tsx title="uniwind/components/patterns/table-list.tsx"
import { cn } from "cn"
import * as React from "react"
import { View, type ViewProps } from "react-native"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemTitle,
} from "@/components/ui/item"
import { Text } from "@/components/ui/text"

type TableListColumn<T> = {
  key: string
  header: React.ReactNode
  cell: (row: T) => React.ReactNode
  mobileCell?: (row: T) => React.ReactNode
  slot?: "title" | "description" | "trailing" | "hidden"
  align?: "start" | "end"
}

function isText(node: React.ReactNode) {
  return typeof node === "string" || typeof node === "number"
}

function TableListTitle({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      role="heading"
      className={cn("text-3xl font-bold text-foreground", className)}
      {...props}
    />
  )
}

function TableListHeader({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      role="heading"
      className={cn(
        "px-4 pt-4 pb-1.5 text-sm text-muted-foreground uppercase",
        className
      )}
      {...props}
    />
  )
}

function TableListFooter({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      className={cn(
        "px-4 pt-1.5 pb-2 text-sm text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function TableListSeparator({ className, ...props }: ViewProps) {
  return (
    <View
      accessible={false}
      importantForAccessibility="no"
      className={cn("bg-muted", className)}
      {...props}
    >
      <View className="ms-4 h-px bg-border" />
    </View>
  )
}

function TableListRow({
  title,
  description,
  trailing,
  first = false,
  last = false,
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof Item>, "children"> & {
  title?: React.ReactNode
  description?: React.ReactNode
  trailing?: React.ReactNode
  first?: boolean
  last?: boolean
  children?: React.ReactNode
}) {
  return (
    <Item
      size="sm"
      className={cn(
        "min-h-11 flex-nowrap gap-4 rounded-none bg-muted px-4 py-2.75 active:opacity-80",
        first && "rounded-t-2xl",
        last && "rounded-b-2xl",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <ItemContent className="gap-0.5">
            {title != null ? (
              <ItemTitle className="font-normal">{title}</ItemTitle>
            ) : null}
            {description != null ? (
              <ItemDescription numberOfLines={1}>{description}</ItemDescription>
            ) : null}
          </ItemContent>
          {trailing != null ? (
            <ItemActions>
              {isText(trailing) ? (
                <Text className="text-base text-muted-foreground tabular-nums">
                  {trailing}
                </Text>
              ) : (
                trailing
              )}
            </ItemActions>
          ) : null}
        </>
      )}
    </Item>
  )
}

function TableList<T>({
  rows,
  columns,
  getKey = (_, index) => index,
  title,
  emptyText = "No results.",
  onRowPress,
  className,
  ...props
}: ViewProps & {
  rows: T[]
  columns: TableListColumn<T>[]
  getKey?: (row: T, index: number) => React.Key
  title?: React.ReactNode
  emptyText?: React.ReactNode
  onRowPress?: (row: T, index: number) => void
}) {
  const titleColumn = columns.find((column) => column.slot === "title")
  const trailing = columns.filter((column) => column.slot === "trailing")
  const description = columns.filter((column) => column.slot === "description")
  const mobileCell = (column: TableListColumn<T>, row: T) =>
    (column.mobileCell ?? column.cell)(row)

  return (
    <View className={cn("flex-col gap-3", className)} {...props}>
      {title != null ? <TableListTitle>{title}</TableListTitle> : null}
      {rows.length === 0 ? (
        <View className="rounded-2xl bg-muted px-4 py-3">
          {isText(emptyText) ? (
            <Text className="text-base text-muted-foreground">{emptyText}</Text>
          ) : (
            emptyText
          )}
        </View>
      ) : (
        <View role="list" className="overflow-hidden rounded-2xl">
          {rows.map((row, index) => (
            <React.Fragment key={getKey(row, index)}>
              {index > 0 ? <TableListSeparator /> : null}
              <TableListRow
                role={onRowPress ? "button" : "listitem"}
                onPress={onRowPress ? () => onRowPress(row, index) : undefined}
                title={titleColumn ? mobileCell(titleColumn, row) : undefined}
                description={
                  description.length > 0
                    ? description.map((column, position) => (
                        <React.Fragment key={column.key}>
                          {position > 0 ? " · " : null}
                          {mobileCell(column, row)}
                        </React.Fragment>
                      ))
                    : undefined
                }
                trailing={
                  trailing.length > 0
                    ? trailing.map((column) => {
                        const cell = mobileCell(column, row)
                        return isText(cell) ? (
                          <Text
                            key={column.key}
                            className="shrink-0 text-base text-muted-foreground tabular-nums"
                          >
                            {cell}
                          </Text>
                        ) : (
                          <React.Fragment key={column.key}>
                            {cell}
                          </React.Fragment>
                        )
                      })
                    : undefined
                }
              />
            </React.Fragment>
          ))}
        </View>
      )}
    </View>
  )
}

export {
  TableList,
  type TableListColumn,
  TableListFooter,
  TableListHeader,
  TableListRow,
  TableListSeparator,
  TableListTitle,
}
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Item](https://ui.suiss.app/docs/components/item), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/patterns/table-list.tsx`:

```tsx title="nativewind/components/patterns/table-list.tsx"
import { cn } from "cn"
import * as React from "react"
import { View, type ViewProps } from "react-native"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemTitle,
} from "@/components/ui/item"
import { Text } from "@/components/ui/text"

type TableListColumn<T> = {
  key: string
  header: React.ReactNode
  cell: (row: T) => React.ReactNode
  mobileCell?: (row: T) => React.ReactNode
  slot?: "title" | "description" | "trailing" | "hidden"
  align?: "start" | "end"
}

function isText(node: React.ReactNode) {
  return typeof node === "string" || typeof node === "number"
}

function TableListTitle({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      role="heading"
      className={cn("text-3xl font-bold text-foreground", className)}
      {...props}
    />
  )
}

function TableListHeader({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      role="heading"
      className={cn(
        "px-4 pt-4 pb-1.5 text-sm text-muted-foreground uppercase",
        className
      )}
      {...props}
    />
  )
}

function TableListFooter({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      className={cn(
        "px-4 pt-1.5 pb-2 text-sm text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function TableListSeparator({ className, ...props }: ViewProps) {
  return (
    <View
      accessible={false}
      importantForAccessibility="no"
      className={cn("bg-muted", className)}
      {...props}
    >
      <View className="ms-4 h-px bg-border" />
    </View>
  )
}

function TableListRow({
  title,
  description,
  trailing,
  first = false,
  last = false,
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof Item>, "children"> & {
  title?: React.ReactNode
  description?: React.ReactNode
  trailing?: React.ReactNode
  first?: boolean
  last?: boolean
  children?: React.ReactNode
}) {
  return (
    <Item
      size="sm"
      className={cn(
        "min-h-11 flex-nowrap gap-4 rounded-none bg-muted px-4 py-[0.6875rem] active:opacity-80",
        first && "rounded-t-2xl",
        last && "rounded-b-2xl",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <ItemContent className="gap-0.5">
            {title != null ? (
              <ItemTitle className="font-normal">{title}</ItemTitle>
            ) : null}
            {description != null ? (
              <ItemDescription numberOfLines={1}>{description}</ItemDescription>
            ) : null}
          </ItemContent>
          {trailing != null ? (
            <ItemActions>
              {isText(trailing) ? (
                <Text className="text-base text-muted-foreground tabular-nums">
                  {trailing}
                </Text>
              ) : (
                trailing
              )}
            </ItemActions>
          ) : null}
        </>
      )}
    </Item>
  )
}

function TableList<T>({
  rows,
  columns,
  getKey = (_, index) => index,
  title,
  emptyText = "No results.",
  onRowPress,
  className,
  ...props
}: ViewProps & {
  rows: T[]
  columns: TableListColumn<T>[]
  getKey?: (row: T, index: number) => React.Key
  title?: React.ReactNode
  emptyText?: React.ReactNode
  onRowPress?: (row: T, index: number) => void
}) {
  const titleColumn = columns.find((column) => column.slot === "title")
  const trailing = columns.filter((column) => column.slot === "trailing")
  const description = columns.filter((column) => column.slot === "description")
  const mobileCell = (column: TableListColumn<T>, row: T) =>
    (column.mobileCell ?? column.cell)(row)

  return (
    <View className={cn("flex-col gap-3", className)} {...props}>
      {title != null ? <TableListTitle>{title}</TableListTitle> : null}
      {rows.length === 0 ? (
        <View className="rounded-2xl bg-muted px-4 py-3">
          {isText(emptyText) ? (
            <Text className="text-base text-muted-foreground">{emptyText}</Text>
          ) : (
            emptyText
          )}
        </View>
      ) : (
        <View role="list" className="overflow-hidden rounded-2xl">
          {rows.map((row, index) => (
            <React.Fragment key={getKey(row, index)}>
              {index > 0 ? <TableListSeparator /> : null}
              <TableListRow
                role={onRowPress ? "button" : "listitem"}
                onPress={onRowPress ? () => onRowPress(row, index) : undefined}
                title={titleColumn ? mobileCell(titleColumn, row) : undefined}
                description={
                  description.length > 0
                    ? description.map((column, position) => (
                        <React.Fragment key={column.key}>
                          {position > 0 ? " · " : null}
                          {mobileCell(column, row)}
                        </React.Fragment>
                      ))
                    : undefined
                }
                trailing={
                  trailing.length > 0
                    ? trailing.map((column) => {
                        const cell = mobileCell(column, row)
                        return isText(cell) ? (
                          <Text
                            key={column.key}
                            className="shrink-0 text-base text-muted-foreground tabular-nums"
                          >
                            {cell}
                          </Text>
                        ) : (
                          <React.Fragment key={column.key}>
                            {cell}
                          </React.Fragment>
                        )
                      })
                    : undefined
                }
              />
            </React.Fragment>
          ))}
        </View>
      )}
    </View>
  )
}

export {
  TableList,
  type TableListColumn,
  TableListFooter,
  TableListHeader,
  TableListRow,
  TableListSeparator,
  TableListTitle,
}
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: [Item](https://ui.suiss.app/docs/components/item), [Text](https://ui.suiss.app/docs/components/text).

2. Copy the source into `components/patterns/table-list.tsx`:

```tsx title="stylesheet/components/patterns/table-list.tsx"
import * as React from "react"
import { StyleSheet, View, type ViewProps } from "react-native"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemTitle,
} from "@/components/ui/item"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type TableListColumn<T> = {
  key: string
  header: React.ReactNode
  cell: (row: T) => React.ReactNode
  mobileCell?: (row: T) => React.ReactNode
  slot?: "title" | "description" | "trailing" | "hidden"
  align?: "start" | "end"
}

const styles = StyleSheet.create({
  root: { flexDirection: "column", gap: 12 },
  title: { ...fontSize["3xl"], fontWeight: "700" },
  header: {
    ...fontSize.sm,
    paddingHorizontal: 16,
    paddingTop: 16,
    paddingBottom: 6,
    textTransform: "uppercase",
  },
  footer: {
    ...fontSize.sm,
    paddingHorizontal: 16,
    paddingTop: 6,
    paddingBottom: 8,
  },
  separator: { marginStart: 16, height: 1 },
  row: {
    minHeight: 44,
    flexWrap: "nowrap",
    gap: 16,
    borderRadius: 0,
    paddingHorizontal: 16,
    paddingVertical: 11,
  },
  first: {
    borderTopLeftRadius: radius["2xl"],
    borderTopRightRadius: radius["2xl"],
  },
  last: {
    borderBottomLeftRadius: radius["2xl"],
    borderBottomRightRadius: radius["2xl"],
  },
  rowContent: { gap: 2 },
  trailing: {
    ...fontSize.base,
    flexShrink: 0,
    fontVariant: ["tabular-nums"],
  },
  empty: {
    borderRadius: radius["2xl"],
    paddingHorizontal: 16,
    paddingVertical: 12,
  },
  list: { overflow: "hidden", borderRadius: radius["2xl"] },
})

function isText(node: React.ReactNode) {
  return typeof node === "string" || typeof node === "number"
}

function TableListTitle({
  style,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { colors } = useTheme()
  return (
    <Text
      role="heading"
      style={[styles.title, { color: colors.foreground }, style]}
      {...props}
    />
  )
}

function TableListHeader({
  style,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { colors } = useTheme()
  return (
    <Text
      role="heading"
      style={[styles.header, { color: colors.mutedForeground }, style]}
      {...props}
    />
  )
}

function TableListFooter({
  style,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { colors } = useTheme()
  return (
    <Text
      style={[styles.footer, { color: colors.mutedForeground }, style]}
      {...props}
    />
  )
}

function TableListSeparator({ style, ...props }: ViewProps) {
  const { colors } = useTheme()
  return (
    <View
      accessible={false}
      importantForAccessibility="no"
      style={[{ backgroundColor: colors.muted }, style]}
      {...props}
    >
      <View style={[styles.separator, { backgroundColor: colors.border }]} />
    </View>
  )
}

function TableListRow({
  title,
  description,
  trailing,
  first = false,
  last = false,
  style,
  children,
  ...props
}: Omit<React.ComponentProps<typeof Item>, "children"> & {
  title?: React.ReactNode
  description?: React.ReactNode
  trailing?: React.ReactNode
  first?: boolean
  last?: boolean
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  return (
    <Item
      size="sm"
      style={(state) => [
        styles.row,
        { backgroundColor: colors.muted },
        first && styles.first,
        last && styles.last,
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    >
      {children ?? (
        <>
          <ItemContent style={styles.rowContent}>
            {title != null ? (
              <ItemTitle style={{ fontWeight: "400" }}>{title}</ItemTitle>
            ) : null}
            {description != null ? (
              <ItemDescription numberOfLines={1}>{description}</ItemDescription>
            ) : null}
          </ItemContent>
          {trailing != null ? (
            <ItemActions>
              {isText(trailing) ? (
                <Text
                  style={[styles.trailing, { color: colors.mutedForeground }]}
                >
                  {trailing}
                </Text>
              ) : (
                trailing
              )}
            </ItemActions>
          ) : null}
        </>
      )}
    </Item>
  )
}

function TableList<T>({
  rows,
  columns,
  getKey = (_, index) => index,
  title,
  emptyText = "No results.",
  onRowPress,
  style,
  ...props
}: ViewProps & {
  rows: T[]
  columns: TableListColumn<T>[]
  getKey?: (row: T, index: number) => React.Key
  title?: React.ReactNode
  emptyText?: React.ReactNode
  onRowPress?: (row: T, index: number) => void
}) {
  const { colors } = useTheme()
  const titleColumn = columns.find((column) => column.slot === "title")
  const trailing = columns.filter((column) => column.slot === "trailing")
  const description = columns.filter((column) => column.slot === "description")
  const mobileCell = (column: TableListColumn<T>, row: T) =>
    (column.mobileCell ?? column.cell)(row)

  return (
    <View style={[styles.root, style]} {...props}>
      {title != null ? <TableListTitle>{title}</TableListTitle> : null}
      {rows.length === 0 ? (
        <View style={[styles.empty, { backgroundColor: colors.muted }]}>
          {isText(emptyText) ? (
            <Text style={{ ...fontSize.base, color: colors.mutedForeground }}>
              {emptyText}
            </Text>
          ) : (
            emptyText
          )}
        </View>
      ) : (
        <View role="list" style={styles.list}>
          {rows.map((row, index) => (
            <React.Fragment key={getKey(row, index)}>
              {index > 0 ? <TableListSeparator /> : null}
              <TableListRow
                role={onRowPress ? "button" : "listitem"}
                onPress={onRowPress ? () => onRowPress(row, index) : undefined}
                title={titleColumn ? mobileCell(titleColumn, row) : undefined}
                description={
                  description.length > 0
                    ? description.map((column, position) => (
                        <React.Fragment key={column.key}>
                          {position > 0 ? " · " : null}
                          {mobileCell(column, row)}
                        </React.Fragment>
                      ))
                    : undefined
                }
                trailing={
                  trailing.length > 0
                    ? trailing.map((column) => {
                        const cell = mobileCell(column, row)
                        return isText(cell) ? (
                          <Text
                            key={column.key}
                            style={[
                              styles.trailing,
                              { color: colors.mutedForeground },
                            ]}
                          >
                            {cell}
                          </Text>
                        ) : (
                          <React.Fragment key={column.key}>
                            {cell}
                          </React.Fragment>
                        )
                      })
                    : undefined
                }
              />
            </React.Fragment>
          ))}
        </View>
      )}
    </View>
  )
}

export {
  TableList,
  type TableListColumn,
  TableListFooter,
  TableListHeader,
  TableListRow,
  TableListSeparator,
  TableListTitle,
}
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

3. Copy the source and every file it uses:

```swift title="swift/Patterns/TableList.swift"
import SwiftUI

public enum TableListSlot: CaseIterable, Sendable {
  case title, description, trailing, hidden
}

public struct TableListColumn<Row> {
  public let key: String
  public let header: LocalizedStringKey
  public let slot: TableListSlot
  public let cell: (Row) -> Text

  public init(
    _ key: String,
    header: LocalizedStringKey,
    slot: TableListSlot = .hidden,
    cell: @escaping (Row) -> Text
  ) {
    self.key = key
    self.header = header
    self.slot = slot
    self.cell = cell
  }
}

public struct TableList<Row, ID: Hashable>: View {
  let rows: [Row]
  let id: (Row) -> ID
  let columns: [TableListColumn<Row>]
  let title: LocalizedStringKey?
  let emptyText: LocalizedStringKey
  let action: ((Row) -> Void)?

  public init(
    _ rows: [Row],
    id: KeyPath<Row, ID>,
    columns: [TableListColumn<Row>],
    title: LocalizedStringKey? = nil,
    emptyText: LocalizedStringKey = "No results.",
    action: ((Row) -> Void)? = nil
  ) {
    self.rows = rows
    self.id = { $0[keyPath: id] }
    self.columns = columns
    self.title = title
    self.emptyText = emptyText
    self.action = action
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      if let title {
        Text(title)
          .scaledTextStyle(.xl3, weight: .bold)
          .foregroundStyle(EsuissColor.foreground)
          .accessibilityAddTraits(.isHeader)
      }
      if rows.isEmpty {
        Text(emptyText)
          .scaledTextStyle(.base)
          .foregroundStyle(EsuissColor.mutedForeground)
          .padding(.horizontal, 16)
          .padding(.vertical, 12)
          .frame(maxWidth: .infinity, alignment: .leading)
          .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
      } else {
        TableListGroup {
          ForEach(rows.map { TableListEntry(id: id($0), row: $0) }) { entry in
            TableListRow(action: action.map { action in { action(entry.row) } }) {
              cells(entry.row)
            }
          }
        }
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .tableListRowInsets()
  }

  @ViewBuilder private func cells(_ row: Row) -> some View {
    let titleColumn = columns.first { $0.slot == .title }
    let descriptions = columns.filter { $0.slot == .description }
    let trailing = columns.filter { $0.slot == .trailing }
    VStack(alignment: .leading, spacing: 2) {
      if let titleColumn {
        titleColumn.cell(row)
          .scaledTextStyle(.base)
          .foregroundStyle(EsuissColor.foreground)
          .lineLimit(1)
      }
      if !descriptions.isEmpty {
        descriptions.enumerated().reduce(Text(verbatim: "")) { text, entry in
          entry.offset == 0 ? text + entry.element.cell(row) : text + Text(verbatim: " \u{00B7} ") + entry.element.cell(row)
        }
        .scaledTextStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .lineLimit(1)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    ForEach(trailing, id: \.key) { column in
      column.cell(row)
        .scaledTextStyle(.base)
        .monospacedDigit()
        .foregroundStyle(EsuissColor.mutedForeground)
        .lineLimit(1)
        .fixedSize()
    }
  }
}

extension TableList where Row: Identifiable, ID == Row.ID {
  public init(
    _ rows: [Row],
    columns: [TableListColumn<Row>],
    title: LocalizedStringKey? = nil,
    emptyText: LocalizedStringKey = "No results.",
    action: ((Row) -> Void)? = nil
  ) {
    self.init(rows, id: \.id, columns: columns, title: title, emptyText: emptyText, action: action)
  }
}

struct TableListEntry<Row, ID: Hashable>: Identifiable {
  let id: ID
  let row: Row
}

struct TableListRowKey: PreferenceKey {
  static var defaultValue: [Anchor<CGRect>] { [] }

  static func reduce(value: inout [Anchor<CGRect>], nextValue: () -> [Anchor<CGRect>]) {
    value.append(contentsOf: nextValue())
  }
}

public struct TableListGroup<Content: View>: View {
  let content: Content

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 0) {
      content
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .overlayPreferenceValue(TableListRowKey.self) { anchors in
      GeometryReader { proxy in
        let rects = anchors.map { proxy[$0] }.sorted { $0.minY < $1.minY }
        ForEach(Array(rects.dropFirst().enumerated()), id: \.offset) { _, rect in
          Rectangle()
            .fill(EsuissColor.border)
            .frame(width: max(rect.width - 16, 0), height: 1)
            .offset(x: rect.minX + 16, y: rect.minY)
        }
      }
      .allowsHitTesting(false)
      .accessibilityHidden(true)
    }
    .transformPreference(TableListRowKey.self) { $0 = [] }
    .background(EsuissColor.muted, in: shape)
    .clipShape(shape)
    .environment(\.itemGroupInset, true)
    .accessibilityElement(children: .contain)
  }
}

public struct TableListRow<Content: View>: View {
  let action: (() -> Void)?
  let content: Content

  public init(action: (() -> Void)? = nil, @ViewBuilder content: () -> Content) {
    self.action = action
    self.content = content()
  }

  public var body: some View {
    Group {
      if let action {
        Button(action: action) { row }
          .buttonStyle(TableListRowStyle())
      } else {
        row
      }
    }
    .anchorPreference(key: TableListRowKey.self, value: .bounds) { [$0] }
  }

  private var row: some View {
    HStack(spacing: 16) {
      content
    }
    .padding(.vertical, 11)
    .padding(.trailing, 16)
    .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
    .padding(.leading, 16)
    .contentShape(Rectangle())
    .accessibilityElement(children: .combine)
  }
}

private struct TableListRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .foregroundStyle(EsuissColor.foreground)
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

public struct TableListSection<Content: View>: View {
  let header: LocalizedStringKey?
  let footer: LocalizedStringKey?
  let content: Content

  public init(
    header: LocalizedStringKey? = nil,
    footer: LocalizedStringKey? = nil,
    @ViewBuilder content: () -> Content
  ) {
    self.header = header
    self.footer = footer
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      if let header {
        Text(header)
          .textCase(.uppercase)
          .scaledTextStyle(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
          .padding(.horizontal, 16)
          .accessibilityAddTraits(.isHeader)
      }
      TableListGroup { content }
      if let footer {
        Text(footer)
          .scaledTextStyle(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
          .fixedSize(horizontal: false, vertical: true)
          .padding(.horizontal, 16)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .tableListRowInsets()
  }
}

extension View {
  func tableListRowInsets() -> some View {
    listRowInsets(EdgeInsets())
      .listRowBackground(Color.clear)
      .listRowSeparator(.hidden)
  }
}
```

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

4. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/patterns/TableList.kt"
package com.esuiss.ui.patterns

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemFrame
import com.esuiss.ui.components.ItemSize
import com.esuiss.ui.components.ItemVariant
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

enum class TableListSlot { Title, Description, Trailing, Hidden }

enum class TableListAlign { Start, End }

class TableListColumn<T>(
    val key: String,
    val header: String,
    val slot: TableListSlot? = null,
    val align: TableListAlign = TableListAlign.Start,
    val mobileCell: (@Composable (T) -> Unit)? = null,
    val cell: @Composable (T) -> Unit,
)

@Composable
fun <T> TableList(
    rows: List<T>,
    columns: List<TableListColumn<T>>,
    modifier: Modifier = Modifier,
    getKey: (T, Int) -> Any = { _, index -> index },
    title: String? = null,
    emptyText: String = "No results.",
    onRowClick: ((T) -> Unit)? = null,
) {
    val colors = EsuissTheme.colors
    val titleColumn = columns.firstOrNull { it.slot == TableListSlot.Title }
    val description = columns.filter { it.slot == TableListSlot.Description }
    val trailing = columns.filter { it.slot == TableListSlot.Trailing }
    Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        if (title != null) TableListTitle(title)
        if (rows.isEmpty()) {
            val shape = RoundedCornerShape(Radius.xl2)
            BasicText(
                emptyText,
                modifier = Modifier
                    .fillMaxWidth()
                    .clip(shape)
                    .background(colors.muted, shape)
                    .padding(horizontal = 16.dp, vertical = 12.dp),
                style = Typography.base.copy(color = colors.mutedForeground),
            )
        } else {
            Column(modifier = Modifier.fillMaxWidth()) {
                rows.forEachIndexed { index, row ->
                    key(getKey(row, index)) {
                        TableListRow(
                            first = index == 0,
                            last = index == rows.lastIndex,
                            onClick = onRowClick?.let { { it(row) } },
                        ) {
                            TableListCells(row, titleColumn, description, trailing)
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun <T> TableListCells(
    row: T,
    titleColumn: TableListColumn<T>?,
    description: List<TableListColumn<T>>,
    trailing: List<TableListColumn<T>>,
) {
    val colors = EsuissTheme.colors
    val cell: (TableListColumn<T>) -> @Composable (T) -> Unit = { it.mobileCell ?: it.cell }
    ItemContent {
        if (titleColumn != null) {
            CompositionLocalProvider(LocalContentStyle provides Typography.base.copy(color = colors.foreground)) {
                cell(titleColumn)(row)
            }
        }
        if (description.isNotEmpty()) {
            CompositionLocalProvider(
                LocalContentStyle provides Typography.sm.copy(color = colors.mutedForeground),
                LocalContentColor provides colors.mutedForeground,
            ) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    description.forEachIndexed { position, column ->
                        if (position > 0) BasicText(" · ", style = LocalContentStyle.current, maxLines = 1)
                        cell(column)(row)
                    }
                }
            }
        }
    }
    trailing.forEach { column ->
        CompositionLocalProvider(
            LocalContentStyle provides Typography.base.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"),
            LocalContentColor provides colors.mutedForeground,
        ) {
            cell(column)(row)
        }
    }
}

@Composable
fun TableListTitle(text: String, modifier: Modifier = Modifier) {
    BasicText(
        text,
        modifier = modifier.semantics { heading() },
        style = Typography.xl3.copy(color = EsuissTheme.colors.foreground, fontWeight = FontWeight.Bold),
    )
}

@Composable
fun TableListHeader(text: String, modifier: Modifier = Modifier) {
    BasicText(
        text,
        modifier = modifier
            .fillMaxWidth()
            .semantics { heading() }
            .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
        style = Typography.xs.copy(color = EsuissTheme.colors.mutedForeground),
    )
}

@Composable
fun TableListFooter(text: String, modifier: Modifier = Modifier) {
    BasicText(
        text,
        modifier = modifier
            .fillMaxWidth()
            .padding(start = 16.dp, end = 16.dp, top = 8.dp),
        style = Typography.xs.copy(color = EsuissTheme.colors.mutedForeground),
    )
}

@Composable
fun TableListRow(
    first: Boolean,
    last: Boolean,
    modifier: Modifier = Modifier,
    onClick: (() -> Unit)? = null,
    onLongClick: (() -> Unit)? = null,
    enabled: Boolean = true,
    selected: Boolean? = null,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val radius = Radius.xl2
    val shape = when {
        first && last -> RoundedCornerShape(radius)
        first -> RoundedCornerShape(topStart = radius, topEnd = radius)
        last -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
        else -> RectangleShape
    }
    val separator = colors.border
    ItemFrame(
        modifier = modifier
            .clip(shape)
            .background(colors.muted, shape)
            .drawWithContent {
                drawContent()
                if (!first) {
                    val inset = 16.dp.toPx()
                    val start = if (layoutDirection == LayoutDirection.Rtl) 0f else inset
                    drawRect(separator, Offset(start, 0f), Size(size.width - inset, 1.dp.toPx()))
                }
            },
        variant = ItemVariant.Default,
        size = ItemSize.Default,
        shape = shape,
        padding = PaddingValues(horizontal = 16.dp, vertical = 11.dp),
        onClick = onClick,
        onLongClick = onLongClick,
        enabled = enabled,
        selected = selected,
        role = Role.Button,
        onClickLabel = null,
        minHeight = 44.dp,
        gap = 16.dp,
        content = content,
    )
}

@Composable
fun TableListRowText(
    title: String,
    description: String? = null,
    trailing: String? = null,
) {
    val colors = EsuissTheme.colors
    ItemContent {
        BasicText(
            title,
            style = Typography.base.copy(color = LocalContentColor.current),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        if (description != null) {
            BasicText(
                description,
                style = Typography.sm.copy(color = colors.mutedForeground),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
    if (trailing != null) {
        BasicText(
            trailing,
            style = Typography.base.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"),
            maxLines = 1,
        )
    }
}

class TableListSectionScope internal constructor() {
    internal val rows = mutableListOf<TableListEntry>()

    fun item(
        key: Any? = null,
        onClick: (() -> Unit)? = null,
        content: @Composable () -> Unit,
    ) {
        rows += TableListEntry(key, onClick, content)
    }

    fun row(
        title: String,
        description: String? = null,
        trailing: String? = null,
        key: Any? = null,
        onClick: (() -> Unit)? = null,
    ) {
        item(key, onClick) { TableListRowText(title, description, trailing) }
    }

    fun <T> items(
        items: List<T>,
        key: ((T) -> Any)? = null,
        onClick: ((T) -> Unit)? = null,
        content: @Composable (T) -> Unit,
    ) {
        items.forEach { value ->
            item(key?.invoke(value), onClick?.let { { it(value) } }) { content(value) }
        }
    }
}

internal class TableListEntry(
    val key: Any?,
    val onClick: (() -> Unit)?,
    val content: @Composable () -> Unit,
)

private sealed interface TableListBlock {
    class Title(val text: String) : TableListBlock
    class Section(
        val header: String?,
        val footer: String?,
        val emptyText: String?,
        val rows: List<TableListEntry>,
    ) : TableListBlock
}

class TableListScope internal constructor() {
    private val blocks = mutableListOf<TableListBlock>()

    fun title(text: String) {
        blocks += TableListBlock.Title(text)
    }

    fun section(
        header: String? = null,
        footer: String? = null,
        emptyText: String? = null,
        content: TableListSectionScope.() -> Unit,
    ) {
        val scope = TableListSectionScope().apply(content)
        blocks += TableListBlock.Section(header, footer, emptyText, scope.rows.toList())
    }

    internal fun emit(lazy: LazyListScope, prefix: String) {
        var sectionIndex = 0
        blocks.forEachIndexed { blockIndex, block ->
            when (block) {
                is TableListBlock.Title -> lazy.item(key = "$prefix:title:$blockIndex", contentType = "table-list-title") {
                    TableListTitle(block.text, Modifier.padding(bottom = 12.dp))
                }
                is TableListBlock.Section -> {
                    val section = sectionIndex++
                    if (section > 0) {
                        lazy.item(key = "$prefix:gap:$section", contentType = "table-list-gap") {
                            Spacer(Modifier.height(24.dp))
                        }
                    }
                    if (block.header != null) {
                        lazy.item(key = "$prefix:header:$section", contentType = "table-list-header") {
                            TableListHeader(block.header)
                        }
                    }
                    if (block.rows.isEmpty() && block.emptyText != null) {
                        lazy.item(key = "$prefix:empty:$section", contentType = "table-list-row") {
                            TableListRow(first = true, last = true) {
                                ItemContent {
                                    BasicText(block.emptyText, style = Typography.base.copy(color = EsuissTheme.colors.mutedForeground))
                                }
                            }
                        }
                    }
                    block.rows.forEachIndexed { index, entry ->
                        lazy.item(
                            key = entry.key?.let { "$prefix:row:$section:$it" },
                            contentType = "table-list-row",
                        ) {
                            TableListRow(
                                first = index == 0,
                                last = index == block.rows.lastIndex,
                                onClick = entry.onClick,
                                content = entry.content,
                            )
                        }
                    }
                    if (block.footer != null) {
                        lazy.item(key = "$prefix:footer:$section", contentType = "table-list-footer") {
                            TableListFooter(block.footer)
                        }
                    }
                }
            }
        }
    }
}

fun LazyListScope.tableList(
    key: String = "table-list",
    content: TableListScope.() -> Unit,
) {
    TableListScope().apply(content).emit(this, key)
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Item.kt`: see [Item](https://ui.suiss.app/docs/components/item.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { TableListTitle, TableListHeader, TableListFooter, TableListSeparator, TableListRow, TableList } from "@/components/patterns/table-list"
```

```tsx
<View className="gap-8">
  <TableList
    title="Invoices"
    rows={invoices}
    columns={columns}
    getKey={(row) => row.id}
    onRowPress={() => undefined}
  />
  <TableList rows={[]} columns={columns} emptyText="No invoices yet." />
</View>
```

Define columns with `key`, `header`, `cell` and `slot`. Several `description` columns are joined with ` · `, and several `trailing` columns are shown side by side. Use `mobileCell` to render a different value than `cell` in the list, such as a shorter date.

Pass `onRowPress` to make rows pressable. When there are no rows, `emptyText` is shown in the group. The `TableListColumn` type is exported.

With a `FlatList`, use `TableListRow` as the item, pass `first` and `last` to round the ends, `TableListSeparator` as `ItemSeparatorComponent`, and `TableListHeader` and `TableListFooter` around the list.

## Examples

### With flat list

Builds the grouped list in a `FlatList` with a header, footer and separators.

![Preview](https://ui.suiss.app/previews/patterns-table-list--with-flat-list.light.webp)

```tsx
import { FlatList } from "react-native"
import { TableListFooter, TableListHeader, TableListRow, TableListSeparator } from "@/components/patterns/table-list"

type Invoice = {
  id: string
  customer: string
  status: string
  method: string
  amount: string
}

const invoices: Invoice[] = [
  {
    id: "INV001",
    customer: "Olivia Martin",
    status: "Paid",
    method: "Card",
    amount: "$250.00",
  },
  {
    id: "INV002",
    customer: "Jackson Lee",
    status: "Pending",
    method: "PayPal",
    amount: "$150.00",
  },
  {
    id: "INV003",
    customer: "Isabella Nguyen",
    status: "Unpaid",
    method: "Transfer",
    amount: "$350.00",
  },
  {
    id: "INV004",
    customer: "William Kim",
    status: "Paid",
    method: "Card",
    amount: "$450.00",
  },
]

export function TableListWithFlatListExample() {
  return (
    <FlatList
      data={invoices}
      keyExtractor={(item) => item.id}
      ListHeaderComponent={<TableListHeader>Recent</TableListHeader>}
      ListFooterComponent={
        <TableListFooter>Payments settle within two days.</TableListFooter>
      }
      ItemSeparatorComponent={TableListSeparator}
      renderItem={({ item, index }) => (
        <TableListRow
          first={index === 0}
          last={index === invoices.length - 1}
          title={item.customer}
          description={item.status}
          trailing={item.amount}
          onPress={() => undefined}
        />
      )}
    />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct TableListWithFlatListExample: View {
  var body: some View {
    TableListSection(header: "Recent", footer: "Payments settle within two days.") {
      ForEach(tableListInvoices) { invoice in
        TableListRow(action: {}) {
          VStack(alignment: .leading, spacing: 2) {
            Text(verbatim: invoice.customer)
              .textStyle(.base)
              .foregroundStyle(EsuissColor.foreground)
              .lineLimit(1)
            Text(verbatim: invoice.status)
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .lineLimit(1)
          }
          .frame(maxWidth: .infinity, alignment: .leading)
          Text(verbatim: invoice.amount)
            .textStyle(.base)
            .monospacedDigit()
            .foregroundStyle(EsuissColor.mutedForeground)
            .lineLimit(1)
            .fixedSize()
        }
      }
    }
  }
}

struct TableListInvoice: Identifiable {
  let id: String
  let customer: String
  let status: String
  let method: String
  let amount: String
}

let tableListInvoices = [
  TableListInvoice(id: "INV001", customer: "Olivia Martin", status: "Paid", method: "Card", amount: "$250.00"),
  TableListInvoice(id: "INV002", customer: "Jackson Lee", status: "Pending", method: "PayPal", amount: "$150.00"),
  TableListInvoice(id: "INV003", customer: "Isabella Nguyen", status: "Unpaid", method: "Transfer", amount: "$350.00"),
  TableListInvoice(id: "INV004", customer: "William Kim", status: "Paid", method: "Card", amount: "$450.00"),
]
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.TableListFooter
import com.esuiss.ui.patterns.TableListHeader
import com.esuiss.ui.patterns.TableListRow
import com.esuiss.ui.patterns.TableListRowText

class TableListInvoice(
    val id: String,
    val customer: String,
    val status: String,
    val method: String,
    val amount: String,
)

val tableListInvoices = listOf(
    TableListInvoice("INV001", "Olivia Martin", "Paid", "Card", "$250.00"),
    TableListInvoice("INV002", "Jackson Lee", "Pending", "PayPal", "$150.00"),
    TableListInvoice("INV003", "Isabella Nguyen", "Unpaid", "Transfer", "$350.00"),
    TableListInvoice("INV004", "William Kim", "Paid", "Card", "$450.00"),
)

@Composable
fun TableListWithFlatList() {
    LazyColumn(Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
        item(key = "header") { TableListHeader("Recent") }
        itemsIndexed(tableListInvoices, key = { _, item -> item.id }) { index, item ->
            TableListRow(first = index == 0, last = index == tableListInvoices.lastIndex, onClick = {}) {
                TableListRowText(item.customer, item.status, item.amount)
            }
        }
        item(key = "footer") { TableListFooter("Payments settle within two days.") }
    }
}
```

## API Reference

### TableListTitle

A large heading above the list.

Also accepts the props of `React.ComponentProps<typeof Text>`.

### TableListHeader

A small uppercase heading above a group.

Also accepts the props of `React.ComponentProps<typeof Text>`.

### TableListFooter

A note below a group.

Also accepts the props of `React.ComponentProps<typeof Text>`.

### TableListSeparator

An inset divider between rows.

Also accepts the props of `ViewProps`.

### TableListRow

One row with a title, description and trailing content.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `React.ReactNode` | - | The row's main text. |
| `description` | `React.ReactNode` | - | A single-line secondary text. |
| `trailing` | `React.ReactNode` | - | Content on the end side; text is shown in secondary color with tabular numbers. |
| `first` | `boolean` | `false` | Rounds the top corners. |
| `last` | `boolean` | `false` | Rounds the bottom corners. |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof Item>`.

### TableList

Renders rows from a column definition, with an optional title and empty state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rows` | `T[]` | Required | The data to render. |
| `columns` | `TableListColumn<T>[]` | Required | Column definitions with `key`, `header`, `cell`, optional `mobileCell`, `slot` and `align`. |
| `getKey` | `(row: T, index: number) => React.Key` | `(_, index) => index` | Returns a unique key for a row. Defaults to the index. |
| `title` | `React.ReactNode` | - | Heading shown above the list. |
| `emptyText` | `React.ReactNode` | `"No results."` | Content shown when `rows` is empty. Defaults to `No results.`. |
| `onRowPress` | `(row: T, index: number) => void` | - | Called with the row and index when a row is pressed. |

Also accepts the props of `ViewProps`.

## Accessibility

- The group has the `list` role; rows have the `listitem` role, or `button` when `onRowPress` is set.
- `TableListTitle` and `TableListHeader` have the `heading` role.
- Separators are hidden from screen readers.

## Notes

- Rows use `bg-muted` and `opacity-80` when pressed.
- The `header` and `align` column fields are not rendered by the list layout.

