-
Notifications
You must be signed in to change notification settings - Fork 0
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
- Column Definitions
- DFTable — Read-Only Sortable
- DFDataTable — Selection and Activation
- DFDataGrid — Inline Editing and Pagination
- Column Width Strategy
- Sorting State Pattern
- Async Data and Loading States
- Accessibility
- Pro Components
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
}// 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))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)
}
}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() }
}
}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 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)
}
)
}
}// 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 … })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)
}
}
}@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 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()
}
}
}
}| 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 |
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)
}
}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)// 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| 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) |
.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) |
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() }
}
}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()
}
}
}
}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)
}
}
}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")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")"
}
)When sort state changes, DFTable automatically posts an accessibility announcement: "Sorted by Name, ascending." This behavior is on by default.
All three components support full keyboard navigation:
-
↑/↓— move focus between rows -
Space— toggle selection (DFDataTable / DFDataGrid) -
Return/Enter— activate row (DFDataTable'sonActivate) -
Tab— move between editable cells (DFDataGrid) -
Escape— cancel cell edit (DFDataGrid) -
⌘A— select all rows (DFDataTable multi-select, DFDataGrid)
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.
See also: Lists-Tables-and-Data · Loading-States · Style-System