Skip to content

Data Tables

Daniel S edited this page Jul 2, 2026 · 1 revision

Data Tables

DesignFoundation provides three table components at different points on the capability spectrum. Choose based on whether you need read-only display, selection and activation, or full inline editing.

Component Selection Editing Sorting Pagination Use When
DFTable No No Yes (client) No Read-only display of fixed data
DFDataTable Yes No Yes (client or server) Optional Browse, select, and act on records
DFDataGrid Yes Yes Yes Yes (virtual or paged) Full spreadsheet-style editing

See also: Lists-Tables-and-Data for DFList and DFListRow · Loading-States for skeleton loaders during async fetch


Contents

  1. Column Definitions
  2. DFTable — Read-Only Sortable
  3. DFDataTable — Selection and Activation
  4. DFDataGrid — Inline Editing and Pagination
  5. Column Width Strategy
  6. Sorting State Pattern
  7. Async Data and Loading States
  8. Accessibility
  9. Pro Components

Column Definitions

All three table components use the same DFTableColumn type:

public struct DFTableColumn<Row: Identifiable & Sendable>: Sendable {
    public let id:        String
    public let title:     String
    public let width:     DFColumnWidth
    public let sortable:  Bool
    public let cell:      @Sendable (Row) -> AnyView
}

Creating Columns

// Static factory — wraps any view
DFTableColumn<User>(
    id: "name",
    title: "Name",
    width: .flexible(min: 140, max: .infinity),
    sortable: true
) { user in
    HStack(spacing: 8) {
        DFAvatar(name: user.fullName, size: 28)
        DFText(user.fullName, style: .label)
    }
}

// Text shorthand — single DFText cell
DFTableColumn<User>.text("email", title: "Email", keyPath: \.email, width: .fixed(200))

// Badge shorthand — renders DFBadge
DFTableColumn<User>.badge("role", title: "Role", keyPath: \.role.displayName, width: .fixed(100))

DFTable — Read-Only Sortable

The simplest table. No selection, no editing, built-in header sort chevrons, scrollable horizontally.

import DesignFoundation

struct UserDirectoryTable: View {
    let users: [User]

    private let columns: [DFTableColumn<User>] = [
        .text("name",  title: "Name",  keyPath: \.fullName, width: .flexible(min: 150, max: .infinity)),
        .text("email", title: "Email", keyPath: \.email,    width: .fixed(220)),
        .text("dept",  title: "Dept",  keyPath: \.department, width: .fixed(120)),
        DFTableColumn(id: "status", title: "Status", width: .fixed(90), sortable: false) { user in
            DFBadge(
                text: user.isActive ? "Active" : "Inactive",
                color: user.isActive ? .green : .gray
            )
        },
    ]

    var body: some View {
        DFTable(columns: columns, rows: users)
    }
}

Controlled Sort

DFTable can be given an external sort binding — useful when sorting is server-driven:

struct ServerSortedTable: View {
    @State private var sortKey: String? = "name"
    @State private var ascending: Bool = true
    let users: [User]

    var body: some View {
        DFTable(
            columns: columns,
            rows: users,
            sortKey: $sortKey,
            ascending: $ascending
        )
        .onChange(of: sortKey) { _, _ in fetchUsers() }
        .onChange(of: ascending) { _, _ in fetchUsers() }
    }
}

Client-Side Auto-Sort

Omit the sort bindings and provide a sortComparator closure — DFTable manages sort state internally:

DFTable(
    columns: columns,
    rows: users,
    sortComparator: { key, a, b, ascending in
        switch key {
        case "name":  return ascending ? a.fullName < b.fullName : a.fullName > b.fullName
        case "email": return ascending ? a.email < b.email : a.email > b.email
        default: return false
        }
    }
)

DFDataTable — Selection and Activation

DFDataTable adds row selection and an onActivate closure for double-tap / enter-key navigation. Supports both single and multi-selection.

struct FileList: View {
    let files: [FileItem]
    @State private var selection: Set<FileItem.ID> = []

    private let columns: [DFTableColumn<FileItem>] = [
        DFTableColumn(id: "name", title: "Name", width: .flexible(min: 200, max: .infinity)) { file in
            HStack(spacing: 8) {
                DFIcon(file.iconName, size: .sm, color: .secondary)
                DFText(file.name, style: .label)
            }
        },
        .text("size",     title: "Size",     keyPath: \.sizeFormatted, width: .fixed(80)),
        .text("modified", title: "Modified",  keyPath: \.modifiedAt,    width: .fixed(140)),
    ]

    var body: some View {
        DFDataTable(
            columns: columns,
            rows: files,
            selection: $selection,
            selectionMode: .multiple,
            onActivate: { id in
                openFile(id: id)
            }
        )
    }
}

Selection Modes

// Single selection — tapping a row deselects the previous one
DFDataTable(columns: cols, rows: rows, selection: $selection, selectionMode: .single)

// Multiple selection — shift+click, cmd+click, swipe checkmark on iOS
DFDataTable(columns: cols, rows: rows, selection: $selection, selectionMode: .multiple)

// Observation without selection UI — tracks last tapped row
DFDataTable(columns: cols, rows: rows, selection: .constant([]), selectionMode: .none, onActivate: { id in  })

Toolbar Integration

DFDataTable works naturally with SwiftUI toolbar items that are conditional on selection:

DFDataTable(columns: columns, rows: rows, selection: $selection)
    .toolbar {
        if !selection.isEmpty {
            ToolbarItemGroup {
                DFButton("Delete (\(selection.count))") { deleteSelected() }
                    .dfButtonStyle(.destructive)
                DFButton("Export") { exportSelected() }
                    .dfButtonStyle(.outlined)
            }
        }
    }

Pagination

@State private var currentPage: Int = 0
let pageSize = 25

DFDataTable(
    columns: columns,
    rows: pagedRows,          // just this page's data
    selection: $selection,
    pagination: DFPaginationConfig(
        currentPage: currentPage,
        pageSize: pageSize,
        totalItems: totalCount,
        onPageChange: { page in
            currentPage = page
            Task { await loadPage(page) }
        }
    )
)

DFDataGrid — Inline Editing and Pagination

DFDataGrid is a full spreadsheet experience: double-click any cell to edit, tab between cells, multi-row select and bulk edit, undo/redo stack, copy/paste support.

import DesignFoundation

struct ProductGrid: View {
    @State private var products: [Product]
    @State private var selection: Set<Product.ID> = []
    @State private var isDirty: Bool = false

    private let columns: [DFGridColumn<Product>] = [
        DFGridColumn(
            id: "sku",
            title: "SKU",
            width: .fixed(100),
            readOnly: true   // no editing — display only
        ) { row in DFText(row.sku, style: .label) },

        DFGridColumn.text(
            id: "name",
            title: "Product Name",
            keyPath: \.name,
            onEdit: { product, newName in
                var p = product; p.name = newName; return p
            },
            width: .flexible(min: 180, max: .infinity)
        ),

        DFGridColumn.number(
            id: "price",
            title: "Price ($)",
            keyPath: \.price,
            onEdit: { product, newPrice in
                var p = product; p.price = newPrice; return p
            },
            format: .currency(code: "USD"),
            width: .fixed(110)
        ),

        DFGridColumn.toggle(
            id: "active",
            title: "Active",
            keyPath: \.isActive,
            onEdit: { product, flag in
                var p = product; p.isActive = flag; return p
            },
            width: .fixed(72)
        ),
    ]

    var body: some View {
        VStack {
            DFDataGrid(
                columns: columns,
                rows: $products,
                selection: $selection,
                isDirty: $isDirty,
                pageSize: 50
            )

            if isDirty {
                HStack {
                    Spacer()
                    DFButton("Discard") { loadProducts() }
                        .dfButtonStyle(.outlined)
                    DFButton("Save changes") { saveProducts() }
                }
                .padding()
            }
        }
    }
}

DFGridColumn Types

Factory Cell Editor Use For
DFGridColumn.text(...) Single-line text field Names, labels, IDs
DFGridColumn.number(...) Numeric field with format Prices, quantities, percentages
DFGridColumn.toggle(...) Checkbox / toggle Boolean flags
DFGridColumn.date(...) Inline date picker Timestamps, deadlines
DFGridColumn.picker(...) Dropdown Enums, fixed option sets
DFGridColumn(...) (custom) Any View Fully custom rendering + editing

Undo/Redo

DFDataGrid maintains an internal UndoManager scoped to the grid. CMD+Z / CMD+SHIFT+Z work out of the box. You can also drive undo/redo programmatically:

@State private var gridUndoManager: UndoManager?

DFDataGrid(columns: columns, rows: $products, undoManager: $gridUndoManager)
    .toolbar {
        ToolbarItemGroup {
            DFButton("Undo") { gridUndoManager?.undo() }
                .disabled(gridUndoManager?.canUndo == false)
            DFButton("Redo") { gridUndoManager?.redo() }
                .disabled(gridUndoManager?.canRedo == false)
        }
    }

Copy/Paste

DFDataGrid supports TSV (Tab-Separated Values) copy/paste — the same format Excel and Numbers use. Selected rows are copied as TSV; pasting overwrites corresponding cells in the selected rows.

// Enable paste — set to false to make the grid read/copy-only
DFDataGrid(columns: columns, rows: $rows, allowPaste: true)

Column Width Strategy

// Fixed — never shrinks or grows
.fixed(120)

// Flexible — grows to fill available space, respects min/max
.flexible(min: 100, max: .infinity)
.flexible(min: 80, max: 240)

// Fraction — proportion of total table width
.fraction(0.3)   // 30% of table width

Recommended Column Widths by Content Type

Content Recommended Width
Icon or checkbox .fixed(40)
ID / SKU / code .fixed(80–100)
Status badge .fixed(90–110)
Short text (name) .flexible(min: 140, max: 260)
Email .fixed(200–240)
Number / price .fixed(90–120)
Date .fixed(120–160)
Primary text (expands) .flexible(min: 180, max: .infinity)
Description / notes .flexible(min: 200, max: .infinity)

Sorting State Pattern

When sort is server-driven, manage the state in a view model or @Observable store:

@Observable
final class UserTableModel {
    var users: [User] = []
    var sortKey: String = "name"
    var ascending: Bool = true
    var isLoading: Bool = false

    func sort(by key: String) {
        if sortKey == key {
            ascending.toggle()
        } else {
            sortKey = key
            ascending = true
        }
        Task { await fetchUsers() }
    }

    func fetchUsers() async {
        isLoading = true
        defer { isLoading = false }
        users = await api.users(sortBy: sortKey, ascending: ascending)
    }
}

struct UserTableView: View {
    @State private var model = UserTableModel()

    var body: some View {
        DFTable(
            columns: columns,
            rows: model.users,
            sortKey: Binding(get: { model.sortKey }, set: { model.sort(by: $0) }),
            ascending: Binding(get: { model.ascending }, set: { _ in })
        )
        .overlay {
            if model.isLoading {
                DFProgressBar(value: nil)
                    .frame(height: 2)
                    .frame(maxHeight: .infinity, alignment: .top)
            }
        }
        .task { await model.fetchUsers() }
    }
}

Async Data and Loading States

Use DFSkeleton rows while data loads, then transition to the real table:

struct AsyncTableView: View {
    @State private var users: [User]? = nil

    var body: some View {
        Group {
            if let users {
                DFTable(columns: columns, rows: users)
                    .transition(.opacity)
            } else {
                SkeletonTableView(rowCount: 8, columnCount: 4)
                    .transition(.opacity)
            }
        }
        .animation(.easeIn(duration: 0.2), value: users != nil)
        .task { users = await api.fetchUsers() }
    }
}

struct SkeletonTableView: View {
    let rowCount: Int
    let columnCount: Int
    @Environment(\.dfTheme) private var theme

    var body: some View {
        VStack(spacing: 0) {
            // Header row
            HStack(spacing: theme.spacing.md) {
                ForEach(0..<columnCount, id: \.self) { _ in
                    DFSkeleton(width: .infinity, height: 14)
                }
            }
            .padding(theme.spacing.md)

            DFDivider()

            // Data rows
            ForEach(0..<rowCount, id: \.self) { _ in
                HStack(spacing: theme.spacing.md) {
                    ForEach(0..<columnCount, id: \.self) { col in
                        DFSkeleton(
                            width: col == 0 ? 180 : 100,
                            height: 12,
                            shape: .rounded
                        )
                    }
                }
                .padding(theme.spacing.md)
                DFDivider()
            }
        }
    }
}

Incremental Loading

For large datasets that stream in, append to rows as data arrives — DFTable and DFDataTable handle incremental updates without re-rendering existing rows:

@State private var rows: [LogEntry] = []

var body: some View {
    DFDataTable(columns: columns, rows: rows, selection: $selection)
        .task {
            for await entry in logStream {
                rows.append(entry)
            }
        }
}

Accessibility

Column Headers

Column titles in DFTableColumn automatically become accessibilityLabels for their sort button. Ensure title strings are descriptive:

// Good
DFTableColumn<User>(id: "created", title: "Account Created")

// Avoid — VoiceOver reads "Date" which is ambiguous
DFTableColumn<User>(id: "created", title: "Date")

Row Descriptions

DFDataTable and DFDataGrid expose each row with an accessibilityElement label derived from the first column's text content. For rows where this is insufficient, supply an accessibilityLabel closure:

DFDataTable(
    columns: columns,
    rows: rows,
    selection: $selection,
    rowAccessibilityLabel: { user in
        "\(user.fullName), \(user.role.displayName), \(user.isActive ? "active" : "inactive")"
    }
)

Sort Announcements

When sort state changes, DFTable automatically posts an accessibility announcement: "Sorted by Name, ascending." This behavior is on by default.

Keyboard Navigation

All three components support full keyboard navigation:

  • / — move focus between rows
  • Space — toggle selection (DFDataTable / DFDataGrid)
  • Return / Enter — activate row (DFDataTable's onActivate)
  • Tab — move between editable cells (DFDataGrid)
  • Escape — cancel cell edit (DFDataGrid)
  • ⌘A — select all rows (DFDataTable multi-select, DFDataGrid)

Pro Components

If your app needs real-time collaborative editing, virtual scrolling for 100k+ rows, drag-to-reorder columns, column pinning, row grouping, or aggregation rows, DesignFoundation Pro ships production-ready table components built on the same token system. Everything themed, accessible, and Liquid Glass-ready out of the box.

DesignFoundation Pro


See also: Lists-Tables-and-Data · Loading-States · Style-System

Clone this wiki locally