initial port of the UI kit
This commit is contained in:
479
go/webui/cellgrid.go
Normal file
479
go/webui/cellgrid.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/CellGrid.tsx — an editable, sortable spreadsheet-style grid.
|
||||
//
|
||||
// Row model: rows are map[string]any (the clean Go analog of the TSX's `any`
|
||||
// objects with string-keyed fields, so row[col.Key], row[IDField], and conflict
|
||||
// lookups map directly). Column callbacks (Render, SortValue, CellClassFn,
|
||||
// OnClick, Parse) take/return that map so no type assertions are needed.
|
||||
//
|
||||
// NOTE: Several CellGrid behaviors are browser-only and have no equivalent in the
|
||||
// neutral runtime, so they are dropped (the render output and sort/edit/conflict
|
||||
// wiring are preserved):
|
||||
// - Keyboard navigation (Enter/Tab/Arrows/Escape), cell focus & the `selected`
|
||||
// signal, and focusCell/moveSelection: all require live DOM focus()/refs.
|
||||
// - Row-reorder FLIP animation (getBoundingClientRect + Element.animate).
|
||||
// - The imperative CellGridApi `ref` (focusCell/selected/snapshot/animateRows).
|
||||
// Its one pure-logic member, dirty(), is offered as the CellGridDirty helper.
|
||||
// - onFocus/onBlur/blurStamp: conflict sets are recomputed on every render from
|
||||
// the current rows instead (a signal write already re-renders), so duplicate
|
||||
// highlighting stays live without the blur hook.
|
||||
// - localeCompare is approximated by byte-wise strings.Compare.
|
||||
// - The sort caret uses "caret-up"/"caret-down"; those aren't in the default
|
||||
// icon registry, so they render as an empty box unless the app RegisterIcon's
|
||||
// them (the triangle-exclamation conflict marker is registered by default).
|
||||
|
||||
// GridHeaderCls is the base <th> class for CellGrid headers (exported, matching
|
||||
// the TSX GRID_HEADER_CLS).
|
||||
const GridHeaderCls = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0"
|
||||
|
||||
var cgLeadingDigits = regexp.MustCompile(`^\d+`)
|
||||
var cgLeadingFloat = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`)
|
||||
|
||||
func cellGridStr(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
func cellGridColumnSize(width, minWidth string) string {
|
||||
if width != "" {
|
||||
return width
|
||||
}
|
||||
return minWidth
|
||||
}
|
||||
|
||||
func cellGridLeadingInt(s string) (int, bool) {
|
||||
m := cgLeadingDigits.FindString(s)
|
||||
if m == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func cellGridParseFloat(v any) float64 {
|
||||
m := cgLeadingFloat.FindString(strings.TrimSpace(cellGridStr(v)))
|
||||
if m == "" || m == "+" || m == "-" || m == "." {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(m, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// CompareRowsGeneric compares a[key] and b[key] the way the TSX comparator does:
|
||||
// empties sort last, "numeric" by leading integer then string tiebreak, "money"
|
||||
// by parsed float, otherwise by string. Returns <0, 0, or >0.
|
||||
func CompareRowsGeneric(a, b map[string]any, key, sortType string) int {
|
||||
av := a[key]
|
||||
bv := b[key]
|
||||
aEmpty := av == nil || av == ""
|
||||
bEmpty := bv == nil || bv == ""
|
||||
if aEmpty && bEmpty {
|
||||
return 0
|
||||
}
|
||||
if aEmpty {
|
||||
return 1
|
||||
}
|
||||
if bEmpty {
|
||||
return -1
|
||||
}
|
||||
switch sortType {
|
||||
case "numeric":
|
||||
as := cellGridStr(av)
|
||||
bs := cellGridStr(bv)
|
||||
an, aok := cellGridLeadingInt(as)
|
||||
bn, bok := cellGridLeadingInt(bs)
|
||||
if aok && bok {
|
||||
if an != bn {
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(as, bs)
|
||||
}
|
||||
if aok {
|
||||
return -1
|
||||
}
|
||||
if bok {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(as, bs)
|
||||
case "money":
|
||||
af := cellGridParseFloat(av)
|
||||
bf := cellGridParseFloat(bv)
|
||||
if af < bf {
|
||||
return -1
|
||||
}
|
||||
if af > bf {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return strings.Compare(cellGridStr(av), cellGridStr(bv))
|
||||
}
|
||||
}
|
||||
|
||||
// CellGridColumn defines one grid column. The `cellClass: string | (row)=>string`
|
||||
// TSX union becomes CellClass (static) + CellClassFn (dynamic; wins when set).
|
||||
type CellGridColumn struct {
|
||||
Key string
|
||||
Label string
|
||||
SortKey string
|
||||
SortType string
|
||||
SortValue func(row map[string]any) any
|
||||
Width string
|
||||
MinWidth string
|
||||
HeaderClass string
|
||||
Editable bool
|
||||
ReadOnly bool
|
||||
Render func(row map[string]any) *vdom.VNode
|
||||
CellClass string
|
||||
CellClassFn func(row map[string]any) string
|
||||
OnClick func(row map[string]any)
|
||||
InputMode string
|
||||
Placeholder string
|
||||
Parse func(value string) any
|
||||
}
|
||||
|
||||
// CellGridProps configures CellGrid. SortKey/SortDesc are the current sort state;
|
||||
// SetSortKey/SetSortDesc are called by the header click handler (which toggles
|
||||
// direction when the same column is re-clicked).
|
||||
type CellGridProps struct {
|
||||
Columns []CellGridColumn
|
||||
Rows []map[string]any
|
||||
IDField string
|
||||
OnCellChange func(rowID any, field string, value any)
|
||||
SortKey string
|
||||
SetSortKey func(key string)
|
||||
SortDesc bool
|
||||
SetSortDesc func(desc bool)
|
||||
ConflictFields []string
|
||||
Dense bool
|
||||
}
|
||||
|
||||
// SortableHeaderProps configures a clickable, sort-indicating header cell.
|
||||
type SortableHeaderProps struct {
|
||||
Label string
|
||||
SortKey string
|
||||
Width string
|
||||
MinWidth string
|
||||
Current string // currently active sort key ("" == none)
|
||||
Desc bool
|
||||
OnSort func(key string)
|
||||
}
|
||||
|
||||
// SortableHeader renders a <th> that shows a caret when it is the active sort
|
||||
// column and calls OnSort(SortKey) on click.
|
||||
func SortableHeader(p SortableHeaderProps) *vdom.VNode {
|
||||
isActive := p.SortKey != "" && p.Current == p.SortKey
|
||||
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-neutral-200"
|
||||
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
|
||||
cls += " " + sz
|
||||
}
|
||||
|
||||
inner := []vdom.Mod{
|
||||
vdom.Attr("class", "flex items-center gap-0.5 min-w-0"),
|
||||
vdom.El("span", vdom.Attr("class", "truncate min-w-0 flex-1"), vdom.Text(p.Label)),
|
||||
}
|
||||
if isActive {
|
||||
icon := "caret-up"
|
||||
if p.Desc {
|
||||
icon = "caret-down"
|
||||
}
|
||||
inner = append(inner, vdom.El("span", vdom.Attr("class", "shrink-0"), Icon(icon, 10, "")))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cls)}
|
||||
if p.OnSort != nil {
|
||||
key := p.SortKey
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSort(key) }))
|
||||
}
|
||||
mods = append(mods, vdom.El("div", inner...))
|
||||
return vdom.El("th", mods...)
|
||||
}
|
||||
|
||||
func cellGridConflictSets(p CellGridProps) map[string]map[string]bool {
|
||||
result := map[string]map[string]bool{}
|
||||
if len(p.ConflictFields) == 0 {
|
||||
return result
|
||||
}
|
||||
for _, field := range p.ConflictFields {
|
||||
counts := map[string]int{}
|
||||
for _, row := range p.Rows {
|
||||
v := row[field]
|
||||
if v == nil || v == "" {
|
||||
continue
|
||||
}
|
||||
counts[cellGridStr(v)]++
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for v, c := range counts {
|
||||
if c > 1 {
|
||||
set[v] = true
|
||||
}
|
||||
}
|
||||
result[field] = set
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cellGridSortedRows(p CellGridProps) []map[string]any {
|
||||
rows := make([]map[string]any, len(p.Rows))
|
||||
copy(rows, p.Rows)
|
||||
sk := p.SortKey
|
||||
if sk == "" {
|
||||
return rows
|
||||
}
|
||||
|
||||
var col *CellGridColumn
|
||||
for i := range p.Columns {
|
||||
if p.Columns[i].SortKey == sk {
|
||||
col = &p.Columns[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
st := "string"
|
||||
getVal := func(r map[string]any) any { return r[sk] }
|
||||
if col != nil {
|
||||
if col.SortType != "" {
|
||||
st = col.SortType
|
||||
}
|
||||
if col.SortValue != nil {
|
||||
getVal = col.SortValue
|
||||
}
|
||||
}
|
||||
|
||||
// Sort ascending stably, then reverse for desc (matches the TSX, which
|
||||
// reverses after a stable ascending sort rather than flipping ties).
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a := map[string]any{"v": getVal(rows[i])}
|
||||
b := map[string]any{"v": getVal(rows[j])}
|
||||
return CompareRowsGeneric(a, b, "v", st) < 0
|
||||
})
|
||||
if p.SortDesc {
|
||||
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||||
rows[i], rows[j] = rows[j], rows[i]
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// CellGridDirty reports whether any editable field differs from initialRows (the
|
||||
// pure-logic half of the dropped CellGridApi.dirty()). A length mismatch or a nil
|
||||
// baseline counts as dirty.
|
||||
func CellGridDirty(columns []CellGridColumn, rows, initialRows []map[string]any) bool {
|
||||
if initialRows == nil || len(rows) != len(initialRows) {
|
||||
return true
|
||||
}
|
||||
var fields []string
|
||||
for _, c := range columns {
|
||||
if c.Editable {
|
||||
fields = append(fields, c.Key)
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
for _, f := range fields {
|
||||
if cellGridStr(rows[i][f]) != cellGridStr(initialRows[i][f]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CellGrid renders the sortable/editable grid. See the file NOTE for the
|
||||
// browser-only behaviors that are intentionally omitted.
|
||||
func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
idf := p.IDField
|
||||
if idf == "" {
|
||||
idf = "id"
|
||||
}
|
||||
rowHCls := "h-8"
|
||||
if p.Dense {
|
||||
rowHCls = "h-6"
|
||||
}
|
||||
readonlyTdCls := "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls
|
||||
const editableTdCls = "border-b border-r border-neutral-300 p-0 relative align-middle"
|
||||
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
|
||||
|
||||
conflicts := cellGridConflictSets(p)
|
||||
isConflict := func(field string, value any) bool {
|
||||
if value == nil || value == "" {
|
||||
return false
|
||||
}
|
||||
set := conflicts[field]
|
||||
return set != nil && set[cellGridStr(value)]
|
||||
}
|
||||
fieldInConflictList := func(key string) bool {
|
||||
for _, f := range p.ConflictFields {
|
||||
if f == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
handleSort := func(key string) {
|
||||
if p.SortKey == key {
|
||||
if p.SetSortDesc != nil {
|
||||
p.SetSortDesc(!p.SortDesc)
|
||||
}
|
||||
return
|
||||
}
|
||||
if p.SetSortKey != nil {
|
||||
p.SetSortKey(key)
|
||||
}
|
||||
if p.SetSortDesc != nil {
|
||||
p.SetSortDesc(false)
|
||||
}
|
||||
}
|
||||
|
||||
renderHeader := func(col CellGridColumn) *vdom.VNode {
|
||||
if col.SortKey != "" {
|
||||
return SortableHeader(SortableHeaderProps{
|
||||
Label: col.Label,
|
||||
SortKey: col.SortKey,
|
||||
Width: col.Width,
|
||||
MinWidth: col.MinWidth,
|
||||
Current: p.SortKey,
|
||||
Desc: p.SortDesc,
|
||||
OnSort: handleSort,
|
||||
})
|
||||
}
|
||||
cls := GridHeaderCls
|
||||
if col.HeaderClass != "" {
|
||||
cls += " " + col.HeaderClass
|
||||
}
|
||||
if sz := cellGridColumnSize(col.Width, col.MinWidth); sz != "" {
|
||||
cls += " " + sz
|
||||
}
|
||||
return vdom.El("th", vdom.Attr("class", cls), vdom.Text(col.Label))
|
||||
}
|
||||
|
||||
renderCell := func(row map[string]any, col CellGridColumn) *vdom.VNode {
|
||||
rowID := row[idf]
|
||||
sizeCls := ""
|
||||
if sz := cellGridColumnSize(col.Width, col.MinWidth); sz != "" {
|
||||
sizeCls = " " + sz
|
||||
}
|
||||
cellClass := func() string {
|
||||
if col.CellClassFn != nil {
|
||||
return col.CellClassFn(row) + sizeCls
|
||||
}
|
||||
base := col.CellClass
|
||||
if base == "" {
|
||||
base = readonlyTdCls
|
||||
}
|
||||
return base + sizeCls
|
||||
}
|
||||
|
||||
// Custom-rendered, non-editable cell.
|
||||
if col.Render != nil && !col.Editable {
|
||||
mods := []vdom.Mod{vdom.Attr("class", cellClass())}
|
||||
if col.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { col.OnClick(row) }))
|
||||
}
|
||||
mods = append(mods, col.Render(row))
|
||||
return vdom.El("td", mods...)
|
||||
}
|
||||
|
||||
// Read-only cell.
|
||||
if col.ReadOnly {
|
||||
mods := []vdom.Mod{vdom.Attr("class", cellClass())}
|
||||
if col.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { col.OnClick(row) }))
|
||||
}
|
||||
if col.Render != nil {
|
||||
mods = append(mods, col.Render(row))
|
||||
} else {
|
||||
mods = append(mods, vdom.Text(cellGridStr(row[col.Key])))
|
||||
}
|
||||
return vdom.El("td", mods...)
|
||||
}
|
||||
|
||||
// Editable cell.
|
||||
inList := fieldInConflictList(col.Key)
|
||||
hasConflict := isConflict(col.Key, row[col.Key])
|
||||
tdCls := editableTdCls + sizeCls
|
||||
if inList {
|
||||
tdCls += " relative"
|
||||
if hasConflict {
|
||||
tdCls += " bg-amber-100"
|
||||
}
|
||||
}
|
||||
im := col.InputMode
|
||||
if im == "" {
|
||||
im = "text"
|
||||
}
|
||||
input := vdom.El("input",
|
||||
vdom.Attr("class", inputCls),
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("inputmode", im),
|
||||
vdom.Attr("placeholder", col.Placeholder),
|
||||
vdom.Prop("value", cellGridStr(row[col.Key])),
|
||||
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) {
|
||||
var v any = e.Value()
|
||||
if col.Parse != nil {
|
||||
v = col.Parse(e.Value())
|
||||
}
|
||||
if p.OnCellChange != nil {
|
||||
p.OnCellChange(rowID, col.Key, v)
|
||||
}
|
||||
}),
|
||||
)
|
||||
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
|
||||
if inList && hasConflict {
|
||||
mods = append(mods, vdom.El("span",
|
||||
vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
|
||||
vdom.Attr("title", "Duplicate value"),
|
||||
Icon("triangle-exclamation", 12, ""),
|
||||
))
|
||||
}
|
||||
return vdom.El("td", mods...)
|
||||
}
|
||||
|
||||
var headerCells []*vdom.VNode
|
||||
for _, col := range p.Columns {
|
||||
headerCells = append(headerCells, renderHeader(col))
|
||||
}
|
||||
thead := vdom.El("thead", vdom.El("tr", kids(nil, headerCells)...))
|
||||
|
||||
var bodyRows []*vdom.VNode
|
||||
for _, row := range cellGridSortedRows(p) {
|
||||
var cells []*vdom.VNode
|
||||
for _, col := range p.Columns {
|
||||
cells = append(cells, renderCell(row, col))
|
||||
}
|
||||
bodyRows = append(bodyRows, vdom.El("tr",
|
||||
kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
|
||||
}
|
||||
tbody := vdom.El("tbody", kids(nil, bodyRows)...)
|
||||
|
||||
tableCls := "min-w-full w-max border-collapse text-sm"
|
||||
if p.Dense {
|
||||
tableCls = "min-w-full w-max border-collapse text-xs"
|
||||
}
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
|
||||
vdom.El("table", vdom.Attr("class", tableCls), thead, tbody),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user