Add landing page for kjol, documentation
This commit is contained in:
@@ -11,14 +11,14 @@ import "kjol/vdom"
|
||||
// The TSX startOpen prop collapses into the caller-supplied initial IsOpen /
|
||||
// open[] / openIndex.
|
||||
|
||||
const accordionRootCls = "ui-accordion border border-neutral-200 rounded-default overflow-hidden"
|
||||
const accordionItemCls = "ui-accordion-item border-b border-neutral-200 last:border-b-0"
|
||||
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50"
|
||||
const accordionRootCls = "ui-accordion border border-line rounded-default overflow-hidden"
|
||||
const accordionItemCls = "ui-accordion-item border-b border-line last:border-b-0"
|
||||
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-ink bg-surface-muted cursor-pointer border-none transition-colors hover:bg-surface-raised active:bg-surface-strong focus:outline-hidden disabled:text-ink-faint disabled:cursor-not-allowed disabled:bg-surface-muted"
|
||||
const accordionTitleCls = "ui-accordion-title flex-1"
|
||||
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700"
|
||||
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-ink-soft"
|
||||
|
||||
func accordionIconCls(open bool) string {
|
||||
c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200"
|
||||
c := "ui-accordion-icon text-ink-muted leading-none transition-transform duration-200"
|
||||
if open {
|
||||
c += " rotate-180"
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ const (
|
||||
const alertBase = "p-4 rounded-default shadow-xs border"
|
||||
|
||||
var alertColors = map[string]string{
|
||||
"white": "bg-white border-neutral-100",
|
||||
"gray": "bg-neutral-50 border-neutral-200",
|
||||
"blue": "bg-sky-50 border-sky-200",
|
||||
"green": "bg-green-50 border-green-200",
|
||||
"red": "bg-red-50 border-red-200",
|
||||
"yellow": "bg-yellow-50 border-yellow-200",
|
||||
"white": "bg-surface border-line",
|
||||
"gray": "bg-surface-muted border-line",
|
||||
"blue": "bg-sky-50 dark:bg-sky-950 border-sky-200 dark:border-sky-900",
|
||||
"green": "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900",
|
||||
"red": "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-900",
|
||||
"yellow": "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-900",
|
||||
}
|
||||
|
||||
// Alert renders a colored callout. header is optional (rendered as a bold title
|
||||
|
||||
@@ -80,7 +80,7 @@ const (
|
||||
|
||||
// HEADER_COLOR_CLS is the background + text color per header color.
|
||||
var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-surface-muted",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
|
||||
@@ -89,7 +89,7 @@ var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
|
||||
|
||||
// atHeaderSortHoverCls is the sortable-hover override per color.
|
||||
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
|
||||
@@ -107,7 +107,7 @@ var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
|
||||
|
||||
// atHeaderSortIconCls is the sort-icon color (matches header text) per color.
|
||||
var atHeaderSortIconCls = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-ink",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
|
||||
@@ -137,11 +137,11 @@ var atPaginationPaddingCls = map[AutoTableSize]string{
|
||||
|
||||
// atRowHoverCls is the body-row hover background per color (when hover is on).
|
||||
var atRowHoverCls = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100 dark:bg-green-900",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-surface-strong",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
|
||||
}
|
||||
|
||||
// POS_CLS is the text alignment per ColumnPosition.
|
||||
@@ -160,7 +160,7 @@ var HEADER_INNER_POS = map[ColumnPosition]string{
|
||||
|
||||
// -- Class string constants for parts that don't vary by config ------------
|
||||
const (
|
||||
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
|
||||
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
|
||||
TBL_WRAPPER = "overflow-x-auto w-full"
|
||||
TBL_BASE = "min-w-full"
|
||||
|
||||
@@ -169,20 +169,20 @@ const (
|
||||
|
||||
atSortIconWrap = "leading-none shrink-0 opacity-50"
|
||||
|
||||
atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse"
|
||||
atErrorCell = "text-center text-red-600"
|
||||
atEmptyCell = "text-center text-neutral-500"
|
||||
atSkeleton = "h-4 bg-surface-strong rounded-default animate-pulse"
|
||||
atErrorCell = "text-center text-red-600 dark:text-red-400"
|
||||
atEmptyCell = "text-center text-ink-muted"
|
||||
|
||||
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
|
||||
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
|
||||
atPaginationBase = "flex justify-between items-center border-t border-line-strong"
|
||||
atPaginationInfo = "hidden sm:flex items-center text-sm text-ink-muted"
|
||||
atPaginationControls = "flex items-center"
|
||||
atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2"
|
||||
atPaginationPage = "text-sm text-neutral-500 px-3"
|
||||
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
atPaginationLabel = "hidden sm:block text-sm text-ink-muted mr-2"
|
||||
atPaginationPage = "text-sm text-ink-muted px-3"
|
||||
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
|
||||
// thead sticky classes are the STATIC replacement for the TSX's JS-driven
|
||||
// header pinning (transform tracking on scroll). See file-level NOTE.
|
||||
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300"
|
||||
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-line-strong"
|
||||
)
|
||||
|
||||
// AutoTableColumn describes one column: its header, alignment, width, whether it
|
||||
@@ -475,7 +475,7 @@ func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdo
|
||||
|
||||
containerCls := TBL_CONTAINER
|
||||
if cfg.surroundingBorder {
|
||||
containerCls = cx(containerCls, "border border-neutral-300")
|
||||
containerCls = cx(containerCls, "border border-line-strong")
|
||||
}
|
||||
if cfg.shadow {
|
||||
containerCls = cx(containerCls, "shadow-sm")
|
||||
@@ -754,7 +754,7 @@ func atResizeHandle(key string, h *atColumnHooks) *vdom.VNode {
|
||||
func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode {
|
||||
bodyCls := BODY_PADDING_CLS[cfg.size]
|
||||
if cfg.borderY {
|
||||
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||||
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-line-strong")
|
||||
}
|
||||
|
||||
colspan := atTotalColumns(cols, cfg)
|
||||
@@ -768,7 +768,7 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
|
||||
for r := 0; r < 5; r++ {
|
||||
tr := vdom.Tr()
|
||||
if cfg.alternate && r%2 == 1 {
|
||||
tr.Attrs["class"] = "bg-neutral-100"
|
||||
tr.Attrs["class"] = "bg-surface-raised"
|
||||
}
|
||||
if cfg.accordion {
|
||||
tr.Children = append(tr.Children, vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL)))
|
||||
@@ -799,11 +799,11 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
|
||||
isLast := rowIdx == len(rows)-1
|
||||
rowCls := ""
|
||||
if cfg.alternate && rowIdx%2 == 1 {
|
||||
rowCls = cx(rowCls, "bg-neutral-100")
|
||||
rowCls = cx(rowCls, "bg-surface-raised")
|
||||
}
|
||||
rowCls = cx(rowCls, hoverCls)
|
||||
if cfg.borderX && !isLast {
|
||||
rowCls = cx(rowCls, "border-b border-neutral-300")
|
||||
rowCls = cx(rowCls, "border-b border-line-strong")
|
||||
}
|
||||
if cfg.highlight != nil && cfg.highlight(row) {
|
||||
rowCls = cx(rowCls, AUTOTABLE_HIGHLIGHT_ROW)
|
||||
@@ -2182,14 +2182,14 @@ func (s *AutoTableState) FiltersToggle() *vdom.VNode {
|
||||
const (
|
||||
AUTOTABLE_SEARCH_FIELDS = "flex flex-wrap items-end gap-2"
|
||||
AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 grow sm:grow-0 sm:w-48"
|
||||
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-neutral-300 bg-white px-3 py-2 text-sm"
|
||||
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-neutral-300 px-3 py-2 text-sm"
|
||||
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-line-strong bg-surface px-3 py-2 text-sm"
|
||||
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-line-strong px-3 py-2 text-sm"
|
||||
AUTOTABLE_FILTERS_BADGE = "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-xs font-semibold text-white"
|
||||
AUTOTABLE_TOOLBAR = "flex flex-wrap items-end justify-between gap-3 pb-3"
|
||||
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-neutral-300 bg-white p-3"
|
||||
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-line-strong bg-surface p-3"
|
||||
AUTOTABLE_ACCORDION_CELL = "w-10 text-center"
|
||||
AUTOTABLE_ACCORDION_ROW = "bg-neutral-50"
|
||||
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
|
||||
AUTOTABLE_ACCORDION_ROW = "bg-surface-muted"
|
||||
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100 dark:bg-amber-900! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
@@ -2602,7 +2602,7 @@ func (s *AutoTableState) pendingSkeleton() *vdom.VNode {
|
||||
vdom.Attr("aria-label", "Loading table"),
|
||||
vdom.Div(
|
||||
kids([]vdom.Mod{
|
||||
vdom.Attr("class", cx(TBL_CONTAINER, "border border-neutral-300 p-4 flex flex-col gap-3")),
|
||||
vdom.Attr("class", cx(TBL_CONTAINER, "border border-line-strong p-4 flex flex-col gap-3")),
|
||||
}, bars)...,
|
||||
),
|
||||
)
|
||||
@@ -2718,6 +2718,7 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
total := len(options)
|
||||
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
|
||||
Options: options,
|
||||
Value: selected,
|
||||
@@ -2725,6 +2726,11 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
FieldWidth: "w-52",
|
||||
// Not "5 items selected": this picker is about COLUMNS, and the count is only
|
||||
// meaningful next to how many there are to choose from.
|
||||
CollapsedLabel: func(n int) string {
|
||||
return strconv.Itoa(n) + " of " + strconv.Itoa(total) + " columns"
|
||||
},
|
||||
OnChange: func(visible []string) {
|
||||
show := map[string]bool{}
|
||||
for _, k := range visible {
|
||||
@@ -2779,7 +2785,7 @@ func (s *AutoTableState) ResetMenu() *vdom.VNode {
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-neutral-500"),
|
||||
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-ink-muted"),
|
||||
vdom.Text("Nothing to reset"),
|
||||
))
|
||||
} else {
|
||||
@@ -3091,7 +3097,7 @@ func (s *AutoTableState) summaryFoot(ctx *CalcContext, colCount int) *vdom.VNode
|
||||
}
|
||||
|
||||
// AUTOTABLE_TFOOT styles the summary footer.
|
||||
const AUTOTABLE_TFOOT = "border-t-2 border-neutral-300 bg-neutral-50 [&_td]:p-3"
|
||||
const AUTOTABLE_TFOOT = "border-t-2 border-line-strong bg-surface-muted [&_td]:p-3"
|
||||
|
||||
// ---- the calculated-column editor ----
|
||||
|
||||
@@ -3127,16 +3133,16 @@ func isBinaryCalcFn(fn CalculatedFunction) bool {
|
||||
// their metrics line up to the pixel; if they ever drift, the caret stops landing on
|
||||
// the glyph it appears to be on.
|
||||
const (
|
||||
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100 py-1 px-3"
|
||||
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised py-1 px-3"
|
||||
|
||||
FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre"
|
||||
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-neutral-800"
|
||||
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-neutral-300 focus:border-sky-500 outline-hidden"
|
||||
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-ink"
|
||||
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden"
|
||||
|
||||
formulaPlaceholder = `<span class="text-neutral-400">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
|
||||
formulaPlaceholder = `<span class="text-ink-faint">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
|
||||
|
||||
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-neutral-300 bg-neutral-50 px-2 py-1 text-xs leading-none text-neutral-700 cursor-pointer hover:bg-neutral-100"
|
||||
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
|
||||
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-line-strong bg-surface-muted px-2 py-1 text-xs leading-none text-ink-soft cursor-pointer hover:bg-surface-raised"
|
||||
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
|
||||
)
|
||||
|
||||
// HighlightFormula renders a formula as HTML with syntax colouring: cell refs
|
||||
@@ -3145,6 +3151,20 @@ const (
|
||||
// Purely visual, and deliberately LEXICAL rather than a real parse — it has to
|
||||
// colour a half-typed formula that does not compile yet, which is exactly when the
|
||||
// colours are worth the most.
|
||||
// The formula highlighter's token colours.
|
||||
//
|
||||
// Named, rather than written inline, because they are asserted on in tests — and a
|
||||
// colour spelled out in two places is a colour that gets changed in one. (It already
|
||||
// happened: adding the dark: variants broke every highlighter test, none of which cared
|
||||
// about the colour, all of which had a copy of it.)
|
||||
const (
|
||||
fmlCellClass = "text-sky-600 dark:text-sky-400" // [Cell]
|
||||
fmlColumnClass = "text-violet-600 dark:text-violet-400" // {Column}
|
||||
fmlNumberClass = "text-amber-600 dark:text-amber-400" // 42, PI
|
||||
fmlFuncClass = "text-emerald-700 dark:text-emerald-400 font-semibold" // SUM(
|
||||
fmlOpClass = "text-ink-faint" // + - * /
|
||||
)
|
||||
|
||||
func HighlightFormula(src string) string {
|
||||
var b strings.Builder
|
||||
i := 0
|
||||
@@ -3159,7 +3179,7 @@ func HighlightFormula(src string) string {
|
||||
if j >= 0 {
|
||||
end = i + j + 1
|
||||
}
|
||||
b.WriteString(`<span class="text-sky-600">`)
|
||||
b.WriteString(`<span class="` + fmlCellClass + `">`)
|
||||
b.WriteString(html.EscapeString(src[i:end]))
|
||||
b.WriteString(`</span>`)
|
||||
i = end
|
||||
@@ -3176,7 +3196,7 @@ func HighlightFormula(src string) string {
|
||||
}
|
||||
j++
|
||||
}
|
||||
b.WriteString(`<span class="text-violet-600">`)
|
||||
b.WriteString(`<span class="` + fmlColumnClass + `">`)
|
||||
b.WriteString(html.EscapeString(src[i:j]))
|
||||
b.WriteString(`</span>`)
|
||||
i = j
|
||||
@@ -3186,7 +3206,7 @@ func HighlightFormula(src string) string {
|
||||
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
|
||||
j++
|
||||
}
|
||||
b.WriteString(`<span class="text-amber-600">`)
|
||||
b.WriteString(`<span class="` + fmlNumberClass + `">`)
|
||||
b.WriteString(html.EscapeString(src[i:j]))
|
||||
b.WriteString(`</span>`)
|
||||
i = j
|
||||
@@ -3205,11 +3225,11 @@ func HighlightFormula(src string) string {
|
||||
}
|
||||
switch {
|
||||
case k < len(src) && src[k] == '(':
|
||||
b.WriteString(`<span class="text-emerald-700 font-semibold">`)
|
||||
b.WriteString(`<span class="` + fmlFuncClass + `">`)
|
||||
b.WriteString(html.EscapeString(word))
|
||||
b.WriteString(`</span>`)
|
||||
case isFormulaConstant(word):
|
||||
b.WriteString(`<span class="text-amber-600">`)
|
||||
b.WriteString(`<span class="` + fmlNumberClass + `">`)
|
||||
b.WriteString(html.EscapeString(word))
|
||||
b.WriteString(`</span>`)
|
||||
default:
|
||||
@@ -3218,7 +3238,7 @@ func HighlightFormula(src string) string {
|
||||
i = j
|
||||
|
||||
case strings.IndexByte("+-*/^%=<>(),:", c) >= 0:
|
||||
b.WriteString(`<span class="text-neutral-400">`)
|
||||
b.WriteString(`<span class="` + fmlOpClass + `">`)
|
||||
b.WriteString(html.EscapeString(string(c)))
|
||||
b.WriteString(`</span>`)
|
||||
i++
|
||||
@@ -3590,7 +3610,7 @@ func (s *AutoTableState) calcChooser(e *calcEditor) *vdom.VNode {
|
||||
))
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-neutral-200 pb-2 mb-1")}, existing)...))
|
||||
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-line pb-2 mb-1")}, existing)...))
|
||||
}
|
||||
|
||||
children = append(children,
|
||||
@@ -3607,19 +3627,19 @@ func calcChooserButton(icon, title, subtitle string, onClick func()) *vdom.VNode
|
||||
return vdom.Button(vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calcChooserItem),
|
||||
vdom.On(vdom.EVENT_CLICK, onClick),
|
||||
Icon(icon, 16, "mt-0.5 text-neutral-500"),
|
||||
Icon(icon, 16, "mt-0.5 text-ink-muted"),
|
||||
vdom.Span(vdom.Attr("class", "flex flex-col"),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(subtitle)),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(subtitle)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func calcListRow(title, subtitle string, onEdit, onDelete func()) *vdom.VNode {
|
||||
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-neutral-50"),
|
||||
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-surface-muted"),
|
||||
vdom.Div(vdom.Attr("class", "min-w-0 grow"),
|
||||
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||||
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-neutral-500"), vdom.Text(subtitle)),
|
||||
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-ink"), vdom.Text(title)),
|
||||
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-ink-muted"), vdom.Text(subtitle)),
|
||||
),
|
||||
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "pencil", Title: "Edit", OnClick: onEdit}),
|
||||
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "trash", Title: "Delete", OnClick: onDelete}),
|
||||
@@ -3712,14 +3732,14 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
|
||||
title = "Edit " + strings.ToLower(title)
|
||||
}
|
||||
|
||||
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-neutral-200 px-3 py-2"),
|
||||
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-line px-3 py-2"),
|
||||
vdom.Button(vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent"),
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-ink-muted hover:bg-surface-raised border-0 bg-transparent"),
|
||||
vdom.Attr("aria-label", "Back"),
|
||||
vdom.On(vdom.EVENT_CLICK, e.reset),
|
||||
Icon("chevron-left", 14, ""),
|
||||
),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
|
||||
// Basic vs Advanced.
|
||||
vdom.Div(vdom.Attr("class", "ml-auto"),
|
||||
SegmentedButtons([]SegmentedButtonOption{
|
||||
@@ -3773,7 +3793,7 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
|
||||
}
|
||||
|
||||
if msg := e.errorMsg.Get(); msg != "" {
|
||||
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(msg)))
|
||||
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(msg)))
|
||||
}
|
||||
|
||||
saveLabel := "Add"
|
||||
@@ -3877,7 +3897,7 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
|
||||
})))
|
||||
}
|
||||
|
||||
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
|
||||
vdom.Text(calcBasicHint(e, summary))))
|
||||
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2")}, children)...)
|
||||
@@ -3908,12 +3928,12 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
label := o.Label
|
||||
colItems = append(colItems,
|
||||
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("[" + label + "]") }},
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-sky-600"), vdom.Text("["+label+"]")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("this row")),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-sky-600 dark:text-sky-400"), vdom.Text("["+label+"]")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("this row")),
|
||||
),
|
||||
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("{" + label + "}") }},
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-violet-600"), vdom.Text("{"+label+"}")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("whole column")),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-violet-600 dark:text-violet-400"), vdom.Text("{"+label+"}")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("whole column")),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -3925,7 +3945,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
groups := filterFormulaGroups(e.fnSearch.Get())
|
||||
if len(groups) == 0 {
|
||||
fnItems = append(fnItems, vdom.Div(
|
||||
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
|
||||
vdom.Attr("class", "px-2 py-3 text-center text-xs text-ink-muted"),
|
||||
vdom.Text("No functions match"),
|
||||
))
|
||||
}
|
||||
@@ -3940,7 +3960,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
// The signature is syntax-highlighted with the same highlighter the
|
||||
// formula box uses, so the menu and the editor speak one language.
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(f.Desc)),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -3953,8 +3973,8 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
OnClick: func() { e.insertAtCaret(cc.Name) },
|
||||
},
|
||||
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600 dark:text-amber-400"), vdom.Text(cc.Name)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(cc.Desc)),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -4008,7 +4028,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
),
|
||||
)
|
||||
|
||||
help := vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
help := vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
|
||||
vdom.Text("[Column] is this row's cell · {Column} is the whole column · {Column:1:ROW()} is a running total."))
|
||||
|
||||
children := []*vdom.VNode{menus, editor, help}
|
||||
@@ -4024,11 +4044,11 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
func (e *calcEditor) fnSearchBox() *vdom.VNode {
|
||||
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
|
||||
|
||||
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
|
||||
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-line bg-surface p-1.5"),
|
||||
vdom.Input(
|
||||
vdom.WithRef(e.fnSearchRef),
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
|
||||
vdom.Attr("class", "w-full rounded-default border border-line-strong p-1 text-xs focus:outline-2 focus:outline-sky-500"),
|
||||
vdom.Attr("placeholder", "Search functions…"),
|
||||
vdom.Attr("spellcheck", "false"),
|
||||
vdom.Prop("value", e.fnSearch.Get()),
|
||||
@@ -4069,12 +4089,12 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
|
||||
return nil
|
||||
}
|
||||
if _, err := CompileFormula(src); err != nil {
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
|
||||
}
|
||||
|
||||
rows := s.FilteredRows()
|
||||
if len(rows) == 0 {
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text("Formula is valid."))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text("Formula is valid."))
|
||||
}
|
||||
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
|
||||
|
||||
@@ -4087,10 +4107,10 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
|
||||
|
||||
n, err := EvalFormula(src, target)
|
||||
if err != nil {
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
|
||||
}
|
||||
out := FormatCalcResult(n, CalculatedDataType(e.dataType.Get()), calcPrecisionOf(e.precision.Get()), "", "", "")
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(prefix+out))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(prefix+out))
|
||||
}
|
||||
|
||||
// insertAtCaret drops text where the cursor is, rather than at the end — which is
|
||||
@@ -4121,7 +4141,7 @@ func (e *calcEditor) insertAtCaretOffset(text string, back int) {
|
||||
|
||||
func calcField(label string, children ...*vdom.VNode) *vdom.VNode {
|
||||
nodes := []*vdom.VNode{
|
||||
vdom.Span(vdom.Attr("class", "text-xs font-medium text-neutral-600"), vdom.Text(label)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs font-medium text-ink-soft"), vdom.Text(label)),
|
||||
}
|
||||
nodes = append(nodes, children...)
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1")}, nodes)...)
|
||||
|
||||
@@ -362,17 +362,17 @@ func TestHighlightFormula(t *testing.T) {
|
||||
src string
|
||||
want []string // fragments that must appear
|
||||
}{
|
||||
{"cell ref", "[Revenue]", []string{`<span class="text-sky-600">[Revenue]</span>`}},
|
||||
{"column ref", "{Revenue}", []string{`<span class="text-violet-600">{Revenue}</span>`}},
|
||||
{"cell ref", "[Revenue]", []string{`<span class="` + fmlCellClass + `">[Revenue]</span>`}},
|
||||
{"column ref", "{Revenue}", []string{`<span class="` + fmlColumnClass + `">{Revenue}</span>`}},
|
||||
{"nested braces stay one span", "{Revenue:1:ROW()}",
|
||||
[]string{`<span class="text-violet-600">{Revenue:1:ROW()}</span>`}},
|
||||
[]string{`<span class="` + fmlColumnClass + `">{Revenue:1:ROW()}</span>`}},
|
||||
{"function", "SUM(",
|
||||
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
|
||||
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
|
||||
{"function with a space before the paren", "SUM (",
|
||||
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
|
||||
{"number", "12.5", []string{`<span class="text-amber-600">12.5</span>`}},
|
||||
{"constant", "PI", []string{`<span class="text-amber-600">PI</span>`}},
|
||||
{"operators", "1 + 2", []string{`<span class="text-neutral-400">+</span>`}},
|
||||
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
|
||||
{"number", "12.5", []string{`<span class="` + fmlNumberClass + `">12.5</span>`}},
|
||||
{"constant", "PI", []string{`<span class="` + fmlNumberClass + `">PI</span>`}},
|
||||
{"operators", "1 + 2", []string{`<span class="` + fmlOpClass + `">+</span>`}},
|
||||
{"a bare word is not coloured", "Revenue", []string{"Revenue"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -455,7 +455,7 @@ func TestEditorOpensOnTheChooser(t *testing.T) {
|
||||
e.advanced.Set(true)
|
||||
e.formula.Set("[Gross revenue] * 12")
|
||||
adv := renderNode(s.calcForm(e, false))
|
||||
if !strings.Contains(adv, `<span class="text-sky-600">[Gross revenue]</span>`) {
|
||||
if !strings.Contains(adv, `<span class="`+fmlCellClass+`">[Gross revenue]</span>`) {
|
||||
t.Errorf("the formula is not syntax-highlighted:\n%s", adv)
|
||||
}
|
||||
for _, want := range []string{"Column", "Function", "Constant"} {
|
||||
|
||||
@@ -21,7 +21,7 @@ var badgeColors = map[string]string{
|
||||
"blue": "text-white bg-sky-800",
|
||||
"amber": "text-white bg-amber-700",
|
||||
"neutral": "text-white bg-neutral-500",
|
||||
"muted": "text-neutral-400 bg-transparent",
|
||||
"muted": "text-ink-faint bg-transparent",
|
||||
}
|
||||
|
||||
// BadgeProps configures Badge. When OnClick is set the badge renders as a
|
||||
|
||||
@@ -25,8 +25,8 @@ const btnBase = "inline-flex items-center justify-center gap-2 cursor-pointer te
|
||||
|
||||
var btnColors = map[string]string{
|
||||
"neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800",
|
||||
"white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50",
|
||||
"light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100",
|
||||
"white": "shadow-xs bg-surface text-ink border border-line-strong hover:bg-surface-muted",
|
||||
"light-neutral": "shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised",
|
||||
"blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800",
|
||||
"dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950",
|
||||
"green": "shadow-xs bg-green-700 text-white hover:bg-green-800",
|
||||
@@ -36,23 +36,23 @@ var btnColors = map[string]string{
|
||||
"yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800",
|
||||
"orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700",
|
||||
"primary": "shadow-xs bg-primary text-white hover:bg-primary-hover",
|
||||
"secondary": "shadow-none bg-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200",
|
||||
"ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100",
|
||||
"secondary": "shadow-none bg-surface-raised text-ink-soft border border-line-strong hover:bg-surface-strong",
|
||||
"ghost": "shadow-none bg-transparent text-ink-soft border-none hover:bg-surface-raised",
|
||||
}
|
||||
|
||||
var btnOutlineColors = map[string]string{
|
||||
"neutral": "text-neutral-700",
|
||||
"white": "text-neutral-300",
|
||||
"light-neutral": "text-neutral-300",
|
||||
"blue": "text-sky-700",
|
||||
"neutral": "text-ink-soft",
|
||||
"white": "text-ink-faint",
|
||||
"light-neutral": "text-ink-faint",
|
||||
"blue": "text-sky-700 dark:text-sky-400",
|
||||
"dark-blue": "text-sky-900",
|
||||
"green": "text-green-700",
|
||||
"green": "text-green-700 dark:text-green-400",
|
||||
"dark-green": "text-green-900",
|
||||
"red": "text-red-700",
|
||||
"dark-red": "text-red-900",
|
||||
"yellow": "text-yellow-700",
|
||||
"orange": "text-orange-600",
|
||||
"primary": "text-primary",
|
||||
"primary": "text-accent",
|
||||
}
|
||||
|
||||
const btnOutlineBase = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]"
|
||||
@@ -139,7 +139,7 @@ func iconSize(small bool) int {
|
||||
func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 hover:underline"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 dark:text-sky-400 hover:underline"),
|
||||
}
|
||||
if onClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||
@@ -151,7 +151,7 @@ func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
func ButtonLinkRed(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 hover:underline"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 dark:text-red-400 hover:underline"),
|
||||
}
|
||||
if onClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||
@@ -174,10 +174,10 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
|
||||
sizeCls = "py-0.5 px-2 text-xs"
|
||||
}
|
||||
const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
const activeCls = "bg-white text-text-heading shadow-sm"
|
||||
const inactiveCls = "text-neutral-500 hover:text-neutral-700"
|
||||
const activeCls = "bg-surface text-text-heading shadow-sm"
|
||||
const inactiveCls = "text-ink-muted hover:text-ink-soft"
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-neutral-100 p-0.5", class))}
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-surface-raised p-0.5", class))}
|
||||
for _, opt := range options {
|
||||
state := inactiveCls
|
||||
if opt.Value == value {
|
||||
@@ -201,7 +201,7 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
|
||||
|
||||
// BackLink is an inline chevron-left anchor. onDark switches to on-dark colors.
|
||||
func BackLink(href, text string, onDark bool) *vdom.VNode {
|
||||
color := "text-neutral-600 hover:text-neutral-900"
|
||||
color := "text-ink-soft hover:text-ink"
|
||||
if onDark {
|
||||
color = "text-text-on-dark-muted hover:text-text-on-dark"
|
||||
}
|
||||
|
||||
@@ -26,18 +26,18 @@ var calMonthsShort = []string{
|
||||
// exported from Calendar.tsx; here they stay unexported with a cal prefix so the
|
||||
// two ported files can share them without widening the package's public API. --
|
||||
const calPickerRoot = "p-2 min-w-[240px]"
|
||||
const calMonthRoot = "p-0 min-w-0 w-full bg-white border border-neutral-200 rounded-default shadow-sm overflow-hidden"
|
||||
const calMonthRoot = "p-0 min-w-0 w-full bg-surface border border-line rounded-default shadow-sm overflow-hidden"
|
||||
|
||||
const calHeaderPicker = "flex items-center justify-between mb-2 gap-1"
|
||||
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-neutral-200 bg-neutral-50"
|
||||
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-line bg-surface-muted"
|
||||
|
||||
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body"
|
||||
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-surface-raised hover:text-text-body"
|
||||
|
||||
const calMyPicker = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap"
|
||||
const calMyMonth = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading whitespace-nowrap"
|
||||
|
||||
const calWeekdaysPicker = "grid grid-cols-7 gap-[2px] mb-1"
|
||||
const calWeekdaysMonth = "grid grid-cols-7 border-b border-neutral-200"
|
||||
const calWeekdaysMonth = "grid grid-cols-7 border-b border-line"
|
||||
const calWeekdayPicker = "text-center text-xs font-semibold text-text-muted p-1"
|
||||
const calWeekdayMonth = "text-center text-xs font-semibold text-text-muted p-2 uppercase tracking-wider"
|
||||
|
||||
@@ -45,9 +45,9 @@ const calDaysPicker = "grid grid-cols-7 gap-[2px]"
|
||||
const calDaysMonth = "grid grid-cols-7"
|
||||
|
||||
const calDayPickerBase = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-text-body p-0"
|
||||
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-neutral-200 text-left gap-1 text-xs bg-transparent cursor-pointer"
|
||||
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-line text-left gap-1 text-xs bg-transparent cursor-pointer"
|
||||
|
||||
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-neutral-200 rounded-sm bg-white text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
|
||||
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-line rounded-sm bg-surface text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
|
||||
|
||||
// Calendar variants.
|
||||
const (
|
||||
@@ -123,10 +123,10 @@ func calDayClassPicker(empty, selected, today bool) string {
|
||||
if empty {
|
||||
c = cx(c, "cursor-default")
|
||||
} else {
|
||||
c = cx(c, "hover:bg-neutral-100")
|
||||
c = cx(c, "hover:bg-surface-raised")
|
||||
}
|
||||
if today {
|
||||
c = cx(c, "font-bold text-primary")
|
||||
c = cx(c, "font-bold text-accent")
|
||||
}
|
||||
if selected {
|
||||
c = cx(c, "!bg-primary !text-white")
|
||||
@@ -142,9 +142,9 @@ func calDayClassMonth(idx int, empty, selected bool) string {
|
||||
c = cx(c, "border-r-0")
|
||||
}
|
||||
if empty {
|
||||
c = cx(c, "bg-neutral-50 cursor-default")
|
||||
c = cx(c, "bg-surface-muted cursor-default")
|
||||
} else {
|
||||
c = cx(c, "hover:bg-neutral-50")
|
||||
c = cx(c, "hover:bg-surface-muted")
|
||||
}
|
||||
if selected {
|
||||
c = cx(c, "bg-primary/10 text-text-body")
|
||||
|
||||
@@ -5,15 +5,15 @@ import "kjol/vdom"
|
||||
// Port of web/kit/Cards.tsx. The `ui-card`/`no-flex` marker classes are kept so
|
||||
// any page CSS targeting them still applies; baseline styling is Tailwind.
|
||||
|
||||
const cardBase = "ui-card bg-white shadow-sm rounded-default w-full"
|
||||
const cardBase = "ui-card bg-surface shadow-sm rounded-default w-full"
|
||||
const cardWithPadding = cardBase + " p-5 flex-1"
|
||||
const cardNoFlex = cardBase + " no-flex p-5"
|
||||
const cardNoPaddingNoFlex = cardBase + " no-padding no-flex"
|
||||
const borderCard = "border border-neutral-300 rounded-default p-5 w-full"
|
||||
const borderCard = "border border-line-strong rounded-default p-5 w-full"
|
||||
|
||||
// cutCornerCard uses two clip-path pseudo-elements to notch opposite corners.
|
||||
const cutCornerCard = "relative isolate p-5 w-full " +
|
||||
"before:content-[''] before:absolute before:inset-0 before:bg-neutral-300 before:-z-20 " +
|
||||
"before:content-[''] before:absolute before:inset-0 before:bg-surface-strong before:-z-20 " +
|
||||
"before:[clip-path:polygon(16px_0,100%_0,100%_calc(100%_-_16px),calc(100%_-_16px)_100%,0_100%,0_16px)] " +
|
||||
"after:content-[''] after:absolute after:inset-[1px] after:bg-white after:-z-10 " +
|
||||
"after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]"
|
||||
@@ -47,7 +47,7 @@ func BorderCutCornerCard(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return cardDiv(cutCornerCard, class, children)
|
||||
}
|
||||
|
||||
const cardHeader = "text-xl tracking-tight text-black mb-5"
|
||||
const cardHeader = "text-xl tracking-tight text-ink mb-5"
|
||||
const cardHeaderHR = "text-neutral-200 mt-1 mb-3"
|
||||
|
||||
// CardHeader renders a card title followed by a divider.
|
||||
@@ -66,7 +66,7 @@ func CardHeaderTextCenter(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
|
||||
// CardSubheader renders a smaller secondary heading.
|
||||
func CardSubheader(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-ink mb-2", class))}, children)...)
|
||||
}
|
||||
|
||||
// CardSpacer is vertical spacing between cards.
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
|
||||
// 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"
|
||||
const GridHeaderCls = "border-b border-r border-line-strong bg-surface-muted px-1.5 py-1.5 text-left text-xs 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+)?`)
|
||||
@@ -185,7 +185,7 @@ type SortableHeaderProps struct {
|
||||
// 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"
|
||||
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-surface-strong"
|
||||
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
|
||||
cls += " " + sz
|
||||
}
|
||||
@@ -311,9 +311,9 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
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)]"
|
||||
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 {
|
||||
@@ -417,7 +417,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
if inList {
|
||||
tdCls += " relative"
|
||||
if hasConflict {
|
||||
tdCls += " bg-amber-100"
|
||||
tdCls += " bg-amber-100 dark:bg-amber-900"
|
||||
}
|
||||
}
|
||||
im := col.InputMode
|
||||
@@ -441,7 +441,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
)
|
||||
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"),
|
||||
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, ""),
|
||||
))
|
||||
@@ -461,7 +461,7 @@ func CellGrid(p CellGridProps) *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)...))
|
||||
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)...)
|
||||
|
||||
@@ -469,7 +469,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
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"),
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
174
go/webui/code.go
Normal file
174
go/webui/code.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Go syntax highlighting, for the code samples a documentation page shows.
|
||||
//
|
||||
// It is a LEXER, not a parser: it classifies tokens and gives up gracefully on anything
|
||||
// it does not understand, because a highlighter that can fail to render is worse than
|
||||
// one that occasionally paints an identifier the wrong colour. Unterminated strings and
|
||||
// comments run to the end of the input rather than throwing.
|
||||
//
|
||||
// Output is HTML, and every run of source text passes through html.EscapeString on the
|
||||
// way out — the input is Go source, which is full of `<`, `>` and `&`, and one of the
|
||||
// snippets this is meant to display is literally a block of HTML.
|
||||
|
||||
// A code block is dark in BOTH themes — a light code block on a light page is a
|
||||
// different kind of thing, and switching it with the theme means the snippet you were
|
||||
// reading changes colour under you. So these are fixed on-dark colours, not theme
|
||||
// tokens: the surface they sit on never changes.
|
||||
const (
|
||||
goCommentClass = "text-neutral-400"
|
||||
goStringClass = "text-emerald-300"
|
||||
goKeywordClass = "text-sky-300"
|
||||
goNumberClass = "text-amber-300"
|
||||
goFuncClass = "text-violet-300"
|
||||
)
|
||||
|
||||
var goKeywords = map[string]bool{
|
||||
"break": true, "case": true, "chan": true, "const": true, "continue": true,
|
||||
"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
|
||||
"func": true, "go": true, "goto": true, "if": true, "import": true,
|
||||
"interface": true, "map": true, "package": true, "range": true, "return": true,
|
||||
"select": true, "struct": true, "switch": true, "type": true, "var": true,
|
||||
// Not keywords to the Go spec — predeclared identifiers — but every editor colours
|
||||
// them, and a reader looking for `nil` is looking for the same kind of thing.
|
||||
"nil": true, "true": true, "false": true, "iota": true,
|
||||
"string": true, "int": true, "int64": true, "float64": true, "bool": true,
|
||||
"byte": true, "rune": true, "any": true, "error": true,
|
||||
}
|
||||
|
||||
// HighlightGo turns Go source into HTML with the tokens wrapped in coloured spans.
|
||||
//
|
||||
// The result is meant for vdom.Raw inside a <pre>: it contains no block elements and
|
||||
// preserves the source's whitespace exactly, so the <pre> does the layout.
|
||||
func HighlightGo(src string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(src) * 2)
|
||||
|
||||
i := 0
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
|
||||
switch {
|
||||
// Line comment — including the //gowasm: directives, which are the most
|
||||
// important line in several of these snippets.
|
||||
case c == '/' && i+1 < len(src) && src[i+1] == '/':
|
||||
end := strings.IndexByte(src[i:], '\n')
|
||||
if end < 0 {
|
||||
end = len(src)
|
||||
} else {
|
||||
end += i
|
||||
}
|
||||
span(&b, goCommentClass, src[i:end])
|
||||
i = end
|
||||
|
||||
// Block comment.
|
||||
case c == '/' && i+1 < len(src) && src[i+1] == '*':
|
||||
end := strings.Index(src[i+2:], "*/")
|
||||
if end < 0 {
|
||||
end = len(src)
|
||||
} else {
|
||||
end = i + 2 + end + 2
|
||||
}
|
||||
span(&b, goCommentClass, src[i:end])
|
||||
i = end
|
||||
|
||||
// Interpreted string. Ends at the closing quote or the line's end — an
|
||||
// unterminated string is a typo in a snippet, not a reason to paint the rest of
|
||||
// the file green.
|
||||
case c == '"':
|
||||
i = quoted(&b, src, i, '"', true)
|
||||
|
||||
// Raw string: no escapes, and it may span lines.
|
||||
case c == '`':
|
||||
i = quoted(&b, src, i, '`', false)
|
||||
|
||||
// Rune literal.
|
||||
case c == '\'':
|
||||
i = quoted(&b, src, i, '\'', true)
|
||||
|
||||
case isDigit(c):
|
||||
j := i
|
||||
for j < len(src) && (isDigit(src[j]) || isHexish(src[j])) {
|
||||
j++
|
||||
}
|
||||
span(&b, goNumberClass, src[i:j])
|
||||
i = j
|
||||
|
||||
case isIdentStart(c):
|
||||
j := i
|
||||
for j < len(src) && isIdentPart(src[j]) {
|
||||
j++
|
||||
}
|
||||
word := src[i:j]
|
||||
switch {
|
||||
case goKeywords[word]:
|
||||
span(&b, goKeywordClass, word)
|
||||
case callAhead(src, j):
|
||||
// An identifier immediately followed by "(" is being called (or is a type
|
||||
// being converted to). Colouring it is what makes the shape of a snippet
|
||||
// readable at a glance.
|
||||
span(&b, goFuncClass, word)
|
||||
default:
|
||||
b.WriteString(html.EscapeString(word))
|
||||
}
|
||||
i = j
|
||||
|
||||
default:
|
||||
b.WriteString(html.EscapeString(string(c)))
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// quoted consumes a quoted literal starting at i and writes it as a string span.
|
||||
// escapes reports whether a backslash escapes the next byte (false for raw strings).
|
||||
func quoted(b *strings.Builder, src string, i int, quote byte, escapes bool) int {
|
||||
j := i + 1
|
||||
for j < len(src) {
|
||||
if escapes && src[j] == '\\' && j+1 < len(src) {
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
if src[j] == quote {
|
||||
j++
|
||||
break
|
||||
}
|
||||
if escapes && src[j] == '\n' {
|
||||
break // unterminated: stop at the line end rather than eating the file
|
||||
}
|
||||
j++
|
||||
}
|
||||
span(b, goStringClass, src[i:j])
|
||||
return j
|
||||
}
|
||||
|
||||
// callAhead reports whether the next non-space byte at or after i is an opening paren.
|
||||
func callAhead(src string, i int) bool {
|
||||
for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
return i < len(src) && src[i] == '('
|
||||
}
|
||||
|
||||
func span(b *strings.Builder, class, text string) {
|
||||
b.WriteString(`<span class="`)
|
||||
b.WriteString(class)
|
||||
b.WriteString(`">`)
|
||||
b.WriteString(html.EscapeString(text))
|
||||
b.WriteString(`</span>`)
|
||||
}
|
||||
|
||||
// isDigit already exists in the package (autotable.go) — reused rather than shadowed.
|
||||
|
||||
func isHexish(c byte) bool {
|
||||
return c == '.' || c == 'x' || c == 'X' || c == '_' ||
|
||||
(c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
}
|
||||
func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
|
||||
func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) }
|
||||
99
go/webui/code_test.go
Normal file
99
go/webui/code_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The highlighter emits HTML, and its input is Go source — which is full of <, > and &.
|
||||
// Anything that reaches the page unescaped is markup injection into your own docs page:
|
||||
// a snippet containing `<div>` would render a div.
|
||||
func TestHighlightGoEscapes(t *testing.T) {
|
||||
got := HighlightGo(`s := "<div class=\"x\">" // a & b`)
|
||||
|
||||
if strings.Contains(got, "<div") {
|
||||
t.Errorf("a `<div>` in the SOURCE reached the output as markup:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<div") {
|
||||
t.Errorf("the angle bracket was not escaped:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "&") {
|
||||
t.Errorf("the ampersand was not escaped:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightGoClassifies(t *testing.T) {
|
||||
got := HighlightGo("func main() { x := 42 // note\n}")
|
||||
|
||||
for _, want := range []struct{ what, class, text string }{
|
||||
{"keyword", goKeywordClass, "func"},
|
||||
{"call", goFuncClass, "main"},
|
||||
{"number", goNumberClass, "42"},
|
||||
{"comment", goCommentClass, "// note"},
|
||||
} {
|
||||
if !strings.Contains(got, `<span class="`+want.class+`">`+want.text+`</span>`) {
|
||||
t.Errorf("%s %q was not highlighted:\n%s", want.what, want.text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The //gowasm: directives are the most important line in half these snippets. They are
|
||||
// comments, and must survive as such.
|
||||
func TestHighlightGoKeepsDirectives(t *testing.T) {
|
||||
got := HighlightGo("//gowasm:page / static layout=public\nfunc HomePage() {}")
|
||||
if !strings.Contains(got, `<span class="`+goCommentClass+`">//gowasm:page / static layout=public</span>`) {
|
||||
t.Errorf("the directive was not kept whole as a comment:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A lexer that can hang or eat the rest of the file on malformed input would take the
|
||||
// whole page down with it. Unterminated literals stop; they do not run away.
|
||||
func TestHighlightGoSurvivesMalformedInput(t *testing.T) {
|
||||
for _, src := range []string{
|
||||
`x := "unterminated`,
|
||||
"y := `unterminated raw",
|
||||
"/* unterminated block",
|
||||
`z := '`,
|
||||
"",
|
||||
} {
|
||||
got := HighlightGo(src)
|
||||
// The text must all still be there — mangling is not an acceptable failure mode
|
||||
// either. Compare on the visible characters, ignoring the spans.
|
||||
if plain := stripTags(got); plain != src {
|
||||
t.Errorf("input %q came out as %q", src, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is dropped: every byte of the source is still on the page, in order.
|
||||
func TestHighlightGoIsLossless(t *testing.T) {
|
||||
src := "package app\n\nimport \"strings\"\n\nfunc f(n int) string {\n\treturn strings.Repeat(\"x\", n) // pad\n}\n"
|
||||
if plain := stripTags(HighlightGo(src)); plain != src {
|
||||
t.Errorf("the highlighter changed the source.\n got: %q\nwant: %q", plain, src)
|
||||
}
|
||||
}
|
||||
|
||||
// stripTags removes the spans and unescapes, recovering the original source.
|
||||
func stripTags(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); {
|
||||
if s[i] == '<' {
|
||||
j := strings.IndexByte(s[i:], '>')
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
i += j + 1
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
out := b.String()
|
||||
// Reverse html.EscapeString, innermost last.
|
||||
out = strings.ReplaceAll(out, "<", "<")
|
||||
out = strings.ReplaceAll(out, ">", ">")
|
||||
out = strings.ReplaceAll(out, """, `"`)
|
||||
out = strings.ReplaceAll(out, "'", "'")
|
||||
out = strings.ReplaceAll(out, "&", "&")
|
||||
return out
|
||||
}
|
||||
@@ -49,9 +49,9 @@ func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
|
||||
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
|
||||
|
||||
const crmTabRow = "flex w-full overflow-x-auto text-sm"
|
||||
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"
|
||||
const crmTabInactive = "border-b text-neutral-500 hover:text-neutral-800"
|
||||
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-primary"
|
||||
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-line-strong transition-colors"
|
||||
const crmTabInactive = "border-b text-ink-muted hover:text-ink"
|
||||
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-accent"
|
||||
const crmTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"
|
||||
|
||||
// CrmTabGroup renders boxed tabs with a sky-blue top accent; content sits flat
|
||||
@@ -79,7 +79,7 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
}
|
||||
row = append(row, vdom.Button(btn...))
|
||||
}
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-neutral-300")))
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-line-strong")))
|
||||
|
||||
return vdom.Div(vdom.Attr("class", "w-full"),
|
||||
vdom.Div(row...),
|
||||
@@ -89,12 +89,12 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
|
||||
// --- CrmSubTabGroup: segmented control --------------------------------------
|
||||
|
||||
const crmSubTabWrap = "flex pb-3 border-b border-neutral-300 overflow-x-auto"
|
||||
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"
|
||||
const crmSubTabWrap = "flex pb-3 border-b border-line-strong overflow-x-auto"
|
||||
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-line-strong overflow-hidden text-sm select-none"
|
||||
const crmSubTabBase = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"
|
||||
const crmSubTabDivider = "border-l border-neutral-300"
|
||||
const crmSubTabDivider = "border-l border-line-strong"
|
||||
const crmSubTabActive = "bg-neutral-500 text-white"
|
||||
const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
const crmSubTabInactive = "bg-surface text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
const crmSubTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"
|
||||
|
||||
// CrmSubTabGroup renders a left-aligned segmented control (interlocking
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
const datePickerWrap = "relative w-full min-w-0"
|
||||
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
|
||||
const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
|
||||
const datePickerDropdown = "bg-surface border border-line rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
|
||||
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
||||
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
||||
|
||||
|
||||
@@ -403,6 +403,13 @@ type FloatingTriggerProps struct {
|
||||
// OnClick runs in addition to the toggle (which is suppressed when the trigger
|
||||
// opens on hover).
|
||||
OnClick func()
|
||||
|
||||
// NoToggle anchors the panel to this element without making it a switch.
|
||||
//
|
||||
// A text field is not a switch: the panel opens because you typed, and closes
|
||||
// because you chose something. Toggling on click means clicking back into the field
|
||||
// to fix a typo dismisses the results you were reading.
|
||||
NoToggle bool
|
||||
}
|
||||
|
||||
// Trigger renders the element the panel is anchored to.
|
||||
@@ -436,6 +443,10 @@ func (f *Floating) Trigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdo
|
||||
if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
} else if p.NoToggle {
|
||||
if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
} else {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnClick != nil {
|
||||
|
||||
@@ -23,28 +23,28 @@ import (
|
||||
|
||||
// -- shared Tailwind class strings (verbatim from Forms.tsx) -------------------
|
||||
|
||||
const formInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
|
||||
const formInputBase = "bg-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised disabled:cursor-not-allowed"
|
||||
const formInputBaseDark = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
const formErrorCls = "block text-red-600 text-xs mt-1"
|
||||
const formErrorCls = "block text-red-600 dark:text-red-400 text-xs mt-1"
|
||||
const formSuccessCls = "block text-green-600 text-xs mt-1"
|
||||
const formInputGroupCls = "flex flex-row items-stretch w-full text-sm"
|
||||
|
||||
const formTriggerBase = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed"
|
||||
const formTriggerBase = "bg-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised disabled:cursor-not-allowed"
|
||||
const formTriggerBaseDark = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
const formDropdown = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const formDropdown = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const formDropdownDark = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const formDropdownSearchWrap = "sticky top-0 bg-white border-b border-neutral-200 p-2"
|
||||
const formDropdownSearchWrap = "sticky top-0 bg-surface border-b border-line p-2"
|
||||
const formDropdownSearchWrapDark = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"
|
||||
const formDropdownSearchInput = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
|
||||
const formDropdownSearchInput = "w-full bg-surface border border-line-strong rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
|
||||
const formDropdownSearchInputDark = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
|
||||
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed whitespace-nowrap"
|
||||
const formDropdownOptionDark = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap"
|
||||
const formDropdownNoResults = "p-2 text-sm text-neutral-500 text-center"
|
||||
const formDropdownNoResults = "p-2 text-sm text-ink-muted text-center"
|
||||
const formDropdownNoResultsDark = "p-2 text-sm text-text-on-dark-muted text-center"
|
||||
const formSelectAllWrap = "border-b border-neutral-200"
|
||||
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"
|
||||
const formSelectAllWrap = "border-b border-line"
|
||||
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"
|
||||
|
||||
// -- shared class builders -----------------------------------------------------
|
||||
|
||||
@@ -59,7 +59,7 @@ func formControlH(small bool) string {
|
||||
// formFieldBorder picks the border/focus-outline color: error > success > normal.
|
||||
// error/success are the message strings; non-empty means "present" (truthy).
|
||||
func formFieldBorder(errMsg, successMsg string, onDark bool) string {
|
||||
borderNormal := "border-neutral-300 focus:outline-sky-500"
|
||||
borderNormal := "border-line-strong focus:outline-sky-500"
|
||||
if onDark {
|
||||
borderNormal = "border-border-on-dark focus:outline-sky-500"
|
||||
}
|
||||
@@ -100,8 +100,8 @@ func formTextareaCls(small bool, errMsg, extra string, onDark bool) string {
|
||||
}
|
||||
|
||||
func formPrefixCls(small bool, errMsg string, onDark bool) string {
|
||||
base := "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
||||
borderNormal := "border-neutral-300"
|
||||
base := "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
||||
borderNormal := "border-line-strong"
|
||||
if onDark {
|
||||
base = "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
||||
borderNormal = "border-border-on-dark"
|
||||
@@ -662,7 +662,7 @@ func FormLabel(p FormLabelProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if p.Inline {
|
||||
display = "inline"
|
||||
}
|
||||
color := "text-neutral-700"
|
||||
color := "text-ink-soft"
|
||||
if p.OnDark {
|
||||
color = "text-text-on-dark"
|
||||
}
|
||||
@@ -685,8 +685,8 @@ func FormFileInput(p FormInputProps) *vdom.VNode {
|
||||
filePad = "file:py-[2px] file:px-3"
|
||||
}
|
||||
class := cx(formInputBase,
|
||||
"p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer",
|
||||
"file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200",
|
||||
"p-1 border-line-strong focus:outline-sky-500 cursor-pointer",
|
||||
"file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong",
|
||||
filePad, p.Class)
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "file"),
|
||||
@@ -717,8 +717,8 @@ func FormSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-3")) }
|
||||
// FormFieldset wraps children in a bordered <fieldset> with an optional legend.
|
||||
func FormFieldset(legend, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("border border-neutral-300 rounded-default py-3 px-4", class)),
|
||||
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-neutral-600"), vdom.Text(legend)),
|
||||
vdom.Attr("class", cx("border border-line-strong rounded-default py-3 px-4", class)),
|
||||
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-ink-soft"), vdom.Text(legend)),
|
||||
}
|
||||
mods = kids(mods, children)
|
||||
return vdom.Fieldset(mods...)
|
||||
@@ -954,7 +954,7 @@ func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark,
|
||||
cls = cx(cls, "py-1.5 px-2")
|
||||
}
|
||||
if idx == d.active.Get() {
|
||||
cls = cx(cls, "bg-neutral-100")
|
||||
cls = cx(cls, "bg-surface-raised")
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{
|
||||
@@ -1073,7 +1073,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
|
||||
}
|
||||
|
||||
display := pick(p.Placeholder, "Select an option")
|
||||
placeholderCls := "text-neutral-500"
|
||||
placeholderCls := "text-ink-muted"
|
||||
if p.OnDark {
|
||||
placeholderCls = "text-text-on-dark-muted"
|
||||
}
|
||||
@@ -1082,7 +1082,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
|
||||
placeholderCls = ""
|
||||
}
|
||||
|
||||
chevronCls := "text-neutral-400"
|
||||
chevronCls := "text-ink-faint"
|
||||
if p.OnDark {
|
||||
chevronCls = "text-text-on-dark-muted"
|
||||
}
|
||||
@@ -1178,6 +1178,12 @@ type FormMultiSelectProps struct {
|
||||
Small bool
|
||||
Class string
|
||||
FieldWidth string
|
||||
|
||||
// CollapsedLabel names the selection once the pills no longer fit. It defaults to
|
||||
// "N items selected", which is right for a list of things and wrong for anything
|
||||
// that is not — a column picker reading "5 items selected" tells you the count and
|
||||
// hides what the count is OF.
|
||||
CollapsedLabel func(n int) string
|
||||
}
|
||||
|
||||
// Render draws the field and its panel.
|
||||
@@ -1211,7 +1217,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
AriaHasPopup: "listbox",
|
||||
},
|
||||
vdom.Div(face...),
|
||||
IconInline(chevron, 16, "text-neutral-400"),
|
||||
IconInline(chevron, 16, "text-ink-faint"),
|
||||
)
|
||||
if p.Disabled {
|
||||
trigger.Attrs["disabled"] = "disabled"
|
||||
@@ -1227,27 +1233,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
// user is looking at and asked for; quietly selecting the hidden ones too would be
|
||||
// a nasty surprise.
|
||||
if p.ShowSelectAll && len(filtered) > 0 {
|
||||
all := formAllSelected(filtered, p.Value)
|
||||
label := "Select All"
|
||||
if all {
|
||||
label = "Deselect All"
|
||||
}
|
||||
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
|
||||
if p.OnChange != nil {
|
||||
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if allNow {
|
||||
next := cur
|
||||
for _, o := range opts {
|
||||
next = formRemoveStr(next, o.Value)
|
||||
}
|
||||
oc(next)
|
||||
return
|
||||
}
|
||||
oc(formSelectAllValues(opts, cur))
|
||||
}))
|
||||
}
|
||||
rows = append(rows, vdom.Button(mods...))
|
||||
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
@@ -1276,6 +1262,34 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
)
|
||||
}
|
||||
|
||||
// selectAllButton is the row at the top of the panel.
|
||||
//
|
||||
// It acts on what is VISIBLE. Selecting all of a filtered list is what the user is
|
||||
// looking at and asked for; quietly selecting the hidden ones too would be a nasty
|
||||
// surprise, and one they would not discover until they saved.
|
||||
func (m *MultiSelect) selectAllButton(filtered []FormSelectOption, value []string, onChange func([]string)) *vdom.VNode {
|
||||
all := formAllSelected(filtered, value)
|
||||
label := "Select All"
|
||||
if all {
|
||||
label = "Deselect All"
|
||||
}
|
||||
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
|
||||
if onChange != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if all {
|
||||
next := value
|
||||
for _, o := range filtered {
|
||||
next = formRemoveStr(next, o.Value)
|
||||
}
|
||||
onChange(next)
|
||||
return
|
||||
}
|
||||
onChange(formSelectAllValues(filtered, value))
|
||||
}))
|
||||
}
|
||||
return vdom.Button(mods...)
|
||||
}
|
||||
|
||||
// triggerFace is what the closed field shows: a placeholder, a row of removable pills,
|
||||
// or "N items selected" when the pills will not fit.
|
||||
//
|
||||
@@ -1286,7 +1300,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectOption, maxTags int) []vdom.Mod {
|
||||
if len(selected) == 0 {
|
||||
return []vdom.Mod{vdom.Span(
|
||||
vdom.Attr("class", "text-neutral-500 truncate"),
|
||||
vdom.Attr("class", "text-ink-muted truncate"),
|
||||
vdom.Text(pick(p.Placeholder, "Select options")),
|
||||
)}
|
||||
}
|
||||
@@ -1315,7 +1329,7 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
|
||||
ov, label := opt.Value, opt.Label
|
||||
remove := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
|
||||
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-ink"),
|
||||
vdom.Attr("aria-label", "Remove "+label),
|
||||
IconInline("xmark", 10, ""),
|
||||
}
|
||||
@@ -1334,10 +1348,14 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
|
||||
))
|
||||
}
|
||||
|
||||
label := itemsSelected
|
||||
if p.CollapsedLabel != nil {
|
||||
label = p.CollapsedLabel
|
||||
}
|
||||
count := vdom.Span(
|
||||
vdom.WithRef(m.countRef),
|
||||
vdom.Attr("class", faceCls("truncate", !tooMany)),
|
||||
vdom.Text(itemsSelected(len(selected))),
|
||||
vdom.Text(label(len(selected))),
|
||||
)
|
||||
|
||||
m.collapseFace(tooMany)
|
||||
@@ -1390,7 +1408,7 @@ func itemsSelected(n int) string {
|
||||
return strconv.Itoa(n) + " items selected"
|
||||
}
|
||||
|
||||
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-neutral-200 py-0.5 text-neutral-700 whitespace-nowrap leading-none"
|
||||
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-surface-strong py-0.5 text-ink-soft whitespace-nowrap leading-none"
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
@@ -1543,18 +1561,15 @@ var formTimezones = []FormSelectOption{
|
||||
{Value: "Pacific/Honolulu", Label: "Hawaii"},
|
||||
}
|
||||
|
||||
// -- intentionally omitted (no neutral-runtime equivalent) ---------------------
|
||||
// -- the rest of the TSX kit ---------------------------------------------------
|
||||
//
|
||||
// NOTE: the following TSX exports are omitted. They depend on capabilities the
|
||||
// neutral vdom runtime does not provide, and have no meaningful static form:
|
||||
// These four were once listed here as unportable — "no neutral-runtime equivalent".
|
||||
// That was true before the host API existed. It is not true now, and they all live in
|
||||
// forms_async.go and signaturepad.go:
|
||||
//
|
||||
// - FormSignaturePad — freehand drawing on a <canvas> 2D context with
|
||||
// mouse/touch tracking and SVG serialization.
|
||||
// - FormAsyncCombobox — debounced async option loading via Promises/timers.
|
||||
// - FormMultiSelectTrigger — a custom-trigger multi-select popover (Portal-free
|
||||
// but still driven by document listeners + refs); its
|
||||
// selection model is already covered by FormMultiSelect.
|
||||
// - handleTaxIdInput / handleRateInput — standalone oninput handlers that mutate
|
||||
// a DOM element in place; with no component consumer in
|
||||
// this file and no DOM handle in handlers, they don't
|
||||
// port. (Their masking logic mirrors formClean* above.)
|
||||
// - SignaturePad — draws into an SVG rather than a canvas, so the thing you
|
||||
// sign IS the value the caller stores.
|
||||
// - AsyncCombobox — debounced, and it discards out-of-order responses.
|
||||
// - MultiSelectTrigger — MultiSelect behind a trigger of your own.
|
||||
// - MaskTaxID / MaskRate — the TSX's oninput handlers, as pure functions of the
|
||||
// string. Testable, reusable, and they run on the server.
|
||||
|
||||
394
go/webui/forms_async.go
Normal file
394
go/webui/forms_async.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// The three form controls the first pass left out, plus the two input masks. All of
|
||||
// them were listed as "no neutral-runtime equivalent" — which was true before the host
|
||||
// API existed, and is not true now.
|
||||
|
||||
// ---- AsyncCombobox: options fetched as you type --------------------------
|
||||
|
||||
// AsyncCombobox is a combobox whose options come from somewhere else: a search endpoint,
|
||||
// a database, a third-party API. You type, it asks, the answers appear.
|
||||
//
|
||||
// The asking is DEBOUNCED and the answers are ORDERED. Both matter and both are easy to
|
||||
// get wrong: without debouncing, a six-letter query is six requests; without ordering, a
|
||||
// slow response to "ab" can land after a fast one to "abcdef" and overwrite it, leaving
|
||||
// the user staring at results for a query they finished typing a second ago. Every
|
||||
// response carries the query it was for, and one that no longer matches the box is
|
||||
// dropped.
|
||||
//
|
||||
// Create it once, alongside your signals — never inside a render.
|
||||
type AsyncCombobox struct {
|
||||
f *Floating
|
||||
|
||||
query *vdom.Signal[string]
|
||||
results *vdom.Signal[[]FormSelectOption]
|
||||
loading *vdom.Signal[bool]
|
||||
active *vdom.Signal[int]
|
||||
inputRef *vdom.Ref
|
||||
|
||||
timer int
|
||||
minChars int
|
||||
debounce int
|
||||
search func(query string, done func([]FormSelectOption))
|
||||
}
|
||||
|
||||
// AsyncComboboxOptions configures NewAsyncCombobox.
|
||||
type AsyncComboboxOptions struct {
|
||||
// Search is asked for options. Call done with the results — from a goroutine, from a
|
||||
// fetch callback, whenever. Call it exactly once; calling it late is fine, since a
|
||||
// stale answer is discarded rather than shown.
|
||||
Search func(query string, done func([]FormSelectOption))
|
||||
|
||||
MinChars int // don't search below this many characters (default 2)
|
||||
DebounceMs int // wait this long after the last keystroke (default 200)
|
||||
Placement string
|
||||
OnOpenChange func(bool)
|
||||
}
|
||||
|
||||
// NewAsyncCombobox creates an async combobox.
|
||||
func NewAsyncCombobox(o AsyncComboboxOptions) *AsyncCombobox {
|
||||
c := &AsyncCombobox{
|
||||
query: vdom.NewSignal(""),
|
||||
results: vdom.NewSignal([]FormSelectOption{}),
|
||||
loading: vdom.NewSignal(false),
|
||||
active: vdom.NewSignal(-1),
|
||||
inputRef: vdom.NewRef(),
|
||||
minChars: o.MinChars,
|
||||
debounce: o.DebounceMs,
|
||||
search: o.Search,
|
||||
}
|
||||
if c.minChars == 0 {
|
||||
c.minChars = 2
|
||||
}
|
||||
if c.debounce == 0 {
|
||||
c.debounce = 200
|
||||
}
|
||||
c.f = NewFloating(FloatingOptions{
|
||||
Placement: pick(o.Placement, PlacementBottomStart),
|
||||
Offset: 4,
|
||||
ConstrainToViewport: true,
|
||||
Standalone: true,
|
||||
OnOpenChange: func(open bool) {
|
||||
if !open {
|
||||
c.active.Set(-1)
|
||||
}
|
||||
if o.OnOpenChange != nil {
|
||||
o.OnOpenChange(open)
|
||||
}
|
||||
},
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
// IsOpen / Close drive the panel.
|
||||
func (c *AsyncCombobox) IsOpen() bool { return c.f.IsOpen() }
|
||||
func (c *AsyncCombobox) Close() { c.f.Hide() }
|
||||
|
||||
// Dispose cancels a pending search and removes the panel's listeners.
|
||||
func (c *AsyncCombobox) Dispose() {
|
||||
wasmruntime.ClearTimeout(c.timer)
|
||||
c.f.Dispose()
|
||||
}
|
||||
|
||||
// FormAsyncComboboxProps configures a render.
|
||||
type FormAsyncComboboxProps struct {
|
||||
Placeholder string
|
||||
// OnSelect fires when an option is chosen. The box shows the option's label.
|
||||
OnSelect func(opt FormSelectOption)
|
||||
Disabled bool
|
||||
Small bool
|
||||
Class string
|
||||
FieldWidth string
|
||||
// EmptyMessage is shown when a search returns nothing (default "No matches").
|
||||
EmptyMessage string
|
||||
}
|
||||
|
||||
// Render draws the field and its panel.
|
||||
func (c *AsyncCombobox) Render(p FormAsyncComboboxProps) *vdom.VNode {
|
||||
input := vdom.Input(
|
||||
vdom.WithRef(c.inputRef),
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("class", cx(formInputBase, formControlH(p.Small), "border-none bg-transparent p-0 shadow-none focus-visible:outline-none")),
|
||||
vdom.Attr("placeholder", pick(p.Placeholder, "Search…")),
|
||||
vdom.Attr("autocomplete", "off"),
|
||||
vdom.Attr("spellcheck", "false"),
|
||||
vdom.Prop("value", c.query.Get()),
|
||||
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { c.onInput(e.Value()) }),
|
||||
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { c.onKey(e, p) }),
|
||||
)
|
||||
if p.Disabled {
|
||||
input.Attrs["disabled"] = "disabled"
|
||||
}
|
||||
|
||||
pad := "p-2"
|
||||
if p.Small {
|
||||
pad = "p-1"
|
||||
}
|
||||
trigger := c.f.Trigger(FloatingTriggerProps{
|
||||
Tag: "div",
|
||||
Class: cx(formTriggerBase, formControlH(p.Small), pad),
|
||||
AriaHasPopup: "listbox",
|
||||
// The field is not a switch — see FloatingTriggerProps.NoToggle.
|
||||
NoToggle: true,
|
||||
},
|
||||
vdom.Div(vdom.Attr("class", "min-w-0 flex-1"), input),
|
||||
c.statusIcon(),
|
||||
)
|
||||
|
||||
return vdom.Div(
|
||||
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
|
||||
trigger,
|
||||
c.panel(p),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) statusIcon() *vdom.VNode {
|
||||
if c.loading.Get() {
|
||||
return vdom.Span(vdom.Attr("class", "shrink-0 animate-spin text-ink-faint"), IconInline("circle-notch", 14, ""))
|
||||
}
|
||||
return IconInline("magnifying-glass", 14, "shrink-0 text-ink-faint")
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) panel(p FormAsyncComboboxProps) *vdom.VNode {
|
||||
rows := []*vdom.VNode{}
|
||||
|
||||
switch {
|
||||
case c.loading.Get():
|
||||
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("Searching…")))
|
||||
case len(c.results.Get()) == 0:
|
||||
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults),
|
||||
vdom.Text(pick(p.EmptyMessage, "No matches"))))
|
||||
default:
|
||||
for i, opt := range c.results.Get() {
|
||||
i, opt := i, opt
|
||||
cls := formDropdownOption
|
||||
if i == c.active.Get() {
|
||||
cls = cx(cls, "bg-surface-raised")
|
||||
}
|
||||
rows = append(rows, vdom.Button(
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cls),
|
||||
vdom.Attr("role", "option"),
|
||||
vdom.On(vdom.EVENT_CLICK, func() { c.choose(opt, p) }),
|
||||
vdom.Text(opt.Label),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return c.f.Panel(FloatingPanelProps{Class: formDropdown, Role: "listbox"}, rows...)
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) choose(opt FormSelectOption, p FormAsyncComboboxProps) {
|
||||
c.query.Set(opt.Label)
|
||||
c.f.Hide()
|
||||
if p.OnSelect != nil {
|
||||
p.OnSelect(opt)
|
||||
}
|
||||
}
|
||||
|
||||
// onInput debounces. A search per keystroke is a search per keystroke — and if the
|
||||
// search costs a network round-trip, typing "engineering" is twelve of them.
|
||||
func (c *AsyncCombobox) onInput(q string) {
|
||||
c.query.Set(q)
|
||||
c.active.Set(-1)
|
||||
wasmruntime.ClearTimeout(c.timer)
|
||||
|
||||
if len([]rune(strings.TrimSpace(q))) < c.minChars {
|
||||
c.loading.Set(false)
|
||||
c.results.Set([]FormSelectOption{})
|
||||
c.f.Hide()
|
||||
return
|
||||
}
|
||||
|
||||
c.loading.Set(true)
|
||||
c.f.Show()
|
||||
c.timer = wasmruntime.SetTimeout(c.debounce, func() { c.run(q) })
|
||||
}
|
||||
|
||||
// run performs the search, and DISCARDS an answer that arrived for a query the user has
|
||||
// since typed past. Out-of-order responses are the classic bug in this control.
|
||||
func (c *AsyncCombobox) run(q string) {
|
||||
if c.search == nil {
|
||||
c.loading.Set(false)
|
||||
return
|
||||
}
|
||||
c.search(q, func(opts []FormSelectOption) {
|
||||
if c.query.Get() != q {
|
||||
return // the box has moved on; this answer is for a question nobody is asking
|
||||
}
|
||||
c.loading.Set(false)
|
||||
c.results.Set(opts)
|
||||
if len(opts) > 0 {
|
||||
c.f.Show()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) onKey(e vdom.Event, p FormAsyncComboboxProps) {
|
||||
res := c.results.Get()
|
||||
switch e.Key() {
|
||||
case vdom.KEY_ARROW_DOWN:
|
||||
e.PreventDefault()
|
||||
if len(res) > 0 {
|
||||
c.active.Set((c.active.Get() + 1) % len(res))
|
||||
}
|
||||
case vdom.KEY_ARROW_UP:
|
||||
e.PreventDefault()
|
||||
if len(res) > 0 {
|
||||
next := c.active.Get() - 1
|
||||
if next < 0 {
|
||||
next = len(res) - 1
|
||||
}
|
||||
c.active.Set(next)
|
||||
}
|
||||
case vdom.KEY_ENTER:
|
||||
if i := c.active.Get(); i >= 0 && i < len(res) {
|
||||
e.PreventDefault()
|
||||
c.choose(res[i], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MultiSelectTrigger: a multi-select behind your own trigger ----------
|
||||
|
||||
// MultiSelectTrigger is MultiSelect with the field replaced by whatever you like — a
|
||||
// button, an icon, a table header. The selection model is identical; only the thing you
|
||||
// click on differs. The AutoTable's column picker is one of these in spirit.
|
||||
type MultiSelectTrigger struct{ *MultiSelect }
|
||||
|
||||
// NewMultiSelectTrigger creates one.
|
||||
func NewMultiSelectTrigger(o DropdownOptions) *MultiSelectTrigger {
|
||||
return &MultiSelectTrigger{MultiSelect: NewMultiSelect(o)}
|
||||
}
|
||||
|
||||
// FormMultiSelectTriggerProps configures a render.
|
||||
type FormMultiSelectTriggerProps struct {
|
||||
// Trigger is your element. It is wrapped in the floating trigger, so it needs no
|
||||
// open/close wiring of its own.
|
||||
Trigger *vdom.VNode
|
||||
|
||||
Options []FormSelectOption
|
||||
Value []string
|
||||
OnChange func([]string)
|
||||
Searchable bool
|
||||
SearchPlaceholder string
|
||||
ShowSelectAll bool
|
||||
Small bool
|
||||
Class string
|
||||
PanelClass string
|
||||
}
|
||||
|
||||
// Render draws the caller's trigger and the panel.
|
||||
func (m *MultiSelectTrigger) Render(p FormMultiSelectTriggerProps) *vdom.VNode {
|
||||
trigger := m.f.Trigger(FloatingTriggerProps{
|
||||
Tag: "div",
|
||||
Class: cx("inline-flex cursor-pointer items-center", p.Class),
|
||||
AriaHasPopup: "listbox",
|
||||
}, p.Trigger)
|
||||
|
||||
filtered := m.filter(p.Options)
|
||||
rows := make([]*vdom.VNode, 0, len(filtered)+2)
|
||||
if p.Searchable && m.IsOpen() {
|
||||
rows = append(rows, m.searchBox(p.SearchPlaceholder, false, len(filtered)))
|
||||
}
|
||||
if p.ShowSelectAll && len(filtered) > 0 {
|
||||
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
rows = append(rows, m.noResults(false))
|
||||
}
|
||||
for i := range filtered {
|
||||
opt := filtered[i]
|
||||
on := formContainsStr(p.Value, opt.Value)
|
||||
rows = append(rows, m.optionButton(opt, i, on, false, p.Small, func() {
|
||||
if p.OnChange != nil {
|
||||
p.OnChange(formToggleStr(p.Value, opt.Value))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return vdom.Div(vdom.Attr("class", "relative inline-flex"),
|
||||
trigger,
|
||||
m.f.Panel(FloatingPanelProps{Class: cx(formDropdown, pick(p.PanelClass, "min-w-48")), Role: "listbox"}, rows...),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- input masks --------------------------------------------------------
|
||||
|
||||
// MaskTaxID formats a US tax ID as the user types: 12-3456789.
|
||||
//
|
||||
// The TSX equivalents (handleTaxIdInput / handleRateInput) were oninput handlers that
|
||||
// reached into the event and rewrote the element's value in place. These are pure
|
||||
// functions of the string instead — they can be tested, reused on the server, and
|
||||
// composed — and the caller wires one into an input the ordinary way:
|
||||
//
|
||||
// ui.FormInput(ui.FormInputProps{
|
||||
// Value: taxID.Get(),
|
||||
// OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
|
||||
// })
|
||||
func MaskTaxID(s string) string {
|
||||
digits := keepDigits(s)
|
||||
if len(digits) > 9 {
|
||||
digits = digits[:9]
|
||||
}
|
||||
if len(digits) <= 2 {
|
||||
return digits
|
||||
}
|
||||
return digits[:2] + "-" + digits[2:]
|
||||
}
|
||||
|
||||
// MaskRate formats a rate as it is typed: digits, one decimal point, at most three
|
||||
// decimal places, no leading zeros.
|
||||
func MaskRate(s string) string {
|
||||
var b strings.Builder
|
||||
seenDot := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
b.WriteByte(c)
|
||||
case c == '.' && !seenDot:
|
||||
seenDot = true
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
|
||||
if i := strings.IndexByte(out, '.'); i >= 0 {
|
||||
whole, frac := out[:i], out[i+1:]
|
||||
if len(frac) > 3 {
|
||||
frac = frac[:3]
|
||||
}
|
||||
out = trimLeadingZeros(whole) + "." + frac
|
||||
} else {
|
||||
out = trimLeadingZeros(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keepDigits(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= '0' && s[i] <= '9' {
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// trimLeadingZeros drops leading zeros but keeps a lone "0" — "007" is 7, and "0" is 0,
|
||||
// but "" is not a number the user meant to type.
|
||||
func trimLeadingZeros(s string) string {
|
||||
i := 0
|
||||
for i < len(s)-1 && s[i] == '0' {
|
||||
i++
|
||||
}
|
||||
return s[i:]
|
||||
}
|
||||
163
go/webui/forms_async_test.go
Normal file
163
go/webui/forms_async_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// ---- input masks ----
|
||||
|
||||
func TestMaskTaxID(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"1", "1"},
|
||||
{"12", "12"},
|
||||
{"123", "12-3"},
|
||||
{"123456789", "12-3456789"},
|
||||
{"12-3456789", "12-3456789"}, // idempotent: re-masking its own output
|
||||
{"1234567890123", "12-3456789"}, // nine digits, no more
|
||||
{"ab12cd3456789xy", "12-3456789"},
|
||||
} {
|
||||
if got := MaskTaxID(tc.in); got != tc.want {
|
||||
t.Errorf("MaskTaxID(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskRate(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"5", "5"},
|
||||
{"5.25", "5.25"},
|
||||
{"5.2555", "5.255"}, // three decimals, no more
|
||||
{"5.2.5", "5.25"}, // one decimal point, no more
|
||||
{"007", "7"}, // no leading zeros...
|
||||
{"0", "0"}, // ...but a lone zero is a number
|
||||
{"0.5", "0.5"}, // and so is a leading zero before a point
|
||||
{"$1,2a3.45", "123.45"},
|
||||
{"5.25", "5.25"}, // idempotent
|
||||
} {
|
||||
if got := MaskRate(tc.in); got != tc.want {
|
||||
t.Errorf("MaskRate(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mask is applied to its own output on every keystroke, so anything that is not
|
||||
// idempotent corrupts the field as you type.
|
||||
func TestMasksAreIdempotent(t *testing.T) {
|
||||
for _, in := range []string{"123456789", "12-345", "1", ""} {
|
||||
once := MaskTaxID(in)
|
||||
if twice := MaskTaxID(once); twice != once {
|
||||
t.Errorf("MaskTaxID is not idempotent: %q -> %q -> %q", in, once, twice)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"5.255", "0.5", "007", "12"} {
|
||||
once := MaskRate(in)
|
||||
if twice := MaskRate(once); twice != once {
|
||||
t.Errorf("MaskRate is not idempotent: %q -> %q -> %q", in, once, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- async combobox ----
|
||||
|
||||
func asyncOpts(labels ...string) []FormSelectOption {
|
||||
out := make([]FormSelectOption, len(labels))
|
||||
for i, l := range labels {
|
||||
out[i] = FormSelectOption{Value: strings.ToLower(l), Label: l}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The classic bug in a search-as-you-type box: a SLOW answer to an early query lands
|
||||
// after a FAST answer to the query the user actually finished typing, and overwrites it.
|
||||
// The user is then reading results for "ab" while the box says "abcdef".
|
||||
//
|
||||
// Every response carries the query it was for; one that no longer matches is dropped.
|
||||
func TestAsyncComboboxDropsStaleResponses(t *testing.T) {
|
||||
var pending []func([]FormSelectOption) // answers we have not given yet
|
||||
var queries []string
|
||||
|
||||
c := NewAsyncCombobox(AsyncComboboxOptions{
|
||||
MinChars: 2,
|
||||
Search: func(q string, done func([]FormSelectOption)) {
|
||||
queries = append(queries, q)
|
||||
pending = append(pending, done)
|
||||
},
|
||||
})
|
||||
|
||||
// Type "ab", then "abcdef". (run() is called directly: the debounce timer is a
|
||||
// browser timer, and this test has no browser.)
|
||||
c.query.Set("ab")
|
||||
c.run("ab")
|
||||
c.query.Set("abcdef")
|
||||
c.run("abcdef")
|
||||
|
||||
if len(pending) != 2 {
|
||||
t.Fatalf("expected 2 searches, got %d (%v)", len(pending), queries)
|
||||
}
|
||||
|
||||
// The answer to "abcdef" comes back first...
|
||||
pending[1](asyncOpts("Abcdef Ltd"))
|
||||
if got := c.results.Get(); len(got) != 1 || got[0].Label != "Abcdef Ltd" {
|
||||
t.Fatalf("the current query's results were not shown: %v", got)
|
||||
}
|
||||
|
||||
// ...and the slow answer to "ab" arrives afterwards. It must be DISCARDED.
|
||||
pending[0](asyncOpts("Ab Corp", "Abacus"))
|
||||
|
||||
got := c.results.Get()
|
||||
if len(got) != 1 || got[0].Label != "Abcdef Ltd" {
|
||||
t.Errorf("a stale response overwrote the current results: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Below MinChars there is no search at all — a one-character query against a big table
|
||||
// is a scan, and every user who ever types starts by typing one character.
|
||||
func TestAsyncComboboxRespectsMinChars(t *testing.T) {
|
||||
searched := 0
|
||||
c := NewAsyncCombobox(AsyncComboboxOptions{
|
||||
MinChars: 3,
|
||||
Search: func(string, func([]FormSelectOption)) { searched++ },
|
||||
})
|
||||
|
||||
c.onInput("a")
|
||||
c.onInput("ab")
|
||||
if searched != 0 {
|
||||
t.Errorf("searched %d times below MinChars", searched)
|
||||
}
|
||||
if c.IsOpen() {
|
||||
t.Error("the panel opened with nothing to show")
|
||||
}
|
||||
}
|
||||
|
||||
// Choosing an option closes the box and reports the whole option, not just its value —
|
||||
// the caller usually needs the label back to display it.
|
||||
func TestAsyncComboboxSelect(t *testing.T) {
|
||||
var picked FormSelectOption
|
||||
c := NewAsyncCombobox(AsyncComboboxOptions{
|
||||
Search: func(_ string, done func([]FormSelectOption)) { done(asyncOpts("Ada Lovelace")) },
|
||||
})
|
||||
|
||||
c.query.Set("ada")
|
||||
c.run("ada")
|
||||
c.f.Show()
|
||||
|
||||
node := c.Render(FormAsyncComboboxProps{OnSelect: func(o FormSelectOption) { picked = o }})
|
||||
if !findAndClick(node, "Ada Lovelace") {
|
||||
t.Fatalf("no result row to click:\n%s", vdom.RenderHTML(node))
|
||||
}
|
||||
|
||||
if picked.Value != "ada lovelace" {
|
||||
t.Errorf("OnSelect got %+v", picked)
|
||||
}
|
||||
if c.IsOpen() {
|
||||
t.Error("choosing an option should close the panel")
|
||||
}
|
||||
if c.query.Get() != "Ada Lovelace" {
|
||||
t.Errorf("the field should show the chosen label, got %q", c.query.Get())
|
||||
}
|
||||
}
|
||||
@@ -230,12 +230,12 @@ const (
|
||||
FuzzyDisplayNone = "none" // render only the input (headless)
|
||||
)
|
||||
|
||||
const fuzzyInputCls = "bg-white block w-full border border-neutral-300 rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
|
||||
const fuzzyInputCls = "bg-surface block w-full border border-line-strong rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
|
||||
|
||||
// Mirror FormCombobox's dropdown styling: neutral hover / highlight.
|
||||
const fuzzyDropdownCls = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-neutral-100 whitespace-nowrap"
|
||||
const fuzzyDropdownOptionHighlightCls = "bg-neutral-100"
|
||||
const fuzzyDropdownCls = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-surface-raised whitespace-nowrap"
|
||||
const fuzzyDropdownOptionHighlightCls = "bg-surface-raised"
|
||||
|
||||
// FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted
|
||||
// are caller-held state (read at the call site); the On* callbacks report changes.
|
||||
@@ -262,7 +262,7 @@ func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode {
|
||||
out := make([]*vdom.VNode, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
if seg.Match {
|
||||
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
|
||||
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 dark:text-sky-400 font-semibold"), vdom.Text(seg.Text)))
|
||||
} else {
|
||||
out = append(out, vdom.Span(vdom.Text(seg.Text)))
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func fuzzyScoreBadge(show bool, score int) *vdom.VNode {
|
||||
if !show {
|
||||
return nil
|
||||
}
|
||||
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
|
||||
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-ink-faint"), vdom.Text(strconv.Itoa(score)))
|
||||
}
|
||||
|
||||
// fuzzyListView renders the inline "list" display. Before the user types, all
|
||||
@@ -296,12 +296,12 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||
for _, r := range items {
|
||||
r := r
|
||||
li := []vdom.Mod{
|
||||
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-neutral-100"),
|
||||
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-surface-raised"),
|
||||
}
|
||||
if p.OnSelect != nil {
|
||||
li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||
}
|
||||
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-ink")}, fuzzyHighlight(r.Segments))...))
|
||||
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||
li = append(li, badge)
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||
}
|
||||
outer = append(outer, vdom.Ul(ul...))
|
||||
} else if strings.TrimSpace(p.Query) != "" {
|
||||
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-neutral-500 italic"),
|
||||
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-ink-muted italic"),
|
||||
vdom.Text(`No matches for "`+p.Query+`".`)))
|
||||
}
|
||||
return vdom.Div(outer...)
|
||||
@@ -338,7 +338,7 @@ func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode
|
||||
if p.OnSelect != nil {
|
||||
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||
}
|
||||
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-ink")}, fuzzyHighlight(r.Segments))...))
|
||||
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||
btnMods = append(btnMods, badge)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func CodeBox(code, class string) *vdom.VNode {
|
||||
func PageHeader(text, class string) *vdom.VNode {
|
||||
return vdom.Header(vdom.Attr("class", class),
|
||||
vdom.Div(vdom.Attr("class", "mt-1"),
|
||||
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
|
||||
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-ink mb-2"), vdom.Text(text)),
|
||||
vdom.Hr(vdom.Attr("class", "text-neutral-200 mb-2")),
|
||||
),
|
||||
)
|
||||
@@ -34,7 +34,7 @@ func PageHeader(text, class string) *vdom.VNode {
|
||||
func PageLink(href string, newTab bool, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("href", href),
|
||||
vdom.Attr("class", cx("text-sky-700 hover:text-sky-800 hover:underline hover:decoration-1", class)),
|
||||
vdom.Attr("class", cx("text-sky-700 dark:text-sky-400 hover:text-sky-800 hover:underline hover:decoration-1", class)),
|
||||
}
|
||||
if newTab {
|
||||
mods = append(mods, vdom.Attr("target", "_blank"), vdom.Attr("rel", "noopener noreferrer"))
|
||||
@@ -58,17 +58,17 @@ type BreadcrumbItem struct {
|
||||
// Breadcrumbs renders a chevron-separated crumb trail; the last item is bold and
|
||||
// not linked.
|
||||
func Breadcrumbs(items []BreadcrumbItem) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-neutral-400 text-xs")}
|
||||
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-ink-faint text-xs")}
|
||||
for i, crumb := range items {
|
||||
if i != len(items)-1 {
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "flex items-center"),
|
||||
vdom.A(vdom.Attr("href", crumb.URL),
|
||||
vdom.Attr("class", "text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline"),
|
||||
vdom.Attr("class", "text-ink-muted cursor-pointer no-underline hover:text-ink-soft hover:underline"),
|
||||
vdom.Text(crumb.DisplayText)),
|
||||
Icon("chevron-right", 12, "mx-[0.15rem] opacity-50"),
|
||||
))
|
||||
} else {
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "text-ink-soft font-medium"), vdom.Text(crumb.DisplayText)))
|
||||
}
|
||||
}
|
||||
return vdom.Div(mods...)
|
||||
|
||||
@@ -24,6 +24,18 @@ func RegisterIcon(name, viewBox, content string) {
|
||||
iconRegistry[name] = iconDef{viewBox: viewBox, content: content}
|
||||
}
|
||||
|
||||
// HasIcon reports whether a name resolves to an icon.
|
||||
//
|
||||
// It exists so an app can TEST that the names it uses are real. An unknown name renders
|
||||
// a correctly-sized but empty box — the layout survives, which is the right runtime
|
||||
// behaviour and a terrible debugging experience: the icon is simply not there, and
|
||||
// nothing anywhere says why. A one-line test over the names you reference turns that
|
||||
// into a build failure.
|
||||
func HasIcon(name string) bool {
|
||||
_, ok := iconRegistry[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Icon renders a named icon at the given pixel size (0 => 16). Extra classes are
|
||||
// appended. Unknown names render an empty, correctly-sized box (never blanks the
|
||||
// layout). Mirrors Icons.tsx's <svg fill=currentColor width height>innerHTML</svg>.
|
||||
@@ -59,7 +71,7 @@ func IconSuccess(name string, size int, class string) *vdom.VNode {
|
||||
return Icon(name, size, cx("inline-block align-middle text-green-600", class))
|
||||
}
|
||||
func IconError(name string, size int, class string) *vdom.VNode {
|
||||
return Icon(name, size, cx("inline-block align-middle text-red-600", class))
|
||||
return Icon(name, size, cx("inline-block align-middle text-red-600 dark:text-red-400", class))
|
||||
}
|
||||
|
||||
// IconContainer is a flex row that vertically centers an icon + text.
|
||||
@@ -136,6 +148,32 @@ func init() {
|
||||
reg("globe", "M12 21a9 9 0 0 0 8.72-6.75M12 21a9 9 0 0 1-8.72-6.75M12 21c2.49 0 4.5-4.03 4.5-9S14.49 3 12 3m0 18c-2.49 0-4.5-4.03-4.5-9S9.51 3 12 3m0 0a9 9 0 0 1 7.84 4.58M12 3a9 9 0 0 0-7.84 4.58m15.68 0A11.95 11.95 0 0 1 12 10.5c-3 0-5.74-1.1-7.84-2.92m15.68 0A8.96 8.96 0 0 1 21 12c0 .78-.1 1.53-.28 2.25m0 0A17.92 17.92 0 0 1 12 16.5c-3.16 0-6.13-.82-8.72-2.25m0 0A9.02 9.02 0 0 1 3 12c0-1.6.42-3.11 1.16-4.42")
|
||||
reg("user", "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.5 20.12a7.5 7.5 0 0 1 15 0A17.93 17.93 0 0 1 12 21.75c-2.68 0-5.22-.58-7.5-1.63Z")
|
||||
|
||||
// --- documentation / section icons ---
|
||||
reg("book-open", "M12 6.04A8.97 8.97 0 0 0 6 3.75c-1.05 0-2.06.18-3 .51v14.25A8.99 8.99 0 0 1 6 18c2.3 0 4.4.87 6 2.29m0-14.25a8.97 8.97 0 0 1 6-2.29c1.05 0 2.06.18 3 .51v14.25A8.99 8.99 0 0 0 18 18a8.97 8.97 0 0 0-6 2.29m0-14.25v14.25")
|
||||
reg("chart-column", "M3.75 20.25h16.5M6.75 20.25v-6.75m4.5 6.75V8.25m4.5 12V4.5m4.5 15.75v-9")
|
||||
reg("server", "M3.75 6.75h16.5v4.5H3.75v-4.5Zm0 6h16.5v4.5H3.75v-4.5ZM6.75 9h.01M6.75 15h.01")
|
||||
reg("cloud-arrow-down", "M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.78 5.25 5.25 0 0 1 10.23-2.33 3 3 0 0 1 3.76 3.85A3.75 3.75 0 0 1 18 19.5H6.75Z")
|
||||
reg("squares", "M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 8.25 20.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z")
|
||||
reg("layers", "m2.25 12 9 5.25L20.25 12M2.25 7.5l9-5.25 9 5.25-9 5.25-9-5.25Zm0 9 9 5.25 9-5.25")
|
||||
reg("table", "M3.75 5.25h16.5v13.5H3.75V5.25Zm0 4.5h16.5m-16.5 4.5h16.5M9.75 9.75v9")
|
||||
reg("code", "m6.75 7.5-4.5 4.5 4.5 4.5m10.5-9 4.5 4.5-4.5 4.5M14.25 3.75l-4.5 16.5")
|
||||
reg("bolt", "M14.25 2.25 4.5 13.5h6l-.75 8.25L19.5 10.5h-6l.75-8.25Z")
|
||||
// A keel is the spine of a hull — the thing everything else is built on. Which is
|
||||
// the whole idea behind the name, so the boat is not merely decorative.
|
||||
reg("sailboat", "M11.25 3.75v12M11.25 15.75H4.5l6.75-12M14.25 15.75h4.5l-4.5-7.5zM2.25 18.75h19.5l-2.4 3H4.65z")
|
||||
reg("cube", "m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9")
|
||||
reg("shield-check", "M9 12.75 11.25 15 15 9.75m-3-7.04A11.96 11.96 0 0 1 3.6 6 12 12 0 0 0 3 9.75c0 5.59 3.82 10.29 9 11.62 5.18-1.33 9-6.03 9-11.62 0-1.31-.21-2.57-.6-3.75h-.15a11.96 11.96 0 0 1-8.25-3.29Z")
|
||||
|
||||
// --- theme ---
|
||||
reg("sun", "M12 3v1.5m0 15V21m9-9h-1.5m-15 0H3m15.36-6.36-1.06 1.06M6.7 17.3l-1.06 1.06m12.72 0-1.06-1.06M6.7 6.7 5.64 5.64M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z")
|
||||
reg("moon", "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z")
|
||||
|
||||
// --- search / loading ---
|
||||
reg("magnifying-glass", "M21 21l-5.2-5.2M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z")
|
||||
// An open arc, so that spinning it actually reads as motion — a full ring rotating
|
||||
// looks like a ring standing still.
|
||||
reg("circle-notch", "M21 12a9 9 0 0 0-9-9")
|
||||
|
||||
// --- status (the toast set) ---
|
||||
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||
reg("circle-info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||
|
||||
@@ -57,11 +57,11 @@ import "kjol/vdom"
|
||||
// MenuDivider, MenuSection and MenuGroup stay free functions: they hold no state
|
||||
// and have nothing to close.
|
||||
|
||||
const menuCls = "bg-white rounded-default shadow-lg border border-neutral-200 p-1.5 min-w-48 max-h-96 overflow-y-auto"
|
||||
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-neutral-200"
|
||||
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 uppercase tracking-wide text-left"
|
||||
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900"
|
||||
const menuCls = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto"
|
||||
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-line"
|
||||
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left"
|
||||
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft cursor-pointer hover:bg-surface-raised hover:text-ink"
|
||||
|
||||
// defaultHoverCloseDelay is the TSX's 150ms grace period after the cursor leaves.
|
||||
const defaultHoverCloseDelay = 150
|
||||
@@ -375,7 +375,7 @@ func (m *Menu) Anchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
}
|
||||
mods = kids(mods, children)
|
||||
if target == "_blank" {
|
||||
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
|
||||
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-ink-faint"))
|
||||
}
|
||||
return vdom.A(mods...)
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (m *Menu) Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
OnClick: m.f.Toggle,
|
||||
},
|
||||
vdom.Span(label...),
|
||||
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
|
||||
Icon("chevron-right", 16, "shrink-0 ml-auto text-ink-faint"),
|
||||
)
|
||||
menuSetAttr(trigger, "role", "menuitem")
|
||||
// Entering the trigger means entering the submenu; it must not let an ancestor
|
||||
|
||||
@@ -98,7 +98,7 @@ const modalContainerCenter = "items-center"
|
||||
|
||||
const modalBackdrop = "fixed inset-0 bg-black/30"
|
||||
|
||||
const modalBase = "relative bg-white shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
|
||||
const modalBase = "relative bg-surface shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
|
||||
|
||||
var modalSizes = map[ModalSize]string{
|
||||
ModalSmall: "max-w-md",
|
||||
@@ -113,19 +113,19 @@ var modalSizes = map[ModalSize]string{
|
||||
ModalFull: "max-w-none",
|
||||
}
|
||||
|
||||
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-neutral-200 text-lg font-semibold text-text-heading"
|
||||
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-line text-lg font-semibold text-text-heading"
|
||||
const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1"
|
||||
const modalCloseBtn = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700"
|
||||
const modalCloseBtn = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink-soft"
|
||||
const modalBody = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]"
|
||||
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-neutral-200 bg-neutral-50 rounded-b-default"
|
||||
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-line bg-surface-muted rounded-b-default"
|
||||
const modalFooterSpacer = "h-2"
|
||||
|
||||
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 border-t border-red-200"
|
||||
const modalWizardErrorIcon = "shrink-0 text-red-500"
|
||||
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 dark:bg-red-950 border-t border-red-200 dark:border-red-900"
|
||||
const modalWizardErrorIcon = "shrink-0 text-red-500 dark:text-red-400"
|
||||
|
||||
// Confirm modal
|
||||
const modalConfirmWrap = "flex justify-end gap-2"
|
||||
const modalConfirmCancel = "py-2 px-4 text-sm border border-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50"
|
||||
const modalConfirmCancel = "py-2 px-4 text-sm border border-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted"
|
||||
const modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"
|
||||
|
||||
var modalConfirmOkVariants = map[string]string{
|
||||
@@ -137,20 +137,20 @@ var modalConfirmOkVariants = map[string]string{
|
||||
const modalWizardHeader = "flex flex-col items-center gap-2 flex-1"
|
||||
const modalWizardTitleRow = "flex items-center justify-between w-full"
|
||||
const modalWizardTitle = "text-xl"
|
||||
const modalWizardStepName = "text-xs font-semibold text-neutral-600 uppercase tracking-wider"
|
||||
const modalWizardStepName = "text-xs font-semibold text-ink-soft uppercase tracking-wider"
|
||||
const modalWizardSteps = "flex items-center justify-between relative w-full max-w-64"
|
||||
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-neutral-200 -translate-y-1/2"
|
||||
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-surface-strong -translate-y-1/2"
|
||||
const modalWizardTrackFill = "h-full bg-primary transition-[width] duration-300 ease-in-out"
|
||||
const modalWizardStepWrap = "relative z-[1]"
|
||||
const modalStepIndicatorBase = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"
|
||||
const modalStepIndicatorPending = "border-neutral-300 text-neutral-400 bg-white"
|
||||
const modalStepIndicatorPending = "border-line-strong text-ink-faint bg-surface"
|
||||
const modalStepIndicatorActive = "bg-primary text-white border-primary"
|
||||
const modalStepIndicatorCompleted = "bg-primary text-white border-primary"
|
||||
|
||||
// Wizard footer
|
||||
const modalWizardFooter = "flex items-center justify-between w-full gap-2"
|
||||
const modalWizardBtnBase = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50"
|
||||
const modalWizardBtnBack = "bg-transparent border border-line-strong text-ink-soft enabled:hover:bg-surface-muted"
|
||||
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
|
||||
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200"
|
||||
const popoverCls = "bg-surface rounded-default shadow-lg border border-line"
|
||||
|
||||
// TSX defaults: offset ?? 8, hoverDelay ?? 0, hoverCloseDelay ?? 150.
|
||||
const popoverOffset = 8
|
||||
|
||||
@@ -45,14 +45,14 @@ const (
|
||||
PrettyTableSizeSuperCompact PrettyTableSize = 2
|
||||
)
|
||||
|
||||
const ptTblContainer = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
|
||||
const ptTblContainer = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
|
||||
const ptTblWrapper = "overflow-x-auto w-full"
|
||||
const ptTblBase = "min-w-full"
|
||||
const ptHeaderContent = "transition-transform duration-150 ease-in-out"
|
||||
const ptHeaderInnerBase = "flex justify-between gap-2 items-center"
|
||||
|
||||
var ptHeaderColorCls = map[PrettyTableHeaderColor]string{
|
||||
PrettyTableColorDefault: "bg-neutral-50",
|
||||
PrettyTableColorDefault: "bg-surface-muted",
|
||||
PrettyTableColorBlue: "bg-sky-700 text-white",
|
||||
PrettyTableColorGreen: "bg-green-700 text-white",
|
||||
PrettyTableColorGray: "bg-neutral-600 text-white",
|
||||
@@ -70,11 +70,11 @@ var ptHeaderTextCls = map[PrettyTableHeaderColor]string{
|
||||
// ptRowHoverCls is applied to the tbody when Hover is on (matches the TSX
|
||||
// PT_ROW_HOVER_CLS map).
|
||||
var ptRowHoverCls = map[PrettyTableHeaderColor]string{
|
||||
PrettyTableColorDefault: "[&_tr:hover]:!bg-neutral-200",
|
||||
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100",
|
||||
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100",
|
||||
PrettyTableColorGray: "[&_tr:hover]:!bg-neutral-200",
|
||||
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100",
|
||||
PrettyTableColorDefault: "[&_tr:hover]:!bg-surface-strong",
|
||||
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
|
||||
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100 dark:bg-green-900",
|
||||
PrettyTableColorGray: "[&_tr:hover]:!bg-surface-strong",
|
||||
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
|
||||
}
|
||||
|
||||
var ptHeaderPaddingCls = map[PrettyTableSize]string{
|
||||
@@ -127,13 +127,13 @@ type PrettyTableOptions struct {
|
||||
func prettyTableBodyClass(o PrettyTableOptions) string {
|
||||
c := ptBodyPaddingCls[o.Size]
|
||||
if o.BorderY {
|
||||
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||||
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-line-strong")
|
||||
}
|
||||
if o.Alternate {
|
||||
c = cx(c, "[&_tr:nth-child(even)]:bg-neutral-100")
|
||||
c = cx(c, "[&_tr:nth-child(even)]:bg-surface-raised")
|
||||
}
|
||||
if o.BorderX {
|
||||
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-neutral-300")
|
||||
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-line-strong")
|
||||
}
|
||||
if o.Hover {
|
||||
c = cx(c, ptRowHoverCls[o.Color])
|
||||
@@ -146,7 +146,7 @@ func prettyTableBodyClass(o PrettyTableOptions) string {
|
||||
func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children ...*vdom.VNode) *vdom.VNode {
|
||||
containerCls := ptTblContainer
|
||||
if opts.SurroundingBorder {
|
||||
containerCls = cx(containerCls, "border border-neutral-300")
|
||||
containerCls = cx(containerCls, "border border-line-strong")
|
||||
}
|
||||
if opts.Shadow {
|
||||
containerCls = cx(containerCls, "shadow-sm")
|
||||
@@ -178,7 +178,7 @@ func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children
|
||||
))
|
||||
}
|
||||
|
||||
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
|
||||
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-line-strong"),
|
||||
vdom.Tr(kids(nil, headerCells)...),
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func (f *RemoteFlash) Fire() { f.visible.Set(true) }
|
||||
// Clear hides the flash.
|
||||
func (f *RemoteFlash) Clear() { f.visible.Set(false) }
|
||||
|
||||
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700"
|
||||
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-900 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700 dark:text-emerald-400"
|
||||
|
||||
// RemoteUpdateFlash is a small pill that briefly shows "Updated". It renders
|
||||
// nothing (nil) when when is false, mirroring the TSX <Show when=…>.
|
||||
|
||||
@@ -4,10 +4,10 @@ package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
const sidebarNavRoot = "bg-white rounded-default shadow-sm border border-neutral-200 py-2"
|
||||
const sidebarNavRoot = "bg-surface rounded-default shadow-sm border border-line py-2"
|
||||
const sidebarNavList = "list-none m-0 p-0"
|
||||
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-neutral-600 border-0 hover:text-neutral-900 hover:bg-neutral-50"
|
||||
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-neutral-500 border-0 hover:text-neutral-900 hover:bg-neutral-50"
|
||||
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-ink-soft border-0 hover:text-ink hover:bg-surface-muted"
|
||||
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-ink-muted border-0 hover:text-ink hover:bg-surface-muted"
|
||||
const sidebarNavIcon = "mr-2"
|
||||
|
||||
const sidebarLayoutRoot = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12"
|
||||
|
||||
265
go/webui/signaturepad.go
Normal file
265
go/webui/signaturepad.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Port of FormSignaturePad (web/uikit/Forms.tsx) — freehand signing.
|
||||
//
|
||||
// Two deliberate departures from the TSX.
|
||||
//
|
||||
// It draws into an SVG, not a <canvas>. The component's OUTPUT is SVG — the TSX kept
|
||||
// strokes in memory, painted them onto a canvas for the user, and serialized a separate
|
||||
// SVG string for the caller. That is two renderers for one drawing, and they can
|
||||
// disagree. Here the SVG the user is looking at IS the value the caller gets, so it
|
||||
// cannot be wrong. It also means a stored signature renders on the SERVER: the same
|
||||
// markup, no client needed to see it.
|
||||
//
|
||||
// And the in-flight stroke never touches a signal. A pointer moves sixty times a second,
|
||||
// and a signal write re-renders the whole page; the live stroke is written straight at
|
||||
// the element with SetHTML, and only the FINISHED stroke is committed. Without that,
|
||||
// signing your name would re-render the document a few hundred times.
|
||||
//
|
||||
// Create it once, alongside your signals — never inside a render.
|
||||
type SignaturePad struct {
|
||||
svgRef *vdom.Ref
|
||||
|
||||
// strokes are the finished ones: committed, re-rendered, part of the value.
|
||||
strokes *vdom.Signal[[]sigStroke]
|
||||
|
||||
// live is the stroke being drawn right now. NOT a signal — see above.
|
||||
live sigStroke
|
||||
drawing bool
|
||||
unsubs []wasmruntime.Unsub
|
||||
onChange func(svg string)
|
||||
width float64
|
||||
height float64
|
||||
}
|
||||
|
||||
type sigPoint struct{ X, Y float64 }
|
||||
type sigStroke []sigPoint
|
||||
|
||||
// SignaturePadOptions configures NewSignaturePad.
|
||||
type SignaturePadOptions struct {
|
||||
// Width and Height are the SVG's coordinate space (its viewBox), not its size on
|
||||
// screen — the element scales to its container and the strokes scale with it.
|
||||
// Default 600x120.
|
||||
Width, Height float64
|
||||
|
||||
// OnChange receives the signature as an SVG document, or "" when it is cleared.
|
||||
OnChange func(svg string)
|
||||
}
|
||||
|
||||
// NewSignaturePad creates a signature pad.
|
||||
func NewSignaturePad(o SignaturePadOptions) *SignaturePad {
|
||||
if o.Width <= 0 {
|
||||
o.Width = 600
|
||||
}
|
||||
if o.Height <= 0 {
|
||||
o.Height = 120
|
||||
}
|
||||
return &SignaturePad{
|
||||
svgRef: vdom.NewRef(),
|
||||
strokes: vdom.NewSignal([]sigStroke{}),
|
||||
onChange: o.OnChange,
|
||||
width: o.Width,
|
||||
height: o.Height,
|
||||
}
|
||||
}
|
||||
|
||||
// SignaturePadProps configures a render.
|
||||
type SignaturePadProps struct {
|
||||
Class string
|
||||
Hint string // shown while empty; default "Sign above"
|
||||
ClearText string // default "Clear"
|
||||
Disabled bool
|
||||
HideFooter bool // no hint / Clear button — for showing a signature back, read-only
|
||||
}
|
||||
|
||||
// IsEmpty reports whether anything has been drawn.
|
||||
func (s *SignaturePad) IsEmpty() bool { return len(s.strokes.Get()) == 0 }
|
||||
|
||||
// SVG is the signature as a standalone SVG document — the value to store. It is "" when
|
||||
// the pad is empty, so an empty pad is an empty string rather than a blank drawing.
|
||||
func (s *SignaturePad) SVG() string {
|
||||
strokes := s.strokes.Get()
|
||||
if len(strokes) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 `)
|
||||
b.WriteString(sigNum(s.width) + " " + sigNum(s.height))
|
||||
b.WriteString(`" width="` + sigNum(s.width) + `" height="` + sigNum(s.height) + `">`)
|
||||
b.WriteString(sigPaths(strokes))
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Clear empties the pad.
|
||||
func (s *SignaturePad) Clear() {
|
||||
s.endDrag()
|
||||
s.live = nil
|
||||
s.strokes.Set([]sigStroke{})
|
||||
if s.onChange != nil {
|
||||
s.onChange("")
|
||||
}
|
||||
}
|
||||
|
||||
// Dispose removes any listeners left behind by an interrupted drag.
|
||||
func (s *SignaturePad) Dispose() { s.endDrag() }
|
||||
|
||||
// Render draws the pad.
|
||||
func (s *SignaturePad) Render(p SignaturePadProps) *vdom.VNode {
|
||||
svgMods := []vdom.Mod{
|
||||
vdom.WithRef(s.svgRef),
|
||||
vdom.Attr("viewBox", "0 0 "+sigNum(s.width)+" "+sigNum(s.height)),
|
||||
// touch-none: without it, drawing on a phone scrolls the page instead.
|
||||
vdom.Attr("class", "block h-auto w-full cursor-crosshair touch-none"),
|
||||
vdom.Attr("role", "img"),
|
||||
vdom.Raw(sigPaths(s.strokes.Get())),
|
||||
}
|
||||
if !p.Disabled {
|
||||
svgMods = append(svgMods, vdom.OnEvent(vdom.EVENT_POINTERDOWN, s.onDown))
|
||||
}
|
||||
|
||||
kids := []*vdom.VNode{vdom.Svg(svgMods...)}
|
||||
|
||||
if !p.HideFooter {
|
||||
hint := ""
|
||||
if s.IsEmpty() {
|
||||
hint = pick(p.Hint, "Sign above")
|
||||
}
|
||||
clear := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "text-xs text-ink-muted hover:text-ink disabled:opacity-50"),
|
||||
vdom.Text(pick(p.ClearText, "Clear")),
|
||||
}
|
||||
if p.Disabled || s.IsEmpty() {
|
||||
clear = append(clear, vdom.Attr("disabled", "disabled"))
|
||||
} else {
|
||||
clear = append(clear, vdom.On(vdom.EVENT_CLICK, s.Clear))
|
||||
}
|
||||
kids = append(kids, vdom.Div(
|
||||
vdom.Attr("class", "flex items-center justify-between border-t border-line bg-surface-muted px-2 py-1"),
|
||||
vdom.Span(vdom.Attr("class", "text-xs italic text-ink-faint"), vdom.Text(hint)),
|
||||
vdom.Button(clear...),
|
||||
))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("overflow-hidden rounded-default border border-line-strong bg-surface", p.Class))}
|
||||
for _, k := range kids {
|
||||
mods = append(mods, k)
|
||||
}
|
||||
return vdom.Div(mods...)
|
||||
}
|
||||
|
||||
// ---- drawing ------------------------------------------------------------
|
||||
|
||||
func (s *SignaturePad) onDown(e vdom.Event) {
|
||||
e.PreventDefault()
|
||||
s.endDrag() // a previous drag that never got its pointerup (alt-tab, say)
|
||||
|
||||
s.drawing = true
|
||||
s.live = sigStroke{s.point(e)}
|
||||
|
||||
// The listeners go on the DOCUMENT, not the element. Drag off the edge of the pad
|
||||
// and the stroke should follow the cursor and finish when you let go — with element
|
||||
// listeners the pointer simply escapes and the stroke is left half-drawn.
|
||||
s.unsubs = append(s.unsubs,
|
||||
wasmruntime.OnDocument(vdom.EVENT_POINTERMOVE, false, s.onMove),
|
||||
wasmruntime.OnDocument(vdom.EVENT_POINTERUP, false, s.onUp),
|
||||
wasmruntime.OnDocument(vdom.EVENT_POINTERCANCEL, false, s.onUp),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *SignaturePad) onMove(e vdom.Event) {
|
||||
if !s.drawing {
|
||||
return
|
||||
}
|
||||
s.live = append(s.live, s.point(e))
|
||||
|
||||
// Imperative. This runs on every pointer move; a signal here would re-render the
|
||||
// entire application between one pixel of ink and the next.
|
||||
wasmruntime.SetHTML(s.svgRef, sigPaths(append(append([]sigStroke{}, s.strokes.Get()...), s.live)))
|
||||
}
|
||||
|
||||
func (s *SignaturePad) onUp(vdom.Event) {
|
||||
if !s.drawing {
|
||||
return
|
||||
}
|
||||
s.endDrag()
|
||||
|
||||
// A stroke of one point is a click, not a mark. Dropping it keeps a stray tap from
|
||||
// counting as a signature.
|
||||
if len(s.live) >= 2 {
|
||||
s.strokes.Set(append(append([]sigStroke{}, s.strokes.Get()...), s.live))
|
||||
if s.onChange != nil {
|
||||
s.onChange(s.SVG())
|
||||
}
|
||||
}
|
||||
s.live = nil
|
||||
}
|
||||
|
||||
func (s *SignaturePad) endDrag() {
|
||||
s.drawing = false
|
||||
for _, u := range s.unsubs {
|
||||
u()
|
||||
}
|
||||
s.unsubs = nil
|
||||
}
|
||||
|
||||
// point maps a pointer's viewport coordinates into the SVG's coordinate space. The
|
||||
// element is fluid and the viewBox is fixed, so the two differ by whatever the browser
|
||||
// scaled the SVG to — measuring is the only way to know.
|
||||
func (s *SignaturePad) point(e vdom.Event) sigPoint {
|
||||
r := wasmruntime.Measure(s.svgRef)
|
||||
if r.Width == 0 || r.Height == 0 {
|
||||
return sigPoint{}
|
||||
}
|
||||
return sigPoint{
|
||||
X: (float64(e.ClientX()) - r.X) * (s.width / r.Width),
|
||||
Y: (float64(e.ClientY()) - r.Y) * (s.height / r.Height),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serialization ------------------------------------------------------
|
||||
|
||||
// sigPaths renders strokes as SVG <path> elements, smoothed.
|
||||
//
|
||||
// Straight lines between raw pointer samples look like a seismograph, not handwriting.
|
||||
// Each segment is a quadratic curve THROUGH the sampled point and ending at the midpoint
|
||||
// of the next one, which is the standard trick for turning a polyline into something
|
||||
// that reads as a pen stroke.
|
||||
func sigPaths(strokes []sigStroke) string {
|
||||
var b strings.Builder
|
||||
for _, st := range strokes {
|
||||
if len(st) < 2 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(`<path d="M`)
|
||||
b.WriteString(sigNum(st[0].X) + "," + sigNum(st[0].Y))
|
||||
if len(st) == 2 {
|
||||
b.WriteString(" L" + sigNum(st[1].X) + "," + sigNum(st[1].Y))
|
||||
} else {
|
||||
for i := 1; i < len(st)-1; i++ {
|
||||
mx := (st[i].X + st[i+1].X) / 2
|
||||
my := (st[i].Y + st[i+1].Y) / 2
|
||||
b.WriteString(" Q" + sigNum(st[i].X) + "," + sigNum(st[i].Y) + "," + sigNum(mx) + "," + sigNum(my))
|
||||
}
|
||||
last := st[len(st)-1]
|
||||
b.WriteString(" L" + sigNum(last.X) + "," + sigNum(last.Y))
|
||||
}
|
||||
b.WriteString(`" fill="none" stroke="#1a1a2e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sigNum formats a coordinate to one decimal, without a trailing ".0" — the markup is
|
||||
// the value the caller stores, and there is no reason to store 600.0 as six bytes.
|
||||
func sigNum(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
88
go/webui/signaturepad_test.go
Normal file
88
go/webui/signaturepad_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// The pad's value IS the markup it shows — that is the whole reason it draws into an SVG
|
||||
// rather than a canvas. If the two could differ, the user would sign one thing and the
|
||||
// caller would store another.
|
||||
func TestSignaturePadValueIsWhatIsDrawn(t *testing.T) {
|
||||
var got string
|
||||
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
|
||||
|
||||
pad.strokes.Set([]sigStroke{{{X: 10, Y: 20}, {X: 30, Y: 40}, {X: 50, Y: 20}}})
|
||||
|
||||
value := pad.SVG()
|
||||
shown := vdom.RenderHTML(pad.Render(SignaturePadProps{}))
|
||||
|
||||
// Every path in the value is present in the rendered element.
|
||||
paths := sigPaths(pad.strokes.Get())
|
||||
if paths == "" {
|
||||
t.Fatal("no path was generated for a three-point stroke")
|
||||
}
|
||||
if !strings.Contains(value, paths) {
|
||||
t.Error("the emitted SVG does not contain the strokes it was built from")
|
||||
}
|
||||
if !strings.Contains(shown, `d="M10,20`) {
|
||||
t.Errorf("the rendered pad is not showing the stroke:\n%s", shown)
|
||||
}
|
||||
_ = got
|
||||
}
|
||||
|
||||
// An empty pad is an empty string, not a blank drawing. A caller storing the value must
|
||||
// be able to ask "did they sign?" without parsing SVG.
|
||||
func TestSignaturePadEmptyIsEmptyString(t *testing.T) {
|
||||
pad := NewSignaturePad(SignaturePadOptions{})
|
||||
if !pad.IsEmpty() {
|
||||
t.Error("a fresh pad should be empty")
|
||||
}
|
||||
if pad.SVG() != "" {
|
||||
t.Errorf("an empty pad produced %q, want \"\"", pad.SVG())
|
||||
}
|
||||
}
|
||||
|
||||
// Clear reports the emptiness to the caller. Without the callback a cleared pad would
|
||||
// leave the last signature sitting in whatever the caller stored it in.
|
||||
func TestSignaturePadClearNotifies(t *testing.T) {
|
||||
got := "not called"
|
||||
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
|
||||
pad.strokes.Set([]sigStroke{{{X: 1, Y: 1}, {X: 2, Y: 2}}})
|
||||
|
||||
pad.Clear()
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("OnChange got %q on clear, want \"\"", got)
|
||||
}
|
||||
if !pad.IsEmpty() {
|
||||
t.Error("the pad is not empty after Clear")
|
||||
}
|
||||
}
|
||||
|
||||
// A single point is a tap, not a mark: it has no length, produces no path, and must not
|
||||
// count as a signature.
|
||||
func TestSignaturePadIgnoresSinglePointStrokes(t *testing.T) {
|
||||
if got := sigPaths([]sigStroke{{{X: 5, Y: 5}}}); got != "" {
|
||||
t.Errorf("a one-point stroke produced a path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two points are a straight line; three or more are smoothed into curves. Handwriting
|
||||
// drawn as raw polylines looks like a seismograph.
|
||||
func TestSignaturePadSmoothsLongStrokes(t *testing.T) {
|
||||
line := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}}})
|
||||
if !strings.Contains(line, " L10,10") {
|
||||
t.Errorf("a two-point stroke should be a straight line: %q", line)
|
||||
}
|
||||
if strings.Contains(line, "Q") {
|
||||
t.Errorf("a two-point stroke has nothing to smooth: %q", line)
|
||||
}
|
||||
|
||||
curve := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}, {X: 20, Y: 0}}})
|
||||
if !strings.Contains(curve, "Q") {
|
||||
t.Errorf("a three-point stroke should be smoothed: %q", curve)
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
// defaultIndex prop likewise collapses into the caller-supplied ActiveIndex.
|
||||
|
||||
const tabsBase = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150"
|
||||
const tabsInactive = "text-text-muted border-neutral-200 hover:text-text-body"
|
||||
const tabsActive = "text-primary border-primary"
|
||||
const tabsInactive = "text-text-muted border-line hover:text-text-body"
|
||||
const tabsActive = "text-accent border-primary"
|
||||
|
||||
// TabItem is one tab: a title, an optional numeric badge (shown only when > 0),
|
||||
// and optional inline panel content.
|
||||
@@ -119,7 +119,7 @@ func TabGroup(p TabGroupProps) *vdom.VNode {
|
||||
header = append(header, vdom.Button(btn...))
|
||||
}
|
||||
if p.Actions != nil {
|
||||
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
|
||||
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-line flex items-center justify-end pb-1"),
|
||||
vdom.Div(vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
|
||||
))
|
||||
}
|
||||
|
||||
207
go/webui/theme.go
Normal file
207
go/webui/theme.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Dark mode.
|
||||
//
|
||||
// The kit is themed by TOKENS, not by a dark: variant on every class. A component says
|
||||
// bg-surface, text-ink, border-line; the theme decides what those mean. Flipping the
|
||||
// theme is therefore one class on <html> and a block of CSS variables — not four hundred
|
||||
// class strings, each of which is a chance to forget one and leave a white card sitting
|
||||
// in the middle of a dark page.
|
||||
//
|
||||
// Only genuinely COLOURED things (an alert's red tint, a validation message) carry dark:
|
||||
// variants, because those are not surfaces or ink and there is no token that means "red,
|
||||
// but for a dark background".
|
||||
//
|
||||
// The app must define the tokens and the variant in its stylesheet. See the example's
|
||||
// css/app.css; the required set is listed in ThemeTokens below.
|
||||
|
||||
// ThemeTokens is the contract between the kit and an app's stylesheet: the CSS custom
|
||||
// properties every component assumes exist, with light values in :root and dark values
|
||||
// under .dark.
|
||||
//
|
||||
// It is a documentation constant, not code — but it is here, next to the components that
|
||||
// depend on it, rather than in a README nobody opens when a card comes out the wrong
|
||||
// colour.
|
||||
const ThemeTokens = `
|
||||
--color-surface page and card backgrounds
|
||||
--color-surface-muted a subtle fill (table stripes, footers)
|
||||
--color-surface-raised a hovered or filled row
|
||||
--color-surface-strong the strongest neutral fill
|
||||
--color-line ordinary borders and dividers
|
||||
--color-line-strong an input's border — the one you must be able to see
|
||||
--color-ink body text and headings
|
||||
--color-ink-soft secondary text
|
||||
--color-ink-muted labels, captions
|
||||
--color-ink-faint placeholders, disabled text
|
||||
--color-accent accent TEXT and icons (readable on the surface)
|
||||
--color-primary accent FILLS, which carry white text
|
||||
--color-primary-hover
|
||||
--color-primary-subtle a tinted panel
|
||||
--color-primary-border
|
||||
`
|
||||
|
||||
// ThemeMode is what the user chose. System is the default: follow the OS until told
|
||||
// otherwise, because a site that ignores the OS setting is a site that flashes white at
|
||||
// someone who asked their whole computer not to.
|
||||
type ThemeMode string
|
||||
|
||||
const (
|
||||
ThemeSystem ThemeMode = "system"
|
||||
ThemeLight ThemeMode = "light"
|
||||
ThemeDark ThemeMode = "dark"
|
||||
)
|
||||
|
||||
// themeStorageKey is where the choice is kept. The bootstrap script in the document head
|
||||
// reads the SAME key — see ThemeBootScript.
|
||||
const themeStorageKey = "kjol-theme"
|
||||
|
||||
// Theme is the site-wide theme controller. Create one, call Init once after mount, and
|
||||
// render its Toggle wherever the switch belongs.
|
||||
type Theme struct {
|
||||
mode *vdom.Signal[ThemeMode]
|
||||
// dark is the RESOLVED answer: what "system" actually means right now.
|
||||
dark *vdom.Signal[bool]
|
||||
stop wasmruntime.Unsub
|
||||
}
|
||||
|
||||
// NewTheme creates the controller. It reads no storage and touches no DOM — Init does
|
||||
// that, once the document exists.
|
||||
func NewTheme() *Theme {
|
||||
return &Theme{
|
||||
mode: vdom.NewSignal(ThemeSystem),
|
||||
dark: vdom.NewSignal(false),
|
||||
}
|
||||
}
|
||||
|
||||
// Init adopts the stored choice and starts following the OS while the mode is "system".
|
||||
// Call it once, after the app has mounted.
|
||||
//
|
||||
// It does not FLASH, because it is not what puts the class on <html> for the first
|
||||
// paint: ThemeBootScript already did that, before any of this code existed. Init only
|
||||
// takes over.
|
||||
func (t *Theme) Init() {
|
||||
if s, ok := wasmruntime.StorageGet(themeStorageKey); ok {
|
||||
switch ThemeMode(s) {
|
||||
case ThemeLight:
|
||||
t.mode.Set(ThemeLight)
|
||||
case ThemeDark:
|
||||
t.mode.Set(ThemeDark)
|
||||
}
|
||||
}
|
||||
t.apply()
|
||||
|
||||
// Follow the OS, but only while the user has not overridden it.
|
||||
t.stop = wasmruntime.OnMediaChange("(prefers-color-scheme: dark)", func(bool) {
|
||||
if t.mode.Get() == ThemeSystem {
|
||||
t.apply()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Dispose stops following the OS.
|
||||
func (t *Theme) Dispose() {
|
||||
if t.stop != nil {
|
||||
t.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Mode is the user's choice; IsDark is what that currently resolves to.
|
||||
func (t *Theme) Mode() ThemeMode { return t.mode.Get() }
|
||||
func (t *Theme) IsDark() bool { return t.dark.Get() }
|
||||
|
||||
// Set changes the theme. "system" forgets the choice entirely rather than storing the
|
||||
// current resolution — otherwise "follow my OS" would quietly freeze at whatever the OS
|
||||
// happened to say the day you chose it.
|
||||
func (t *Theme) Set(m ThemeMode) {
|
||||
t.mode.Set(m)
|
||||
if m == ThemeSystem {
|
||||
wasmruntime.StorageRemove(themeStorageKey)
|
||||
} else {
|
||||
wasmruntime.StorageSet(themeStorageKey, string(m))
|
||||
}
|
||||
t.apply()
|
||||
}
|
||||
|
||||
// Toggle flips between light and dark. It resolves "system" first, so the first click
|
||||
// does what it looks like it will do — the opposite of what you are looking at.
|
||||
func (t *Theme) Toggle() {
|
||||
if t.resolve() {
|
||||
t.Set(ThemeLight)
|
||||
return
|
||||
}
|
||||
t.Set(ThemeDark)
|
||||
}
|
||||
|
||||
// resolve turns the mode into a yes or no.
|
||||
func (t *Theme) resolve() bool {
|
||||
switch t.mode.Get() {
|
||||
case ThemeDark:
|
||||
return true
|
||||
case ThemeLight:
|
||||
return false
|
||||
default:
|
||||
return wasmruntime.PrefersDark()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Theme) apply() {
|
||||
dark := t.resolve()
|
||||
t.dark.Set(dark)
|
||||
wasmruntime.SetRootClass("dark", dark)
|
||||
}
|
||||
|
||||
// ThemeToggleProps configures the switch.
|
||||
type ThemeToggleProps struct {
|
||||
Class string
|
||||
// Small renders an icon-only button.
|
||||
Small bool
|
||||
}
|
||||
|
||||
// ThemeToggle is the switch: one button, showing the theme you would get by pressing it.
|
||||
func (t *Theme) ThemeToggle(p ThemeToggleProps) *vdom.VNode {
|
||||
icon, label := "moon", "Dark"
|
||||
if t.IsDark() {
|
||||
icon, label = "sun", "Light"
|
||||
}
|
||||
|
||||
cls := "inline-flex items-center gap-2 rounded-default border border-line px-2.5 py-1.5 text-sm text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
if p.Small {
|
||||
cls = "inline-flex h-8 w-8 items-center justify-center rounded-default border border-line text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(cls, p.Class)),
|
||||
vdom.Attr("aria-label", "Switch to "+label+" theme"),
|
||||
vdom.Attr("title", "Switch to "+label+" theme"),
|
||||
vdom.On(vdom.EVENT_CLICK, t.Toggle),
|
||||
IconInline(icon, 15, ""),
|
||||
}
|
||||
if !p.Small {
|
||||
mods = append(mods, vdom.Span(vdom.Text(label)))
|
||||
}
|
||||
return vdom.Button(mods...)
|
||||
}
|
||||
|
||||
// ThemeBootScript is the inline script an app puts in its <head>, BEFORE any stylesheet
|
||||
// or markup.
|
||||
//
|
||||
// It exists to prevent the flash. The server cannot read localStorage, so it cannot know
|
||||
// which theme to render; if the class were applied by the WebAssembly after it loads,
|
||||
// every dark-mode user would be shown a white page for as long as the binary takes to
|
||||
// download, and then have it yanked out from under them. This runs first, synchronously,
|
||||
// and the first paint is already correct.
|
||||
//
|
||||
// It is ten lines of JavaScript in a project that has none. That is the price of the
|
||||
// browser giving the page no way to ask about localStorage before it paints, and it is
|
||||
// worth paying — the alternative is a white flash on every single load.
|
||||
const ThemeBootScript = `<script>(function(){try{
|
||||
var m = localStorage.getItem("kjol-theme");
|
||||
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
}catch(e){}})();</script>`
|
||||
@@ -72,7 +72,7 @@ var toastContainerPositions = map[ToastPosition]string{
|
||||
ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
|
||||
}
|
||||
|
||||
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
|
||||
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-line border-l-4 bg-surface min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
|
||||
|
||||
var toastTypeBorder = map[ToastType]string{
|
||||
ToastSuccess: "border-l-green-700",
|
||||
@@ -84,9 +84,9 @@ var toastTypeBorder = map[ToastType]string{
|
||||
|
||||
var toastIconColor = map[ToastType]string{
|
||||
ToastSuccess: "text-green-600",
|
||||
ToastError: "text-red-600",
|
||||
ToastError: "text-red-600 dark:text-red-400",
|
||||
ToastWarning: "text-yellow-600",
|
||||
ToastInfo: "text-sky-700",
|
||||
ToastInfo: "text-sky-700 dark:text-sky-400",
|
||||
ToastGeneric: "",
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
|
||||
if icon != "" {
|
||||
row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
|
||||
}
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message)))
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-ink"), vdom.Text(t.Message)))
|
||||
if t.Dismissible {
|
||||
dismiss := []vdom.Mod{
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"),
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer text-ink-faint hover:text-ink-soft bg-transparent border-0 p-0 transition-colors"),
|
||||
vdom.Attr("aria-label", "Dismiss"),
|
||||
}
|
||||
if onDismiss != nil {
|
||||
@@ -133,13 +133,13 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
|
||||
// string on every render is what stops the reconciler's attribute diff from
|
||||
// resetting it back to 100% mid-countdown.
|
||||
bar := []vdom.Mod{
|
||||
vdom.Attr("class", "h-full bg-neutral-300"),
|
||||
vdom.Attr("class", "h-full bg-surface-strong"),
|
||||
vdom.Attr("style", "width:100%"),
|
||||
}
|
||||
if progressRef != nil {
|
||||
bar = append(bar, vdom.WithRef(progressRef))
|
||||
}
|
||||
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-neutral-100"),
|
||||
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-surface-raised"),
|
||||
vdom.Div(bar...),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
|
||||
onChange(!checked)
|
||||
}
|
||||
|
||||
trackState := "bg-neutral-300"
|
||||
trackState := "bg-surface-strong"
|
||||
if checked {
|
||||
trackState = "bg-primary"
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
|
||||
if checked {
|
||||
knobState = "translate-x-[18px]"
|
||||
}
|
||||
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform", knobState)
|
||||
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-surface shadow-sm transition-transform", knobState)
|
||||
|
||||
ariaChecked := "false"
|
||||
if checked {
|
||||
@@ -47,16 +47,16 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
|
||||
if label != "" || description != "" {
|
||||
text := []vdom.Mod{vdom.Attr("class", "flex flex-col leading-tight")}
|
||||
if label != "" {
|
||||
labelColor := "text-neutral-800"
|
||||
labelColor := "text-ink"
|
||||
if disabled {
|
||||
labelColor = "text-neutral-400"
|
||||
labelColor = "text-ink-faint"
|
||||
}
|
||||
text = append(text, vdom.Span(vdom.Attr("class", cx("text-sm select-none", labelColor)),
|
||||
vdom.On(vdom.EVENT_CLICK, toggle),
|
||||
vdom.Text(label)))
|
||||
}
|
||||
if description != "" {
|
||||
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
|
||||
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(description)))
|
||||
}
|
||||
mods = append(mods, vdom.Div(text...))
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ const (
|
||||
|
||||
// Tailwind classes, verbatim from the TSX.
|
||||
const (
|
||||
tutorialPopoverClass = "fixed z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm"
|
||||
tutorialPopoverClass = "fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm"
|
||||
tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
|
||||
tutorialCatcherClass = "fixed inset-0 -z-10 cursor-pointer"
|
||||
tutorialBackdropClass = "fixed inset-0 bg-black/50 z-150"
|
||||
@@ -701,23 +701,23 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
|
||||
// Header: optional title + "X of N", and a close button.
|
||||
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
|
||||
if step != nil && step.Title != "" {
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-neutral-900"),
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-ink"),
|
||||
vdom.Text(step.Title)))
|
||||
}
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"),
|
||||
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
|
||||
|
||||
header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
|
||||
vdom.Div(headerLeft...),
|
||||
vdom.Button(vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"),
|
||||
vdom.Attr("class", "cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft"),
|
||||
vdom.On(vdom.EVENT_CLICK, t.End),
|
||||
Icon("xmark", 18, ""),
|
||||
),
|
||||
)
|
||||
|
||||
// Body.
|
||||
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
|
||||
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-ink-soft")}
|
||||
if step != nil && step.Content != nil {
|
||||
if c := step.Content(); c != nil {
|
||||
bodyMods = append(bodyMods, c)
|
||||
@@ -749,7 +749,7 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
|
||||
if total > 1 {
|
||||
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
|
||||
for i := 0; i < total; i++ {
|
||||
state := "bg-neutral-300"
|
||||
state := "bg-surface-strong"
|
||||
if i == idx {
|
||||
state = "bg-sky-600 scale-110"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user