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
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 | 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.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.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.Div(inner...))
return vdom.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.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.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.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.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.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.Td(mods...)
}
var headerCells []*vdom.VNode
for _, col := range p.Columns {
headerCells = append(headerCells, renderHeader(col))
}
thead := vdom.Thead(vdom.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.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
}
tbody := vdom.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.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
)
}
|