476 lines
13 KiB
Go
476 lines
13 KiB
Go
package webui
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"kjol/wasmruntime/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-line-strong bg-surface-muted px-1.5 py-1.5 text-left text-ss font-bold uppercase text-ink 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-surface-strong"
|
|
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-line-strong bg-black/5 px-2 text-ink-soft align-middle " + rowHCls
|
|
const editableTdCls = "border-b border-r border-line-strong p-0 relative align-middle"
|
|
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-ink-faint focus:bg-red-50 dark:bg-red-950 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 dark:bg-amber-900"
|
|
}
|
|
}
|
|
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 dark:text-amber-400"),
|
|
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-surface even:bg-surface-raised")}, 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-ss"
|
|
}
|
|
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-line-strong rounded-default bg-surface tabular-nums"),
|
|
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
|
|
)
|
|
}
|