Data Table
A server-driven table with typed columns, sort headers and paging.
{datatable.DataTable(datatable.DataTableProps[datatable.FixtureRow]{
Columns: datatable.FixtureColumns,
Rows: []datatable.FixtureRow{{Name: "Alpha", Total: 10}, {Name: "Beta", Total: 20}, {Name: "Gamma", Total: 30}},
Page: datatable.Page{Number: 1, Size: 10, Total: 3},
Href: datatable.FixtureHref,
})}Installation
Run the command in the app module.
gx add data-tableThe command also installs table and pagination.
The command writes these files.
| File | Path in the app |
|---|---|
DataTable.go |
ui/data-table/DataTable.go |
DataTable.fixtures.go |
ui/data-table/DataTable.fixtures.go |
Install table and pagination first.
Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.
// Package datatable renders a server-driven table (REQ-REG-10). Sort,
// filter and paging state live in typed GET routes; the component only
// renders one page and links the next one.
package datatable
import (
"strconv"
"github.com/alternayte/gx"
"github.com/alternayte/gx/registry/pagination"
"github.com/alternayte/gx/registry/table"
)
// Column is one typed column of a data table.
type Column[T any] struct {
// Key identifies the column in the row data.
Key string
// Label is the visible header text.
Label string
// Cell renders one cell.
Cell func(T) gx.Node
// Sort is the query value of the column when the header is a sort
// link. Empty makes the column unsortable.
Sort string
}
// Page is the paging state of one table render.
type Page struct {
Number int
Size int
Total int
}
// Href builds the typed GET URL of one table state. The app returns its
// own route value, so the query stays typed.
type Href func(sort, dir string, page int) gx.URL
// DataTableProps holds one rendered table.
type DataTableProps[T any] struct {
// Columns are the typed columns, in display order.
Columns []Column[T]
// Rows are the rows of the current page.
Rows []T
// Page is the paging state: the page number, the page size and the
// total row count.
Page Page
// Sort is the Sort value of the column the rows are sorted by. Empty
// means no sort.
Sort string
// Dir is the sort direction: "asc" or "desc".
Dir string
// Href builds the URL of a sort link and of a paging link.
Href Href
// Empty renders in place of the rows when the page holds none.
Empty gx.Node
}
// DataTable renders one page of a server-driven table with sortable
// headers and paging links (REQ-REG-10).
func DataTable[T any](p DataTableProps[T]) gx.Node {
head := make([]gx.Node, 0, len(p.Columns))
for _, col := range p.Columns {
head = append(head, header(p, col))
}
body := make([]gx.Node, 0, len(p.Rows))
for _, row := range p.Rows {
cells := make([]gx.Node, 0, len(p.Columns))
for _, col := range p.Columns {
cells = append(cells, table.TableCell(table.TableCellProps{Children: col.Cell(row)}))
}
body = append(body, table.TableRow(table.TableRowProps{Children: gx.Frag(cells...)}))
}
var rows gx.Node
switch {
case len(p.Rows) > 0:
rows = gx.Frag(body...)
case p.Empty != nil:
rows = table.TableRow(table.TableRowProps{
Children: table.TableCell(table.TableCellProps{
Class: "h-24 text-center text-muted-foreground",
Children: p.Empty,
}),
})
default:
rows = table.TableRow(table.TableRowProps{
Children: table.TableCell(table.TableCellProps{
Class: "h-24 text-center text-muted-foreground",
Children: gx.Text("No results."),
}),
})
}
parts := []gx.Node{
table.Table(table.TableProps{Children: gx.Frag(
table.TableHeader(table.TableHeaderProps{Children: table.TableRow(table.TableRowProps{Children: gx.Frag(head...)})}),
table.TableBody(table.TableBodyProps{Children: rows}),
)}),
}
if p.Page.Size > 0 {
parts = append(parts, paging(p))
}
return gx.Frag(parts...)
}
// header renders one header cell, with a sort link when the column sorts.
func header[T any](p DataTableProps[T], col Column[T]) gx.Node {
if col.Sort == "" {
return table.TableHead(table.TableHeadProps{Children: gx.Text(col.Label)})
}
dir := "asc"
arrow := "↕"
attrs := gx.Attrs{}
if p.Sort == col.Sort {
switch p.Dir {
case "desc":
dir = "asc"
arrow = "↓"
attrs = append(attrs, gx.Attr{Key: "aria-sort", Value: "descending"})
default:
dir = "desc"
arrow = "↑"
attrs = append(attrs, gx.Attr{Key: "aria-sort", Value: "ascending"})
}
}
link := gx.El("a", gx.Attrs{
{Key: "href", Value: string(p.Href(col.Sort, dir, 1)), Kind: gx.AttrURL},
{Key: "class", Value: "inline-flex items-center gap-1 no-underline hover:text-foreground"},
}, gx.Text(col.Label), gx.El("span", gx.Attrs{{Key: "class", Value: "text-muted-foreground"}}, gx.Text(arrow)))
return table.TableHead(table.TableHeadProps{Attrs: attrs, Children: link})
}
// paging renders the footer with the row range and the page links.
func paging[T any](p DataTableProps[T]) gx.Node {
pages := (p.Page.Total + p.Page.Size - 1) / p.Page.Size
if pages < 1 {
pages = 1
}
number := p.Page.Number
if number < 1 {
number = 1
}
if number > pages {
number = pages
}
from := (number-1)*p.Page.Size + 1
to := from + len(p.Rows) - 1
if p.Page.Total == 0 {
from, to = 0, 0
}
items := []gx.Node{
pagination.PaginationItem(pagination.PaginationItemProps{Children: pagination.PaginationPrevious(pagination.PaginationPreviousProps{
Href: p.Href(p.Sort, p.Dir, number-1),
Class: previousClass(number),
})}),
}
for _, page := range pageWindow(number, pages) {
page := page
if page == 0 {
items = append(items, pagination.PaginationItem(pagination.PaginationItemProps{
Children: pagination.PaginationEllipsis(pagination.PaginationEllipsisProps{}),
}))
continue
}
items = append(items, pagination.PaginationItem(pagination.PaginationItemProps{
Children: pagination.PaginationLink(pagination.PaginationLinkProps{
Href: p.Href(p.Sort, p.Dir, page),
Active: page == number,
Children: gx.Text(strconv.Itoa(page)),
}),
}))
}
items = append(items, pagination.PaginationItem(pagination.PaginationItemProps{Children: pagination.PaginationNext(pagination.PaginationNextProps{
Href: p.Href(p.Sort, p.Dir, number+1),
Class: nextClass(number, pages),
})}))
return gx.El("div", gx.Attrs{{Key: "class", Value: "flex items-center justify-between gap-4 px-2 pt-4"}},
gx.El("p", gx.Attrs{{Key: "class", Value: "text-sm text-muted-foreground"}},
gx.Text(strconv.Itoa(from)+"–"+strconv.Itoa(to)+" of "+strconv.Itoa(p.Page.Total))),
pagination.Pagination(pagination.PaginationProps{Children: pagination.PaginationContent(pagination.PaginationContentProps{Children: gx.Frag(items...)})}),
)
}
// previousClass hides the previous link on the first page.
func previousClass(number int) string {
if number <= 1 {
return "pointer-events-none opacity-50"
}
return ""
}
// nextClass hides the next link on the last page.
func nextClass(number, pages int) string {
if number >= pages {
return "pointer-events-none opacity-50"
}
return ""
}
// pageWindow returns the page numbers to show, with 0 for an ellipsis.
func pageWindow(number, pages int) []int {
if pages <= 7 {
out := make([]int, 0, pages)
for i := 1; i <= pages; i++ {
out = append(out, i)
}
return out
}
out := []int{1}
start, end := number-1, number+1
if start < 2 {
start, end = 2, 4
}
if end > pages-1 {
start, end = pages-4, pages-1
}
if start > 2 {
out = append(out, 0)
}
for i := start; i <= end; i++ {
out = append(out, i)
}
if end < pages-1 {
out = append(out, 0)
}
return append(out, pages)
}package datatable
import (
"strconv"
"github.com/alternayte/gx"
)
// FixtureRow is the row type of the data table fixtures.
type FixtureRow struct {
Name string
Total int
}
// FixtureColumns are the columns of the data table fixtures. The name column
// is a sort link.
var FixtureColumns = []Column[FixtureRow]{
{Key: "name", Label: "Name", Sort: "name", Cell: func(r FixtureRow) gx.Node { return gx.Text(r.Name) }},
{Key: "total", Label: "Total", Cell: func(r FixtureRow) gx.Node { return gx.Text(strconv.Itoa(r.Total)) }},
}
// FixtureHref is the Href of the data table fixtures. An app returns its own
// typed route here.
func FixtureHref(sort, dir string, page int) gx.URL { return gx.URL("/") }
var DataTableFixtures = gx.Fixtures[DataTableProps[FixtureRow]]{
"Three": {
Columns: FixtureColumns,
Rows: []FixtureRow{{Name: "Alpha", Total: 10}, {Name: "Beta", Total: 20}, {Name: "Gamma", Total: 30}},
Page: Page{Number: 1, Size: 10, Total: 3},
Href: FixtureHref,
},
"Empty": {
Columns: FixtureColumns,
Page: Page{Number: 1, Size: 10, Total: 0},
Href: FixtureHref,
},
}The theme must define these tokens: --border, --muted-foreground, --accent, --accent-foreground, --ring.
Usage
type Row struct {
Name string
Total int
}
var columns = []datatable.Column[Row]{
{Key: "name", Label: "Name", Sort: "name", Cell: func(r Row) gx.Node { return gx.Text(r.Name) }},
{Key: "total", Label: "Total", Cell: func(r Row) gx.Node { return gx.Text(strconv.Itoa(r.Total)) }},
}
// In the view:
datatable.DataTable(datatable.DataTableProps[Row]{
Columns: columns,
Rows: p.Rows,
Page: datatable.Page{Number: p.Page, Size: 25, Total: 10000},
Sort: p.Sort,
Dir: p.Dir,
Href: func(sort, dir string, page int) gx.URL {
return invoices.List{Sort: sort, Dir: dir, Page: page}.URL()
},
})Examples
Three
{datatable.DataTable(datatable.DataTableProps[datatable.FixtureRow]{
Columns: datatable.FixtureColumns,
Rows: []datatable.FixtureRow{{Name: "Alpha", Total: 10}, {Name: "Beta", Total: 20}, {Name: "Gamma", Total: 30}},
Page: datatable.Page{Number: 1, Size: 10, Total: 3},
Href: datatable.FixtureHref,
})}Empty
{datatable.DataTable(datatable.DataTableProps[datatable.FixtureRow]{
Columns: datatable.FixtureColumns,
Page: datatable.Page{Number: 1, Size: 10, Total: 0},
Href: datatable.FixtureHref,
})}API reference
datatable.DataTable
The component is a Go function. Its props are the fields of DataTableProps.
| Prop | Type | Default | Description |
|---|---|---|---|
Columns |
[]Column[T] |
Zero value | Columns are the typed columns, in display order. |
Rows |
[]T |
Zero value | Rows are the rows of the current page. |
Page |
Page |
Zero value | Page is the paging state: the page number, the page size and the total row count. |
Sort |
string |
Zero value | Sort is the Sort value of the column the rows are sorted by. Empty means no sort. |
Dir |
string |
Zero value | Dir is the sort direction: "asc" or "desc". |
Href |
Href |
Zero value | Href builds the URL of a sort link and of a paging link. |
Empty |
gx.Node |
Zero value | Empty renders in place of the rows when the page holds none. |
Do and do not
Do
- Sort, filter and page on the server through a typed GET route.
- Pass the current
SortandDirback into the props.
Don't
- Do not page on the client.
- Do not render more rows than the page size.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves between the sort headers and the page links. |
| Enter | Sorts by the focused header or follows a page link. |