Add landing page for kjol, documentation

This commit is contained in:
2026-07-13 16:51:21 -04:00
parent 5230bd6702
commit fec8ef4a3e
54 changed files with 3529 additions and 547 deletions

View File

@@ -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.