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), ) }