rename packages, refactor out tailwind compiler,

This commit is contained in:
2026-07-13 15:06:09 -04:00
parent c5b14d7c41
commit d52151cc1a
62 changed files with 1446 additions and 460 deletions

16
.vscode/launch.json vendored
View File

@@ -1,7 +1,12 @@
{
// Debug configs for the gowasm example. cwd is the example dir because the
// dev server resolves ./wwwroot, ./app, ./wasm and the watched engine dir
// dev server resolves ./wwwroot, ./app, ./wasm and the watched engine dirs
// relative to it.
//
// The dev-server configs preLaunchTask the prebuild (codegen + Tailwind). Codegen must
// run before the server is compiled — it writes app/*.gen.go, which the server imports —
// and under the debugger the binary is built by Delve, not by the server's own Build
// hook, so nothing else would generate it.
"version": "0.2.0",
"configurations": [
{
@@ -11,7 +16,8 @@
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"args": ["-addr", ":8085"]
"args": ["-addr", ":8085"],
"preLaunchTask": "gowasm: prebuild"
},
{
"name": "gowasm: dev server (no watch)",
@@ -20,7 +26,11 @@
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"args": ["-watch=false"]
// -watch=false skips the server's own Build entirely, so wwwroot is served exactly
// as it sits on disk. The prebuild is what puts current CSS and generated code
// there; without it you would be debugging against stale artefacts.
"args": ["-watch=false"],
"preLaunchTask": "gowasm: prebuild"
},
{
"name": "gowasm: codegen (wasmgen)",

74
.vscode/tasks.json vendored
View File

@@ -2,14 +2,62 @@
// Tasks for the kjol repo. The gowasm example is a nested module at
// go/cmd/examples/go-wasm-web, so its tasks set cwd there; the module-wide
// Go tasks run in go/.
//
// The example's build pipeline is Go, not a shell script — see
// go/cmd/examples/go-wasm-web/buildsteps. The dev server calls the same steps on every
// save, and a bash script would not run for anyone on Windows. "gowasm: prebuild" is
// codegen + Tailwind, and everything that runs the app depends on it; the two steps are
// also exposed on their own so you can rerun just the one you need.
"version": "2.0.0",
"tasks": [
{
"label": "gowasm: dev server (hot reload)",
"detail": "Run the go-wasm-web example: codegen + SSR + hot reload on :8085",
"label": "gowasm: prebuild",
"detail": "Codegen (pages/layouts/server components) then Tailwind. Dependency of the run + debug configs.",
"dependsOrder": "sequence",
"dependsOn": ["gowasm: codegen", "gowasm: tailwind"],
"problemMatcher": [],
"group": "build"
},
{
"label": "gowasm: codegen",
"detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)",
"type": "shell",
"command": "go run ./server",
"command": "go",
"args": ["run", "kjol/cmd/wasmgen", "./app"],
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" },
"group": "build"
},
{
"label": "gowasm: tailwind",
"detail": "Compile css/app.css -> wwwroot/app.css, scanning the webui kit + the example's Go markup",
"type": "shell",
"command": "go",
"args": [
"run", "./cmd/twcss",
"-entry", "cmd/examples/go-wasm-web/css/app.css",
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"cmd/examples/go-wasm-web/app/**/*.go",
"cmd/examples/go-wasm-web/server/**/*.go"
],
// Run from the kjol module root so the Tailwind engine's deps resolve in kjol's
// go.mod, not the example's.
"options": { "cwd": "${workspaceFolder}/go" },
"problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" },
"group": "build"
},
{
"label": "gowasm: dev server (hot reload)",
"detail": "SSR + hot reload on :8085. Rebuilds Go on save; a .css save recompiles Tailwind only and swaps the stylesheet in place.",
"type": "shell",
"command": "go",
"args": ["run", "./server"],
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"dependsOn": ["gowasm: prebuild"],
"isBackground": true,
"problemMatcher": {
"owner": "go",
@@ -19,27 +67,19 @@
},
"background": {
"activeOnStart": true,
"beginsPattern": "rebuilding",
"endsPattern": "serving|reloading clients"
"beginsPattern": "rebuilding|recompiling",
"endsPattern": "serving|reloading clients|stylesheet updated"
}
},
"presentation": { "reveal": "always", "panel": "dedicated", "clear": true },
"group": { "kind": "build", "isDefault": true }
},
{
"label": "gowasm: build (build.sh)",
"detail": "One-off build of the example: codegen + wasm + stage wasm_exec.js",
"label": "gowasm: build",
"detail": "One-off cold build of the example: codegen + Tailwind + wasm + stage wasm_exec.js",
"type": "shell",
"command": "./build.sh",
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"problemMatcher": ["$go"],
"group": "build"
},
{
"label": "gowasm: codegen",
"detail": "Regenerate app/*.gen.go from //gowasm: directives",
"type": "shell",
"command": "go run kjol/cmd/wasmgen ./app",
"command": "go",
"args": ["run", "./build"],
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"problemMatcher": ["$go"],
"group": "build"

View File

@@ -24,23 +24,29 @@ Each top-level directory is one language / build root:
```
kjol/
go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/<pkg>`.
web/ all JS/TS (browser + SSR). No build system of its own; built by go/bundler.
web/ all JS/TS (browser + SSR). No build system of its own; built by go/webbundler.
# future: cpp/ kotlin/ swift/
```
Language-first, **not** feature-first. Consequence: the **bundler is Go** and lives in
`go/bundler` even though it builds `web/`. Don't "fix" this by splitting it.
Language-first, **not** feature-first. Consequence: the **web bundler is Go** and lives in
`go/webbundler` even though it builds `web/`. Don't "fix" this by splitting it.
### go/ — module `kjol`
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
l4g security snailmail validation bundler`, plus the **gowasm** web-UI engine (`vdom`
l4g security snailmail validation webbundler tw`, plus the **gowasm** web-UI engine (`vdom`
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported
from `web/kit`; author components in pure Go compiled to WebAssembly; all stdlib-only), and
`cmd/{bundle,migrate,loc,passgen,typecheck,wasmgen}`. A runnable
`cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`. A runnable
example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays
out of kjol).
`webbundler` is the **JS** build (TSX → Solid → esbuild). `tw` is the **Tailwind v4
compiler**, and it is deliberately NOT inside it: Tailwind only reads text and writes CSS,
and the text is just as likely to be Go — the gowasm kit writes its markup in Go and has no
JS build at all. Keeping it in the bundler made every Go-only consumer drag a JavaScript
bundler along for a CSS file.
Build / test (run from repo root):
```
go -C go build ./...

View File

@@ -1,19 +0,0 @@
package bundler
// CompileTailwind compiles a Tailwind v4 entry stylesheet with kjol's native
// engine, discovering utility candidates from the given source globs (relative
// to baseDir). The scanner is language-agnostic — it extracts candidate class
// tokens from any text file — so this works for markup authored in Go (e.g. the
// go-wasm-web example's webui components) just as well as .tsx/.html.
//
// entryCSS is the stylesheet source (typically `@import "tailwindcss";` plus an
// `@theme { … }` block). Returns minified CSS. This is the app-bundler engine
// (twCompile/scanSources) exposed for consumers that don't go through Build.
func CompileTailwind(entryCSS, baseDir string, sourceGlobs []string) (string, error) {
candidates := scanSources(baseDir, sourceGlobs)
compiled, _, err := twCompile(entryCSS, baseDir, candidates)
if err != nil {
return "", err
}
return m.String("text/css", compiled)
}

View File

@@ -1,14 +1,14 @@
package main
// Thin CLI wrapper around kjol/bundler. The bundler wires its own Go-native
// Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic.
// Thin CLI wrapper around kjol/webbundler. The bundler wires its own Go-native
// Solid JSX compiler (see webbundler.Build), so this wrapper carries no build logic.
import (
"flag"
"fmt"
"os"
"kjol/bundler"
"kjol/webbundler"
)
func main() {
@@ -19,14 +19,14 @@ func main() {
genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default <app>/src/ui/generated).")
flag.Parse()
cfg := bundler.Config{
cfg := webbundler.Config{
AppFrontend: *app,
WebDir: *web,
Output: *out,
GenGoDir: *genGo,
GenTSDir: *genTS,
}
if err := bundler.Build(cfg); err != nil {
if err := webbundler.Build(cfg); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

View File

@@ -33,10 +33,30 @@ a `static` route, and fetching only exists on the client, so the fetches no-op
during SSR: the server pre-renders the page's **spinner**, and the client runs
them for real after hydration.
Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`,
which scans the Go markup + the `webui` kit for utility classes and compiles
`css/app.css``wwwroot/app.css` with kjol's native Tailwind v4 engine. There is
**no Bootstrap and no hand-written CSS**. (`build.sh` does a one-off build.)
Styling is **Tailwind**: the build runs `kjol/cmd/twcss`, which scans the Go markup +
the `webui` kit for utility classes and compiles `css/app.css``wwwroot/app.css` with
kjol's native Tailwind v4 engine (`kjol/tw`). There is **no Bootstrap and no
hand-written CSS**.
Saving a `.css` file recompiles **only** Tailwind and swaps the stylesheet into the live
page — no wasm rebuild, no reload, no lost state. Saving a `.go` file does the full
rebuild and hot-swaps the wasm.
## Building
The build is Go, not a shell script — `buildsteps/` holds the four steps (codegen →
Tailwind → wasm → `wasm_exec.js` shim), and both the one-off build and the dev server's
watch loop call the *same* functions, so they cannot drift apart.
```
go run ./build # one-off: codegen + Tailwind + wasm + shim
go run ./server # dev server: does the same build, then watches and hot-reloads
```
In VS Code these are the `gowasm: build` and `gowasm: dev server (hot reload)` tasks;
both run through `gowasm: prebuild` (codegen + Tailwind), which is also the
`preLaunchTask` of the debug configs — under the debugger the binary is built by Delve,
so nothing else would generate `app/*.gen.go`.
## This is a separate module

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Pre-compile step: directive codegen, Tailwind CSS, WebAssembly build, JS shim.
# Run the dev server instead (go run ./server) for hot reload; this is for a
# one-off/production-style build. Run from anywhere.
set -euo pipefail
cd "$(dirname "$0")"
KJOL_GO="$(cd ../../.. && pwd)"
echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)"
go run kjol/cmd/wasmgen ./app
echo "==> Compiling Tailwind CSS -> wwwroot/app.css (scanning webui + Go markup)"
# Run twcss from the kjol module root so the Tailwind engine's deps resolve there
# (not in this example module — keeps its go.mod lean).
( cd "$KJOL_GO" && go run ./cmd/twcss \
-entry cmd/examples/go-wasm-web/css/app.css \
-out cmd/examples/go-wasm-web/wwwroot/app.css \
-base . \
'webui/**/*.go' \
'cmd/examples/go-wasm-web/app/**/*.go' \
'cmd/examples/go-wasm-web/server/**/*.go' )
echo "==> Compiling ./wasm to wwwroot/app.wasm (GOOS=js GOARCH=wasm)"
GOOS=js GOARCH=wasm go build -o wwwroot/app.wasm ./wasm
echo "==> Copying Go's wasm_exec.js shim into wwwroot/"
GOROOT="$(go env GOROOT)"
shim="$GOROOT/lib/wasm/wasm_exec.js" # Go >= 1.24
[ -f "$shim" ] || shim="$GOROOT/misc/wasm/wasm_exec.js" # Go <= 1.23
rm -f wwwroot/wasm_exec.js # GOROOT copy is read-only; remove before overwriting
cp "$shim" wwwroot/wasm_exec.js
chmod u+w wwwroot/wasm_exec.js
echo "==> Done. Run the server with: go run ./server"
echo " then open http://localhost:8085"

View File

@@ -0,0 +1,41 @@
// Command build runs the example's full pre-compile step once: directive codegen,
// Tailwind, the wasm binary, and Go's JS shim.
//
// go run ./build # from cmd/examples/go-wasm-web
//
// For day-to-day work run the dev server instead (`go run ./server`) — it performs
// these same steps on every save and hot-swaps the result into the browser. This
// command is for a cold build, CI, or an editor's pre-launch task.
package main
import (
"log"
"os"
"gowasmweb/buildsteps"
)
func main() {
log.SetFlags(0)
steps := []struct {
name string
run func() ([]byte, error)
}{
{"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen},
{"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind},
{"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm},
{"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim},
}
for _, s := range steps {
log.Println("==>", s.name)
if out, err := s.run(); err != nil {
os.Stderr.Write(out)
log.Fatalln("build failed:", err)
}
}
log.Println("==> Done. Run the server with: go run ./server")
log.Println(" then open http://localhost:8085")
}

View File

@@ -0,0 +1,106 @@
// Package buildsteps is the example's build pipeline: directive codegen, Tailwind,
// the wasm binary, and Go's JS shim.
//
// It is Go, not a shell script, for three reasons. The dev server needs to call these
// steps on every save and cannot shell out to bash on Windows. Editors need to run
// them as tasks, and a task that only works on one platform is a task half the team
// cannot use. And the one-off build and the watch build must be the SAME steps — the
// moment they are two scripts, they drift, and the bug only shows up in whichever one
// you use less.
//
// Run the whole thing with `go run ./build`.
package buildsteps
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
// kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind
// and codegen commands are run FROM there so the engine's dependencies resolve in
// kjol's own go.mod, and this example's stays lean.
const kjolRoot = "../../.."
// Wwwroot is where every build artefact lands, and what the dev server serves.
const Wwwroot = "wwwroot"
// Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the
// layouts, and the client stubs for server components. It runs FIRST: everything after
// it compiles the code it writes.
func Codegen() ([]byte, error) {
return run("go", "run", "kjol/cmd/wasmgen", "./app")
}
// Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit and this
// example's Go markup for utility candidates.
//
// It scans .go files, which is the whole point of kjol's native engine: the markup is
// written in Go, so that is where the class names are. There is no JS build here at
// all.
func Tailwind() ([]byte, error) {
cmd := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/examples/go-wasm-web/css/app.css",
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"cmd/examples/go-wasm-web/app/**/*.go",
"cmd/examples/go-wasm-web/server/**/*.go",
)
cmd.Dir = kjolRoot
return cmd.CombinedOutput()
}
// Wasm compiles ./wasm to wwwroot/app.wasm.
func Wasm() ([]byte, error) {
cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm")
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
return cmd.CombinedOutput()
}
// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to
// start a Go wasm binary, it ships with the toolchain, and it must match the compiler
// that produced the binary — so it is copied from GOROOT rather than vendored.
func Shim() ([]byte, error) {
out, err := exec.Command("go", "env", "GOROOT").Output()
if err != nil {
return out, err
}
goroot := strings.TrimSpace(string(out))
for _, src := range []string{
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
} {
b, err := os.ReadFile(src)
if err != nil {
continue
}
dst := filepath.Join(Wwwroot, "wasm_exec.js")
// The GOROOT copy is read-only, and so is the copy we made last time. Remove it
// first, or the write fails with a permission error that says nothing useful.
os.Remove(dst)
return nil, os.WriteFile(dst, b, 0o644)
}
return nil, os.ErrNotExist
}
// All is the full build, in order. It is what the dev server runs on a code change and
// what `go run ./build` runs once.
//
// Returned output is the failing command's combined stdout+stderr, which the dev server
// puts straight into the browser's error overlay — so a compile error lands in front of
// you rather than in a terminal you were not looking at.
func All() ([]byte, error) {
for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, Shim} {
if out, err := step(); err != nil {
return out, err
}
}
return nil, nil
}
func run(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}

View File

@@ -13,15 +13,13 @@ import (
"flag"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"kjol/httputil"
"kjol/vdom"
"kjol/wasmdevserver"
"gowasmweb/app"
"gowasmweb/buildsteps"
)
func main() {
@@ -34,7 +32,8 @@ func main() {
Dir: "./wwwroot",
Watch: *watch,
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
Build: buildWasm,
Build: buildsteps.All,
BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
Render: render,
Document: document,
Handle: apiRoutes,
@@ -89,29 +88,3 @@ func document(inner string) string {
</body>
</html>`
}
// buildWasm runs the directive codegen (kjol/cmd/wasmgen), compiles the Tailwind
// CSS (kjol/cmd/twcss, scanning the Go markup + webui kit), then compiles ./wasm
// to wwwroot/app.wasm. Returned combined output is shown in the browser overlay
// on failure.
func buildWasm() ([]byte, error) {
if out, err := exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput(); err != nil {
return out, err
}
// Compile Tailwind from the kjol module root (so the engine's deps resolve),
// scanning the webui kit + this example's Go markup for utility candidates.
tw := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/examples/go-wasm-web/css/app.css",
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"cmd/examples/go-wasm-web/app/**/*.go",
"cmd/examples/go-wasm-web/server/**/*.go")
tw.Dir = "../../.." // kjol/go
if out, err := tw.CombinedOutput(); err != nil {
return out, err
}
cmd := exec.Command("go", "build", "-o", filepath.Join("wwwroot", "app.wasm"), "./wasm")
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
return cmd.CombinedOutput()
}

View File

@@ -2,8 +2,8 @@
// The wasm client entry point (main.go) builds only under GOOS=js GOARCH=wasm.
// This native placeholder keeps the package buildable on the host so a plain
// `go build ./...` succeeds; the real client is built by build.sh / the dev
// server with GOOS=js GOARCH=wasm.
// `go build ./...` succeeds; the real client is built by ./build (or the dev
// server) with GOOS=js GOARCH=wasm.
package main
func main() {}

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,7 @@ import (
"fmt"
"os"
"kjol/bundler"
"kjol/tw"
)
func main() {
@@ -32,7 +32,7 @@ func main() {
fmt.Fprintln(os.Stderr, "twcss:", err)
os.Exit(1)
}
css, err := bundler.CompileTailwind(string(src), *base, flag.Args())
css, err := tw.CompileFiles(string(src), *base, flag.Args())
if err != nil {
fmt.Fprintln(os.Stderr, "twcss:", err)
os.Exit(1)

View File

@@ -1,4 +1,4 @@
package bundler
package tw
// Native Go Tailwind v4 compiler. A from-scratch, pure-Go implementation of the
// Tailwind v4 engine — CSS parser (AST), candidate scanner, utility/variant

View File

@@ -1,4 +1,4 @@
package bundler
package tw
import (
"strings"

59
go/tw/tw.go Normal file
View File

@@ -0,0 +1,59 @@
// Package tw is kjol's native Go Tailwind v4 compiler.
//
// It lives on its own, not inside the bundler, because Tailwind is not a
// JavaScript concern. The bundler builds web/ (TSX, Solid, esbuild); the Tailwind
// engine only ever reads text and writes CSS, and the text it reads is just as
// likely to be Go — the gowasm kit authors its markup in Go and has no JS build at
// all. Burying the compiler in the bundler made every Go-only consumer drag a
// JavaScript bundler along for a CSS file.
//
// The scanner is language-agnostic: it pulls candidate class tokens out of any text
// file, so `class="flex gap-2"` in a .tsx and `vdom.Attr("class", "flex gap-2")` in
// a .go are found the same way.
//
// The engine itself is in tailwind.go — a from-scratch port of Tailwind v4's
// compiler. This file is the surface the rest of kjol calls.
package tw
import (
"github.com/tdewolff/minify/v2"
mincss "github.com/tdewolff/minify/v2/css"
)
var min *minify.M
func init() {
min = minify.New()
min.AddFunc("text/css", mincss.Minify)
}
// Scan extracts candidate utility class names from the files matched by patterns
// (globs, relative to baseDir). It reads text, not syntax: a candidate is any token
// that could plausibly be a class, and the compiler decides which ones actually are.
func Scan(baseDir string, patterns []string) []string {
return scanSources(baseDir, patterns)
}
// Compile compiles a Tailwind entry stylesheet against a set of candidates, and
// returns the CSS along with how many utilities it emitted (useful for a build log —
// a sudden drop usually means the scanner stopped seeing a source tree).
//
// entryCSS is the stylesheet source: typically `@import "tailwindcss";` plus an
// `@theme { … }` block. Anything else in it — @font-face, plain rules — passes
// through untouched.
func Compile(entryCSS, baseDir string, candidates []string) (css string, utilities int, err error) {
return twCompile(entryCSS, baseDir, candidates)
}
// CompileFiles is Scan + Compile + minify: the whole job, for a caller that just
// wants CSS out of a stylesheet and some source globs.
func CompileFiles(entryCSS, baseDir string, sourceGlobs []string) (string, error) {
compiled, _, err := twCompile(entryCSS, baseDir, scanSources(baseDir, sourceGlobs))
if err != nil {
return "", err
}
return Minify(compiled)
}
// Minify shrinks compiled CSS.
func Minify(css string) (string, error) { return min.String("text/css", css) }

View File

@@ -1,3 +1,5 @@
//go:build !(js && wasm)
// Package wasmdevserver is a reusable development server for gowasm apps: it serves
// the built web assets, renders routes server-side (SSR) at request time, hosts
// the /rsc server-component endpoint, and hot-swaps the freshly built wasm into
@@ -37,7 +39,14 @@ type Config struct {
Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot")
Watch bool // rebuild on change + hot reload
WatchDirs []string // source dirs to watch when Watch is set
Build func() ([]byte, error) // (re)build the wasm bundle; combined output on failure
Build func() ([]byte, error) // (re)build everything; combined output on failure
// BuildCSS recompiles ONLY the stylesheet. When a save touched stylesheets and no
// Go code, this runs instead of Build and the browser swaps the stylesheet in
// place — no wasm rebuild, no page reload, no lost state. Without it a colour
// tweak waits on the whole Go compiler, which is the difference between a design
// loop and a coffee break.
BuildCSS func() ([]byte, error)
Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered)
Document func(inner string) string // wrap #app inner HTML in a full HTML document
Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints)
@@ -116,15 +125,45 @@ func rootHandler(cfg Config) http.HandlerFunc {
// ---- build + watch ------------------------------------------------------
// watchLoop rebuilds on change, and treats a stylesheet edit differently from a code
// edit — because they cost wildly different amounts.
//
// A Go change means recompiling a multi-megabyte wasm binary. A CSS change means
// re-running Tailwind, which takes a moment. Putting both through the same path
// would make every tweak to a colour wait on the compiler, so a save that touched
// ONLY stylesheets runs BuildCSS and pushes a stylesheet swap: no wasm rebuild, no
// page reload, no lost state — the new CSS just appears.
func watchLoop(cfg Config, h *hub) {
prev := fingerprint(cfg.WatchDirs)
prevGo := fingerprintExt(cfg.WatchDirs, ".go")
prevCSS := fingerprintExt(cfg.WatchDirs, ".css")
for {
time.Sleep(300 * time.Millisecond)
fp := fingerprint(cfg.WatchDirs)
if fp == prev {
fpGo := fingerprintExt(cfg.WatchDirs, ".go")
fpCSS := fingerprintExt(cfg.WatchDirs, ".css")
codeChanged := fpGo != prevGo
cssChanged := fpCSS != prevCSS
if !codeChanged && !cssChanged {
continue
}
prev = fp
prevGo, prevCSS = fpGo, fpCSS
// Styles only: the cheap path.
if !codeChanged && cfg.BuildCSS != nil {
log.Println("stylesheet changed, recompiling CSS…")
h.broadcast(`{"type":"building"}`)
if out, err := cfg.BuildCSS(); err != nil {
log.Printf("CSS build failed: %v\n%s", err, out)
h.setError(string(out))
continue
}
log.Println("CSS ok — swapping stylesheets")
h.clearError()
h.broadcast(`{"type":"css"}`)
continue
}
log.Println("change detected, rebuilding…")
h.broadcast(`{"type":"building"}`)
if cfg.Build == nil {
@@ -141,12 +180,20 @@ func watchLoop(cfg Config, h *hub) {
}
}
// fingerprint changes whenever any .go file under dirs is modified.
func fingerprint(dirs []string) int64 {
// fingerprintExt changes whenever a file of the given extension under dirs is
// modified.
//
// .css is watched at all for a reason: the build compiles Tailwind, and the entry
// stylesheet (its @theme block, its @font-face rules, any hand-written CSS) is an
// INPUT to that build. Watching only .go meant saving app.css did nothing — no
// rebuild, no reload — and the change simply never reached the browser. Editing some
// unrelated Go file would then sweep it up by accident, which is a maddening way to
// find out.
func fingerprintExt(dirs []string, ext string) int64 {
var fp int64
for _, d := range dirs {
filepath.WalkDir(d, func(path string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() || !strings.HasSuffix(path, ".go") {
if err != nil || e.IsDir() || !strings.EqualFold(filepath.Ext(path), ext) {
return nil
}
if info, err := e.Info(); err == nil {
@@ -206,6 +253,28 @@ const clientJS = `// Injected by the dev server in watch mode.
start(); // renders synchronously — no await between dispose and first paint
}
// Swap every stylesheet for a freshly-fetched copy — no page reload, so scroll
// position, form state and the running wasm instance all survive a style tweak.
//
// The NEW link is loaded and only then does the old one go, on its onload. Removing
// it first would leave the page unstyled for however long the fetch takes, which
// reads as a flash of naked HTML every time you save.
function swapCSS() {
hideOverlay();
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) {
(function (old) {
var url = old.href.split("?")[0] + "?v=" + Date.now();
var next = old.cloneNode();
next.href = url;
next.onload = function () { if (old.parentNode) old.parentNode.removeChild(old); };
next.onerror = function () { if (next.parentNode) next.parentNode.removeChild(next); };
old.parentNode.insertBefore(next, old.nextSibling);
})(links[i]);
}
console.log("[hot reload] stylesheet updated");
}
// Full-screen overlay showing the Go compiler output when a build fails. The
// app underneath keeps running (and its state), so fixing the code and saving
// clears the overlay and hot-swaps without losing anything.
@@ -246,6 +315,7 @@ const clientJS = `// Injected by the dev server in watch mode.
var msg = {};
try { msg = JSON.parse(e.data); } catch (_) { return; }
if (msg.type === "reload") { hotSwap(); } // build ok: swap in place
else if (msg.type === "css") { swapCSS(); } // styles only: swap the stylesheet
else if (msg.type === "error") { showOverlay(msg.msg); } // build failed: show compiler output
else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); }
};

View File

@@ -0,0 +1,11 @@
//go:build js && wasm
// This file exists only so the package is not EMPTY under js/wasm.
//
// wasmdevserver is a server: it compiles Go, serves HTTP, and walks the filesystem —
// none of which a browser can do. The real file is excluded from the wasm build (it
// depends on rsc.Handler, which is itself server-only). But a package whose files are
// all excluded is a build error, not an empty package, and `GOOS=js go build ./...`
// would fail on it — which is exactly the command you run to check the half of kjol
// that DOES target wasm. So the package stays present here, and empty.
package wasmdevserver

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"fmt"

View File

@@ -18,7 +18,7 @@
// genssr.go public-page SSR bake + Go registry generation
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
// watch.go poll-and-rebuild watch loop
package bundler
package webbundler
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja).
//
@@ -397,3 +397,11 @@ func isAttrNameChar(b byte) bool {
func isSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
// isASCIILetter used to live in tailwind.go, which the Solid compiler happened to
// share a package with. It is a lexer helper, not a Tailwind one, so it moved here
// with the code that actually uses it when the Tailwind engine was factored out into
// kjol/tw.
func isASCIILetter(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
//

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"os"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import "testing"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import "path/filepath"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"os"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
@@ -13,6 +13,8 @@ import (
"github.com/tdewolff/minify/v2"
mincss "github.com/tdewolff/minify/v2/css"
"kjol/tw"
)
// Tailwind source patterns - configured here rather than in CSS so each bundle
@@ -72,8 +74,8 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
// Candidate classes come from the app sources (patterns relative to the css
// dir) plus the shared kit tree scanned directly, so kit component classes are
// present even though the kit lives outside the app frontend.
candidates := scanSources(cssDir, twSources)
kitCands := scanSources(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
candidates := tw.Scan(cssDir, twSources)
kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
candidates = dedupStrings(append(candidates, kitCands...))
// Prepend the shared @theme scaffold (kjol web/styles/theme.css) ahead of the
@@ -86,7 +88,7 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
}
}
compiled, count, err := twCompile(input, cssDir, candidates)
compiled, count, err := tw.Compile(input, cssDir, candidates)
if err != nil {
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
}

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// defaultExportShimPlugin bridges two ESM-strictness mismatches that
// existed in the previous custom bundler:

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// Public-route code generation. The single source of truth is the TypeScript
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// Public-page SSR code generation. For each page in the manifest
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
import (
"context"

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
import "net/http"

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
import (
"strings"

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
import (
"encoding/json"

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
import (
"errors"

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
// The development HMR server: it serves the SPA source tree as unbundled native
// ES modules (transformed on the fly), tracks the module import graph, watches
@@ -8,7 +8,7 @@ package bundler
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
// a non-boundary module bubbles up to a full page reload.
//
// This is the reason internal/bundler grew a `dev` build tag: the whole HMR
// This is the reason webbundler grew a `dev` build tag: the whole HMR
// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go,
// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the
// production server and the plain `cmd/bundle` build carry none of it.

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
// (no external dependency). A source-module change is turned into an HMR update

View File

@@ -1,12 +1,12 @@
//go:build dev
package bundler
package webbundler
// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
// push of HMR messages. We hand-roll it (rather than add a dependency) because
// the surface we need is tiny: the handshake, unmasked server text frames, and a
// read loop that answers pings and notices close. No per-message compression, no
// fragmentation, no client→server application data. All of internal/bundler's
// fragmentation, no client→server application data. All of webbundler's
// HMR support is behind `//go:build dev`, so prod builds compile none of it.
import (

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
import (
"bufio"

View File

@@ -1,6 +1,6 @@
//go:build dev
package bundler
package webbundler
import (
"bufio"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
// which lets misnamed specifiers slip through. This check catches them up

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"encoding/json"
@@ -17,7 +17,7 @@ import (
// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time
// deployment environment (appenv.Environment) into the JS, where
// frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT
// define it (see internal/bundler/ssr.go), so the env badge renders only after
// define it (see ssr.go), so the env badge renders only after
// the client takeover.
func esbuildDefine() map[string]string {
return map[string]string{

View File

@@ -1,4 +1,4 @@
// Solid JSX/TSX compilation (part of package bundler): compiles Solid JSX/TSX to
// Solid JSX/TSX compilation (part of package webbundler): compiles Solid JSX/TSX to
// optimized Solid dom-expressions output (template cloning + fine-grained
// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler
// lives in compile_solid.go (parser) and compile_solid_gen.go (codegen);
@@ -10,7 +10,7 @@
// per-declaration incremental caching is gone. A small in-memory cache dedups
// identical transforms within a process (e.g. a module served to several page
// loads under HMR); there is no on-disk cache and no compiler bootstrap.
package bundler
package webbundler
import (
"crypto/sha256"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"strings"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"strings"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// Top-level source segmentation. segmentTopLevel splits a module into contiguous
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"os"
@@ -8,7 +8,7 @@ import (
)
// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the
// package dir, which is internal/bundler).
// package dir, which is webbundler).
func walkRepoTSX(t *testing.T) []string {
t.Helper()
root := filepath.Join("..", "..", "frontend", "src")

View File

@@ -1,4 +1,4 @@
// SSR (part of package bundler): server-renders the public-page Solid components
// SSR (part of package webbundler): server-renders the public-page Solid components
// to HTML strings by running the (unmodified) client Solid runtime inside a goja
// JS engine against a minimal Go-backed DOM (dom.js). Components are authored in
// JSX/TSX and compiled to optimized Solid output at build time by the Go-native
@@ -11,7 +11,7 @@
// is imported by the server; the build-time entries are used by the bundler.
//
// -mta
package bundler
package webbundler
import (
"crypto/sha256"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"path/filepath"

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
// Build-time caching + parallelism for public-page SSR (see genssr.go).
//

View File

@@ -1,4 +1,4 @@
package bundler
package webbundler
import (
"encoding/json"

View File

@@ -1686,8 +1686,8 @@ type AutoTableState struct {
// thRefs memoizes header-cell refs, so a resize can measure them.
thRefs map[string]*vdom.Ref
// openSignals memoizes per-dropdown open state (see openSignal).
openSignals map[string]*vdom.Signal[bool]
// multiSelects memoizes the dropdown controllers (see multiSelect).
multiSelects map[string]*MultiSelect
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
// what the current filter actually selected.
@@ -2116,34 +2116,33 @@ func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, value
for _, v := range values {
options = append(options, FormSelectOption{Value: v, Label: v})
}
open := s.openSignal(identifier)
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
FormMultiSelect(FormMultiSelectProps{
s.multiSelect(identifier).Render(FormMultiSelectProps{
Options: options,
Value: s.SearchValues(identifier),
Placeholder: pick(placeholder, "Any"),
Searchable: true,
ShowSelectAll: true,
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
}),
)
}
// openSignal memoizes the open state of a dropdown, keyed by identifier. It has to
// be memoized: a signal created fresh inside a render would reset to closed on
// every render, so the dropdown could never stay open.
func (s *AutoTableState) openSignal(key string) *vdom.Signal[bool] {
if s.openSignals == nil {
s.openSignals = map[string]*vdom.Signal[bool]{}
// multiSelect memoizes a dropdown controller, keyed by identifier.
//
// It MUST be memoized. A controller built inside a render would get fresh refs and a
// fresh closed state every frame — the dropdown could never stay open, and the
// outside-click listener would be watching an element that no longer exists.
func (s *AutoTableState) multiSelect(key string) *MultiSelect {
if s.multiSelects == nil {
s.multiSelects = map[string]*MultiSelect{}
}
sig, ok := s.openSignals[key]
ms, ok := s.multiSelects[key]
if !ok {
sig = vdom.NewSignal(false)
s.openSignals[key] = sig
ms = NewMultiSelect(DropdownOptions{})
s.multiSelects[key] = ms
}
return sig
return ms
}
// DateSearch renders a date field bound to an identifier. A date RANGE is two of
@@ -2719,17 +2718,13 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
if len(options) == 0 {
return nil
}
open := s.openSignal("__columns__")
return FormMultiSelect(FormMultiSelectProps{
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
Options: options,
Value: selected,
Placeholder: "Columns",
Searchable: true,
ShowSelectAll: true,
FieldWidth: "w-52",
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
OnChange: func(visible []string) {
show := map[string]bool{}
for _, k := range visible {
@@ -3266,7 +3261,11 @@ type calcEditor struct {
position *vdom.Signal[int]
errorMsg *vdom.Signal[string]
opOpen *vdom.Signal[bool]
opSelect *MultiSelect // the Basic tab's operand picker
// The function menu's own search box.
fnSearch *vdom.Signal[string]
fnSearchRef *vdom.Ref
colMenu *Menu
fnMenu *Menu
constMenu *Menu
@@ -3293,16 +3292,29 @@ func (s *AutoTableState) calcEditorState() *calcEditor {
precision: vdom.NewSignal(""),
position: vdom.NewSignal(int(COL_POS_RIGHT)),
errorMsg: vdom.NewSignal(""),
opOpen: vdom.NewSignal(false),
opSelect: NewMultiSelect(DropdownOptions{}),
fnSearch: vdom.NewSignal(""),
fnSearchRef: vdom.NewRef(),
formulaRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
// Standalone: these menus live INSIDE the editor's popover, and without it the
// single-open manager would read them as a rival panel and close their own
// parent as they opened.
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
fnMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
constMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
}
// Built after e exists, because it closes over it: closing the menu abandons the
// query. Reopening to a stale filter — four of thirty-five functions showing, for
// no visible reason — is worse than retyping.
e.fnMenu = NewMenu(MenuOptions{
Placement: PlacementBottomStart,
Standalone: true,
OnOpenChange: func(open bool) {
if !open {
e.fnSearch.Set("")
}
},
})
e.pop = NewPopover(PopoverProps{
Placement: PlacementBottomEnd,
// Closing the popover abandons the draft — reopening should start clean rather
@@ -3382,12 +3394,124 @@ var calcDataTypes = []struct {
{CALC_TYPE_PLAIN, "Plain"},
}
// FormulaFunctionNames are the functions a formula may call, for the insert menu.
var FormulaFunctionNames = []string{
"SUM", "AVERAGE", "MEDIAN", "MODE", "MIN", "MAX", "COUNT",
"ABS", "ROUND", "FLOOR", "CEILING", "SQRT", "POWER", "MOD", "EXP", "LN", "LOG",
"SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "ATAN2", "SINH", "COSH", "TANH",
"RADIANS", "DEGREES", "IF", "AND", "OR", "NOT", "ROW",
// FormulaFunction is one entry in the insert menu: what to type, what it takes, and
// what it does. The signature and the description are not decoration — they are what
// makes a list of thirty-five names usable by someone who does not already know them,
// and they are what the menu's search matches against.
type FormulaFunction struct {
Name string
Sig string
Desc string
}
// FormulaFunctionGroup is a category of functions.
type FormulaFunctionGroup struct {
Label string
Fns []FormulaFunction
}
// FormulaFunctionGroups are the functions a formula may call, grouped the way someone
// looking for one would think about them — by what they are FOR, not alphabetically.
// Someone who wants a total looks under Aggregate; nobody scans an A-to-Z list from
// ABS to TANH hoping to recognise something.
var FormulaFunctionGroups = []FormulaFunctionGroup{
{Label: "Aggregate", Fns: []FormulaFunction{
{"SUM", "SUM(range)", "Total of the values"},
{"AVERAGE", "AVERAGE(range)", "Mean of the values"},
{"MEDIAN", "MEDIAN(range)", "Middle value"},
{"MODE", "MODE(range)", "Most frequent value"},
{"MIN", "MIN(range)", "Smallest value"},
{"MAX", "MAX(range)", "Largest value"},
{"COUNT", "COUNT(range)", "How many numbers"},
}},
{Label: "Math", Fns: []FormulaFunction{
{"ABS", "ABS(n)", "Absolute value"},
{"ROUND", "ROUND(n, digits)", "Round to digits"},
{"FLOOR", "FLOOR(n)", "Round down"},
{"CEILING", "CEILING(n)", "Round up"},
{"SQRT", "SQRT(n)", "Square root"},
{"POWER", "POWER(n, p)", "n to the power p"},
{"MOD", "MOD(n, d)", "Remainder of n ÷ d"},
{"EXP", "EXP(n)", "e to the power n"},
{"LN", "LN(n)", "Natural log (base e)"},
{"LOG", "LOG(n, base)", "Log, base 10 by default"},
}},
{Label: "Trigonometry", Fns: []FormulaFunction{
{"SIN", "SIN(angle)", "Sine (radians)"},
{"COS", "COS(angle)", "Cosine (radians)"},
{"TAN", "TAN(angle)", "Tangent (radians)"},
{"ASIN", "ASIN(n)", "Inverse sine"},
{"ACOS", "ACOS(n)", "Inverse cosine"},
{"ATAN", "ATAN(n)", "Inverse tangent"},
{"ATAN2", "ATAN2(x, y)", "Angle of point (x, y)"},
{"SINH", "SINH(n)", "Hyperbolic sine"},
{"COSH", "COSH(n)", "Hyperbolic cosine"},
{"TANH", "TANH(n)", "Hyperbolic tangent"},
{"PI", "PI()", "π constant"},
{"RADIANS", "RADIANS(deg)", "Degrees → radians"},
{"DEGREES", "DEGREES(rad)", "Radians → degrees"},
}},
{Label: "Logic", Fns: []FormulaFunction{
{"IF", "IF(test, then, else)", "Choose by condition"},
{"AND", "AND(a, b, …)", "True if all are true"},
{"OR", "OR(a, b, …)", "True if any are true"},
{"NOT", "NOT(a)", "Negate"},
}},
{Label: "Row", Fns: []FormulaFunction{
{"ROW", "ROW()", "Current row number"},
}},
}
// FormulaConstant is a named constant, with what it is worth.
type FormulaConstant struct {
Name string
Desc string
}
// FormulaConstantOptions are the constants a formula may write bare, for the insert
// menu. The approximate value is shown because "PHI" tells you nothing and "≈ 1.618"
// tells you everything.
var FormulaConstantOptions = []FormulaConstant{
{"PI", "π ≈ 3.14159"},
{"E", "Euler's number ≈ 2.71828"},
{"TAU", "2π ≈ 6.28319"},
{"PHI", "Golden ratio ≈ 1.61803"},
{"SQRT2", "√2 ≈ 1.41421"},
}
// FormulaFunctionNames flattens the groups — for anything that just needs the names.
func FormulaFunctionNames() []string {
var out []string
for _, g := range FormulaFunctionGroups {
for _, f := range g.Fns {
out = append(out, f.Name)
}
}
return out
}
// filterFormulaGroups narrows the menu by a query, matching name, signature AND
// description — so "total" finds SUM, which is the whole point of carrying the prose
// around. Empty groups drop out rather than leaving a bare heading behind.
func filterFormulaGroups(query string) []FormulaFunctionGroup {
q := strings.TrimSpace(strings.ToLower(query))
if q == "" {
return FormulaFunctionGroups
}
var out []FormulaFunctionGroup
for _, g := range FormulaFunctionGroups {
var fns []FormulaFunction
for _, f := range g.Fns {
hay := strings.ToLower(f.Name + " " + f.Sig + " " + f.Desc)
if strings.Contains(hay, q) {
fns = append(fns, f)
}
}
if len(fns) > 0 {
out = append(out, FormulaFunctionGroup{Label: g.Label, Fns: fns})
}
}
return out
}
// operandOption is one entry in the operand pickers and the column insert menu: the
@@ -3743,14 +3867,12 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label})
}
children = append(children, calcField("Columns (combined per row)",
FormMultiSelect(FormMultiSelectProps{
e.opSelect.Render(FormMultiSelectProps{
Options: msOpts,
Value: e.operands.Get(),
Placeholder: "Select columns…",
Searchable: true,
Small: true,
Open: e.opOpen.Get(),
OnToggle: func() { e.opOpen.Set(!e.opOpen.Get()) },
OnChange: func(v []string) { e.operands.Set(v) },
})))
}
@@ -3796,25 +3918,45 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
)
}
fnItems := make([]*vdom.VNode, 0, len(FormulaFunctionNames))
for _, name := range FormulaFunctionNames {
fname := name
// The function menu, grouped by category and searchable. Thirty-five names in one
// flat list is a wall; grouped by what they are FOR, with a signature and a
// sentence, it is something you can actually find SUM in.
fnItems := []*vdom.VNode{e.fnSearchBox()}
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.Text("No functions match"),
))
}
for _, g := range groups {
fnItems = append(fnItems, MenuSection("", vdom.Text(g.Label)))
for _, fn := range g.Fns {
f := fn
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(fname + "()") },
}, vdom.Span(vdom.Attr("class", "font-mono text-emerald-700"), vdom.Text(fname))))
OnClick: func() { e.insertFunction(f) },
},
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5 min-w-0"),
// 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)),
),
))
}
}
constNames := make([]string, 0, len(FormulaConstants))
for name := range FormulaConstants {
constNames = append(constNames, name)
}
sort.Strings(constNames)
constItems := make([]*vdom.VNode, 0, len(constNames))
for _, name := range constNames {
cname := name
constItems := make([]*vdom.VNode, 0, len(FormulaConstantOptions))
for _, c := range FormulaConstantOptions {
cc := c
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(cname) },
}, vdom.Span(vdom.Attr("class", "font-mono text-amber-600"), vdom.Text(cname))))
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)),
),
))
}
menus := vdom.Div(vdom.Attr("class", "flex flex-wrap items-center gap-1"),
@@ -3876,6 +4018,44 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
return calcField("Formula", children...)
}
// fnSearchBox filters the function menu. It is sticky, so it stays reachable as the
// list scrolls, and it is focused when the menu opens — you can type "total", see SUM,
// and never touch the mouse.
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"),
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("placeholder", "Search functions…"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", e.fnSearch.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) { e.fnSearch.Set(ev.Value()) }),
),
)
}
// insertFunction drops a call in and puts the caret INSIDE the parentheses, ready for
// the first argument — SUM() with the cursor after the "(" rather than after the ")",
// which would make you move it back every single time.
//
// A no-argument function (ROW(), PI()) has nothing to type, so the caret goes after it.
func (e *calcEditor) insertFunction(f FormulaFunction) {
noArgs := strings.HasSuffix(f.Sig, "()")
e.insertAtCaretOffset(f.Name+"()", boolToInt(!noArgs))
e.fnSearch.Set("")
e.fnMenu.Close()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// syncOverlayScroll keeps the colours under the caret. Imperative on purpose: this
// fires on every scroll frame, and a signal write would re-render the whole table.
func (e *calcEditor) syncOverlayScroll() {
@@ -3916,7 +4096,11 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
// insertAtCaret drops text where the cursor is, rather than at the end — which is
// the whole point of an insert menu. It needs the caret position and focus back
// afterwards, both of which come from the host API.
func (e *calcEditor) insertAtCaret(text string) {
func (e *calcEditor) insertAtCaret(text string) { e.insertAtCaretOffset(text, 0) }
// insertAtCaretOffset inserts text and leaves the caret `back` characters from its
// end — so a function can land with the cursor between its parentheses.
func (e *calcEditor) insertAtCaretOffset(text string, back int) {
src := e.formula.Get()
start, end := wasmruntime.SelectionRange(e.formulaRef)
if start < 0 || start > len(src) || end < start || end > len(src) {
@@ -3927,7 +4111,7 @@ func (e *calcEditor) insertAtCaret(text string) {
// The textarea's value is written by the render this signal just scheduled, so the
// caret can only be placed once that render has landed.
caret := start + len(text)
caret := start + len(text) - back
wasmruntime.AfterRender(func() {
wasmruntime.Focus(e.formulaRef)
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)

View File

@@ -490,3 +490,115 @@ func TestEditingJumpsToTheRightForm(t *testing.T) {
t.Error("reset did not return the editor to a clean chooser")
}
}
// ---- the function insert menu ----
// Thirty-five function names in one flat list is a wall. Grouped by what they are FOR,
// with a signature and a sentence, it is something you can find SUM in.
func TestFormulaFunctionGroups(t *testing.T) {
want := []string{"Aggregate", "Math", "Trigonometry", "Logic", "Row"}
if len(FormulaFunctionGroups) != len(want) {
t.Fatalf("got %d groups, want %d", len(FormulaFunctionGroups), len(want))
}
for i, label := range want {
if FormulaFunctionGroups[i].Label != label {
t.Errorf("group %d is %q, want %q", i, FormulaFunctionGroups[i].Label, label)
}
}
// Every function carries a signature and a description — they are what the menu
// shows and what its search matches, not decoration.
for _, g := range FormulaFunctionGroups {
for _, f := range g.Fns {
if f.Name == "" || f.Sig == "" || f.Desc == "" {
t.Errorf("%s/%s is missing its signature or description: %+v", g.Label, f.Name, f)
}
if !strings.HasPrefix(f.Sig, f.Name+"(") {
t.Errorf("%s's signature %q does not start with its own name", f.Name, f.Sig)
}
}
}
}
// Every function the menu offers must actually EXIST in the engine — an insert menu
// that offers a function the evaluator rejects is worse than no menu.
func TestEveryOfferedFunctionEvaluates(t *testing.T) {
// A formula per function, using its own arity.
args := map[string]string{
"IF": "IF(1, 2, 3)", "ATAN2": "ATAN2(1, 1)", "POWER": "POWER(2, 3)",
"MOD": "MOD(5, 2)", "LOG": "LOG(100, 10)", "ROUND": "ROUND(1.5, 0)",
"ROW": "ROW()", "PI": "PI()", "AND": "AND(1, 1)", "OR": "OR(1, 0)", "NOT": "NOT(0)",
}
for _, g := range FormulaFunctionGroups {
for _, f := range g.Fns {
src, ok := args[f.Name]
if !ok {
src = f.Name + "(1)"
}
if _, err := CompileFormula(src); err != nil {
t.Errorf("the menu offers %s, but %q does not compile: %v", f.Name, src, err)
}
}
}
}
// And every constant it offers must be one the evaluator knows.
func TestEveryOfferedConstantExists(t *testing.T) {
if len(FormulaConstantOptions) != len(FormulaConstants) {
t.Errorf("the menu offers %d constants, the engine knows %d",
len(FormulaConstantOptions), len(FormulaConstants))
}
for _, c := range FormulaConstantOptions {
if _, ok := FormulaConstants[c.Name]; !ok {
t.Errorf("the menu offers %q, which the engine does not know", c.Name)
}
if c.Desc == "" {
t.Errorf("%q has no description — the name alone says nothing", c.Name)
}
}
}
// The menu's search matches the DESCRIPTION too, which is the whole point of carrying
// the prose around: someone who wants a total types "total", not "SUM".
func TestFormulaMenuSearch(t *testing.T) {
got := filterFormulaGroups("total")
found := false
for _, g := range got {
for _, f := range g.Fns {
if f.Name == "SUM" {
found = true
}
}
}
if !found {
t.Error(`searching "total" did not find SUM — the description is not being matched`)
}
// Empty groups drop out rather than leaving a bare heading behind.
for _, g := range filterFormulaGroups("sine") {
if len(g.Fns) == 0 {
t.Errorf("group %q survived the filter with no functions in it", g.Label)
}
}
// An empty query is everything.
if len(filterFormulaGroups("")) != len(FormulaFunctionGroups) {
t.Error("an empty query should show every group")
}
// A query matching nothing yields nothing (the menu shows its own empty state).
if got := filterFormulaGroups("zzzz"); len(got) != 0 {
t.Errorf("a query matching nothing returned %d groups", len(got))
}
}
func TestFormulaFunctionNamesFlattensTheGroups(t *testing.T) {
names := FormulaFunctionNames()
if len(names) < 30 {
t.Errorf("flattened to %d names, want the full set", len(names))
}
for _, want := range []string{"SUM", "ROUND", "ATAN2", "IF", "ROW"} {
if !contains2(names, want) {
t.Errorf("%s missing from the flattened names", want)
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/kit/Forms.tsx. Reactive accessors collapse to plain values, and
@@ -790,22 +791,234 @@ func FormTimezoneSelector(p FormSelectProps) *vdom.VNode {
return FormSelect(p, opts...)
}
// -- combobox / multi-select (approximated) ------------------------------------
// -- combobox / multi-select ---------------------------------------------------
//
// NOTE: FormCombobox, FormSearchableSelect and FormMultiSelect in the TSX open a
// solid-js Portal, measure the trigger with getBoundingClientRect + a
// requestAnimationFrame position tracker (floating-ui style), focus-trap the
// search box, filter options against the query, track a keyboard-highlighted
// index, and close on document mousedown. None of that has a neutral-runtime
// equivalent, so these ports:
// - take `Open bool` + `OnToggle func()` instead of an internal open signal,
// - render the dropdown inline, positioned with `absolute top-full` utilities
// rather than fixed computed coordinates (no Portal),
// - render ALL options (the search box is decorative — client-side filtering,
// keyboard nav, and highlight tracking are dropped),
// - keep selection fully functional through Value + OnChange.
// Both are built on the Floating controller, which is where their close behaviour
// comes from: an outside mousedown, and Escape closing the topmost panel only. The
// previous port took `Open bool` + `OnToggle func()` from the caller and had NEITHER
// — a dropdown, once open, stayed open until you clicked its trigger again. It also
// rendered its panel in-flow, so it was clipped by any scrolling ancestor, and its
// search box was decorative: it filtered nothing.
//
// # Where a multi-select differs from a combobox, deliberately
//
// A combobox picks ONE thing, so choosing closes it. A multi-select picks SEVERAL, so
// choosing does NOT — you are mid-selection, and closing the panel under you after
// each tick would make it unusable. That is why both are controllers over the same
// Floating but only one of them hides on select.
//
// Everything else they share: the panel is portaled to document.body (so it escapes
// overflow), positioned by measurement, flipped when it will not fit, and closed by an
// outside click or Escape.
// FormComboboxProps configures FormCombobox / FormSearchableSelect.
// dropdown is the shared half of Combobox and MultiSelect.
type dropdown struct {
f *Floating
search *vdom.Signal[string]
searchRef *vdom.Ref
// active is the keyboard-highlighted option, as an index into the FILTERED list.
// -1 means nothing is highlighted.
active *vdom.Signal[int]
}
// DropdownOptions configures NewCombobox / NewMultiSelect.
type DropdownOptions struct {
// Placement defaults to bottom-start. The panel flips above the field when there
// is not room below.
Placement string
OnOpenChange func(bool)
}
func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown {
d := &dropdown{
search: vdom.NewSignal(""),
searchRef: vdom.NewRef(),
active: vdom.NewSignal(-1),
}
d.f = NewFloating(FloatingOptions{
Placement: pick(o.Placement, PlacementBottomStart),
Offset: 4,
// A long option list near the bottom of the page scrolls inside its own box
// rather than running off the screen.
ConstrainToViewport: true,
// Standalone: these open INSIDE other floatings (a filter popover, the calc
// editor's form). Without it the single-open manager would read the dropdown as
// a rival panel and close the very popover it belongs to.
Standalone: true,
OnOpenChange: func(open bool) {
// Closing abandons the query. Reopening to a stale filter — showing three of
// twenty options for no visible reason — is worse than retyping.
if !open {
d.search.Set("")
d.active.Set(-1)
}
if o.OnOpenChange != nil {
o.OnOpenChange(open)
}
},
})
_ = closeOnSelect
return d
}
// IsOpen / Open / Close / Toggle drive the panel.
func (d *dropdown) IsOpen() bool { return d.f.IsOpen() }
func (d *dropdown) Open() { d.f.Show() }
func (d *dropdown) Close() { d.f.Hide() }
func (d *dropdown) Toggle() { d.f.Toggle() }
// Dispose removes the panel's listeners. Call it if the component owning this
// dropdown goes away while the panel might still be open.
func (d *dropdown) Dispose() { d.f.Dispose() }
// filter narrows the options by the search query — the box actually filters now.
// Matching is case-insensitive on the label, which is what the user can see.
func (d *dropdown) filter(opts []FormSelectOption) []FormSelectOption {
q := strings.TrimSpace(strings.ToLower(d.search.Get()))
if q == "" {
return opts
}
out := make([]FormSelectOption, 0, len(opts))
for _, o := range opts {
if strings.Contains(strings.ToLower(o.Label), q) {
out = append(out, o)
}
}
return out
}
// searchBox is the filter field at the top of the panel. It is focused when the panel
// opens, so a searchable dropdown can be driven entirely from the keyboard.
func (d *dropdown) searchBox(placeholder string, onDark bool, count int) *vdom.VNode {
wrapCls, inputCls := formDropdownSearchWrap, formDropdownSearchInput
if onDark {
wrapCls, inputCls = formDropdownSearchWrapDark, formDropdownSearchInputDark
}
// Focus it once the panel is in the DOM. Scheduled rather than called: the input
// does not exist yet at the moment the signal that opens the panel is written.
wasmruntime.AfterRender(func() { wasmruntime.Focus(d.searchRef) })
return vdom.Div(vdom.Attr("class", wrapCls),
vdom.Input(
vdom.WithRef(d.searchRef),
vdom.Attr("type", "text"),
vdom.Attr("class", inputCls),
vdom.Attr("placeholder", pick(placeholder, "Search...")),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", d.search.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) {
d.search.Set(e.Value())
d.active.Set(-1) // the old highlight indexed a different list
}),
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { d.onSearchKey(e, count) }),
),
)
}
// onSearchKey is arrow-key navigation over the filtered list. Escape is NOT handled
// here: Floating already closes the topmost panel on Escape, and handling it twice
// would close a dropdown and the popover around it with one press.
func (d *dropdown) onSearchKey(e vdom.Event, count int) {
switch e.Key() {
case vdom.KEY_ARROW_DOWN:
e.PreventDefault()
if count > 0 {
d.active.Set((d.active.Get() + 1) % count)
}
case vdom.KEY_ARROW_UP:
e.PreventDefault()
if count > 0 {
next := d.active.Get() - 1
if next < 0 {
next = count - 1
}
d.active.Set(next)
}
}
}
// optionButton is one row of the panel.
func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark, small bool, onClick func()) *vdom.VNode {
cls := formDropdownOption
if onDark {
cls = formDropdownOptionDark
}
if small {
cls = cx(cls, "py-1.5 px-2")
}
if idx == d.active.Get() {
cls = cx(cls, "bg-neutral-100")
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cls),
vdom.Attr("role", "option"),
vdom.Attr("aria-selected", strconv.FormatBool(selected)),
}
if opt.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
} else if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
}
return vdom.Button(append(mods, vdom.Text(opt.Label))...)
}
func (d *dropdown) noResults(onDark bool) *vdom.VNode {
cls := formDropdownNoResults
if onDark {
cls = formDropdownNoResultsDark
}
return vdom.Div(vdom.Attr("class", cls), vdom.Text("No options found"))
}
// panel wraps the dropdown's rows in the floating panel. Width is matched to the
// field so the options line up under it.
func (d *dropdown) panel(onDark bool, children ...*vdom.VNode) *vdom.VNode {
cls := formDropdown
if onDark {
cls = formDropdownDark
}
// The exact width is written by matchFieldWidth once the field has been measured;
// min-w-48 is only a floor, so a very narrow field still gets a readable list.
return d.f.Panel(FloatingPanelProps{
Role: "listbox",
Class: cx("min-w-48", cls),
}, children...)
}
// matchFieldWidth sizes the panel to the field it hangs off. Done imperatively, from
// a measurement, because the two are no longer DOM relatives: the panel is portaled to
// document.body, so it cannot simply be `w-full`.
func (d *dropdown) matchFieldWidth() {
wasmruntime.AfterRender(func() {
w := wasmruntime.Measure(d.f.triggerRef).Width
if w > 0 {
wasmruntime.SetStyle(d.f.panelRef, "width", px(w))
}
})
}
// ---- Combobox: pick one ----
// Combobox is a single-select dropdown. Choosing an option closes it.
//
// Create it once, alongside your signals — never inside a render function, which
// would rebuild its refs and open state every frame:
//
// team := webui.NewCombobox(webui.DropdownOptions{})
// …
// team.Render(webui.FormComboboxProps{Options: opts, Value: v.Get(), OnChange: v.Set})
type Combobox struct{ *dropdown }
// NewCombobox creates a single-select dropdown.
func NewCombobox(o DropdownOptions) *Combobox {
return &Combobox{dropdown: newDropdown(o, true)}
}
// FormComboboxProps configures Combobox.Render.
type FormComboboxProps struct {
Options []FormSelectOption
Value string
@@ -819,13 +1032,10 @@ type FormComboboxProps struct {
OnDark bool
MaxDisplayLength int
Disabled bool
// Open/OnToggle replace the internal open signal (see NOTE above).
Open bool
OnToggle func()
}
// FormCombobox is a single-select dropdown styled like a listbox trigger.
func FormCombobox(p FormComboboxProps) *vdom.VNode {
// Render draws the field and its panel.
func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
var selected *FormSelectOption
for i := range p.Options {
if p.Options[i].Value == p.Value {
@@ -840,13 +1050,7 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
placeholderCls = "text-text-on-dark-muted"
}
if selected != nil {
display = selected.Label
if p.MaxDisplayLength > 0 {
r := []rune(display)
if len(r) > p.MaxDisplayLength {
display = strings.TrimRight(string(r[:p.MaxDisplayLength]), " ") + "…"
}
}
display = truncateRunes(selected.Label, p.MaxDisplayLength)
placeholderCls = ""
}
@@ -855,89 +1059,72 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
chevronCls = "text-text-on-dark-muted"
}
chevron := "chevron-down"
if p.Open {
if c.IsOpen() {
chevron = "chevron-up"
}
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formTriggerCls(p.Small, p.OnDark)),
trigger := c.f.Trigger(FloatingTriggerProps{
Class: formTriggerCls(p.Small, p.OnDark),
AriaHasPopup: "listbox",
},
vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
IconInline(chevron, 16, chevronCls),
}
)
if p.Disabled {
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
}
if p.OnToggle != nil {
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
trigger.Attrs["disabled"] = "disabled"
}
rootMods := []vdom.Mod{
filtered := c.filter(p.Options)
rows := make([]*vdom.VNode, 0, len(filtered)+1)
if p.Searchable && c.IsOpen() {
rows = append(rows, c.searchBox(p.SearchPlaceholder, p.OnDark, len(filtered)))
}
if len(filtered) == 0 {
rows = append(rows, c.noResults(p.OnDark))
}
for i := range filtered {
opt := filtered[i]
rows = append(rows, c.optionButton(opt, i, opt.Value == p.Value, p.OnDark, p.Small, func() {
if p.OnChange != nil {
p.OnChange(opt.Value)
}
c.Close() // one choice, so we are done
}))
}
if c.IsOpen() {
c.matchFieldWidth()
}
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.Button(triggerMods...),
trigger,
c.panel(p.OnDark, rows...),
)
}
if p.Open {
dropdownCls := formDropdown
if p.OnDark {
dropdownCls = formDropdownDark
}
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", dropdownCls))}
// NewSearchableSelect is a Combobox with the search box on — the old
// FormSearchableSelect.
func NewSearchableSelect(o DropdownOptions) *Combobox { return NewCombobox(o) }
if p.Searchable {
searchWrapCls := formDropdownSearchWrap
searchInputCls := formDropdownSearchInput
if p.OnDark {
searchWrapCls = formDropdownSearchWrapDark
searchInputCls = formDropdownSearchInputDark
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", searchWrapCls),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", searchInputCls),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
))
// ---- MultiSelect: pick several ----
// MultiSelect is a multi-select dropdown with removable tags and an optional
// select-all row.
//
// Unlike a Combobox it does NOT close when an option is chosen: you are mid-selection,
// and closing the panel after each tick would make it useless. It closes on an outside
// click, on Escape, or when its trigger is clicked again.
//
// Create it once, alongside your signals — never inside a render function.
type MultiSelect struct{ *dropdown }
// NewMultiSelect creates a multi-select dropdown.
func NewMultiSelect(o DropdownOptions) *MultiSelect {
return &MultiSelect{dropdown: newDropdown(o, false)}
}
optionCls := formDropdownOption
if p.OnDark {
optionCls = formDropdownOptionDark
}
if p.Small {
optionCls = cx(optionCls, "py-1.5 px-2")
}
if len(p.Options) == 0 {
noResultsCls := formDropdownNoResults
if p.OnDark {
noResultsCls = formDropdownNoResultsDark
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", noResultsCls), vdom.Text("No options found")))
}
for _, opt := range p.Options {
ov := opt.Value
optMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", optionCls)}
if opt.Disabled {
optMods = append(optMods, vdom.Attr("disabled", "disabled"))
} else if p.OnChange != nil {
oc := p.OnChange
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(ov) }))
}
optMods = append(optMods, vdom.Text(opt.Label))
ddMods = append(ddMods, vdom.Button(optMods...))
}
rootMods = append(rootMods, vdom.Div(ddMods...))
}
return vdom.Div(rootMods...)
}
// FormSearchableSelect is a FormCombobox with the search box shown.
func FormSearchableSelect(p FormComboboxProps) *vdom.VNode {
p.Searchable = true
return FormCombobox(p)
}
// FormMultiSelectProps configures FormMultiSelect.
// FormMultiSelectProps configures MultiSelect.Render.
type FormMultiSelectProps struct {
Options []FormSelectOption
Value []string
@@ -951,14 +1138,10 @@ type FormMultiSelectProps struct {
Small bool
Class string
FieldWidth string
// Open/OnToggle replace the internal open signal (see NOTE above).
Open bool
OnToggle func()
}
// FormMultiSelect is a multi-select dropdown with removable tags and an optional
// select-all row. Selection is functional through Value + OnChange.
func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
// Render draws the field and its panel.
func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
maxTags := p.MaxTagsBeforeCollapse
if maxTags == 0 {
maxTags = 3
@@ -971,138 +1154,133 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
}
}
// Trigger content: placeholder, tag list, or "N items selected".
var triggerContent *vdom.VNode
// The trigger's face: a placeholder, tags, or "N selected" once there are too many
// to show without the field growing unboundedly.
var face *vdom.VNode
switch {
case len(selected) == 0:
triggerContent = vdom.Span(vdom.Attr("class", "text-neutral-500"), vdom.Text(pick(p.Placeholder, "Select options")))
face = vdom.Span(vdom.Attr("class", "text-neutral-500 truncate"),
vdom.Text(pick(p.Placeholder, "Select options")))
case len(selected) > maxTags:
n := len(selected)
suffix := "s"
if n == 1 {
suffix = ""
}
triggerContent = vdom.Span(vdom.Text(strconv.Itoa(n) + " item" + suffix + " selected"))
face = vdom.Span(vdom.Attr("class", "truncate"),
vdom.Text(strconv.Itoa(len(selected))+" selected"))
default:
tagSize := "py-0.5 px-2 text-sm"
if p.Small {
tagSize = "py-0.5 px-1.5 text-xs"
}
tagCls := cx("inline-flex items-center gap-0.5 bg-neutral-200 rounded-default whitespace-nowrap leading-none", tagSize)
tagsMods := []vdom.Mod{vdom.Attr("class", "flex flex-nowrap gap-1 overflow-hidden items-center")}
tags := []vdom.Mod{vdom.Attr("class", "flex flex-wrap items-center gap-1 min-w-0")}
for _, opt := range selected {
ov := opt.Value
// NOTE: the remove button nests inside the trigger button (as in the TSX);
// the neutral Event has no stopPropagation, so a remove click also toggles
// the dropdown. Harmless given selection is idempotent through OnChange.
rmMods := []vdom.Mod{
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("aria-label", "Remove "+opt.Label),
IconInline("xmark", 10, ""),
}
if p.OnChange != nil {
oc := p.OnChange
cur := p.Value
rmMods = append(rmMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formRemoveStr(cur, ov)) }))
oc, cur := p.OnChange, p.Value
remove = append(remove, vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
// The tag sits INSIDE the trigger, so without this the click would
// bubble up and toggle the panel open as it removed the tag.
e.StopPropagation()
oc(formRemoveStr(cur, ov))
}))
}
tag := vdom.Span(vdom.Attr("class", tagCls),
tags = append(tags, vdom.Span(vdom.Attr("class", formMultiSelectTag),
vdom.Text(opt.Label),
vdom.Button(rmMods...),
)
tagsMods = append(tagsMods, tag)
vdom.Button(remove...),
))
}
triggerContent = vdom.Div(tagsMods...)
face = vdom.Div(tags...)
}
pad := "p-2"
if p.Small {
pad = "p-1"
}
triggerCls := cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad)
chevron := "chevron-down"
if p.Open {
if m.IsOpen() {
chevron = "chevron-up"
}
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", triggerCls),
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), triggerContent),
trigger := m.f.Trigger(FloatingTriggerProps{
Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad),
AriaHasPopup: "listbox",
},
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), face),
IconInline(chevron, 16, "text-neutral-400"),
}
)
if p.Disabled {
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
}
if p.OnToggle != nil {
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
trigger.Attrs["disabled"] = "disabled"
}
rootMods := []vdom.Mod{
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.Button(triggerMods...),
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.Open {
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", formDropdown))}
if p.Searchable {
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownSearchWrap),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", formDropdownSearchInput),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
))
}
if p.ShowSelectAll && len(p.Options) > 0 {
all := formAllSelected(p.Options, p.Value)
// Select-all 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.
if p.ShowSelectAll && len(filtered) > 0 {
all := formAllSelected(filtered, p.Value)
label := "Select All"
if all {
label = "Deselect All"
}
saMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if p.OnChange != nil {
oc := p.OnChange
opts := p.Options
cur := p.Value
allNow := all
saMods = append(saMods, vdom.On(vdom.EVENT_CLICK, func() {
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if allNow {
oc(formDeselectAll(opts, cur))
} else {
oc(formSelectAllValues(opts, cur))
next := cur
for _, o := range opts {
next = formRemoveStr(next, o.Value)
}
oc(next)
return
}
oc(formSelectAllValues(opts, cur))
}))
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formSelectAllWrap), vdom.Button(saMods...)))
rows = append(rows, vdom.Button(mods...))
}
if len(p.Options) == 0 {
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("No options found")))
if len(filtered) == 0 {
rows = append(rows, m.noResults(false))
}
for _, opt := range p.Options {
ov := opt.Value
cbMods := []vdom.Mod{vdom.Attr("type", "checkbox"), vdom.Attr("style", "pointer-events:none")}
if formContainsStr(p.Value, opt.Value) {
cbMods = append(cbMods, vdom.Attr("checked", "checked"))
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))
}
optMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formDropdownOption),
vdom.Input(cbMods...),
vdom.Text(opt.Label),
// Deliberately NOT closing: this is a MULTI-select, and the user is very
// likely about to tick another one.
}))
}
if opt.Disabled {
optMods = append(optMods, vdom.Attr("disabled", "disabled"))
} else if p.OnChange != nil {
oc := p.OnChange
cur := p.Value
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formToggleStr(cur, ov)) }))
if m.IsOpen() {
m.matchFieldWidth()
}
ddMods = append(ddMods, vdom.Button(optMods...))
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
trigger,
m.panel(false, rows...),
)
}
rootMods = append(rootMods, vdom.Div(ddMods...))
const formMultiSelectTag = "inline-flex items-center gap-1 rounded-default bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-700 max-w-full"
func truncateRunes(s string, max int) string {
if max <= 0 {
return s
}
return vdom.Div(rootMods...)
r := []rune(s)
if len(r) <= max {
return s
}
return strings.TrimRight(string(r[:max]), " ") + "…"
}
// -- []string selection helpers (for FormMultiSelect) --------------------------

View File

@@ -0,0 +1,221 @@
package webui
import (
"strings"
"testing"
"kjol/vdom"
)
// fakeEvent is a vdom.Event with no DOM behind it — enough to drive Floating's
// close handlers natively. Target() is nil, which the outside-click test reads (quite
// correctly) as "this click was not inside the panel".
type fakeEvent struct{ key string }
func (fakeEvent) PreventDefault() {}
func (fakeEvent) StopPropagation() {}
func (fakeEvent) Value() string { return "" }
func (fakeEvent) Checked() bool { return false }
func (e fakeEvent) Key() string { return e.key }
func (fakeEvent) ClientX() int { return 0 }
func (fakeEvent) ClientY() int { return 0 }
func (fakeEvent) Target() any { return nil }
func (fakeEvent) SetData(_, _ string) {}
func (fakeEvent) GetData(string) string { return "" }
var _ vdom.Event = fakeEvent{}
func dropdownOptions() []FormSelectOption {
return []FormSelectOption{
{Value: "eng", Label: "Engineering"},
{Value: "res", Label: "Research"},
{Value: "net", Label: "Networking"},
}
}
// ---- closing ----
// The previous port took Open/OnToggle from the caller and had NO outside-click and
// NO Escape: once open, a dropdown stayed open until you clicked its trigger again.
// Both now come from Floating.
func TestMultiSelectClosesOnOutsideClick(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
m.Open()
if !m.IsOpen() {
t.Fatal("did not open")
}
m.f.onOutside(fakeEvent{}) // a mousedown that landed outside the panel
if m.IsOpen() {
t.Error("a multi-select must close on an outside click")
}
}
func TestMultiSelectClosesOnEscape(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
m.Open()
m.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE})
if m.IsOpen() {
t.Error("a multi-select must close on Escape")
}
}
func TestComboboxClosesOnOutsideClickAndEscape(t *testing.T) {
c := NewCombobox(DropdownOptions{})
c.Open()
c.f.onOutside(fakeEvent{})
if c.IsOpen() {
t.Error("a combobox must close on an outside click")
}
c.Open()
c.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE})
if c.IsOpen() {
t.Error("a combobox must close on Escape")
}
}
// Another key must NOT close it — otherwise typing in the search box would dismiss
// the thing you are searching.
func TestDropdownIgnoresOtherKeys(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
m.Open()
m.f.onKeydown(fakeEvent{key: "a"})
if !m.IsOpen() {
t.Error("an ordinary keypress closed the dropdown")
}
}
// ---- where they differ, deliberately ----
// A combobox picks ONE thing, so choosing is the end of the interaction: it closes.
func TestComboboxClosesWhenAnOptionIsChosen(t *testing.T) {
c := NewCombobox(DropdownOptions{})
got := ""
c.Open()
// Render, then invoke the option's click handler the way the DOM would.
node := c.Render(FormComboboxProps{
Options: dropdownOptions(),
OnChange: func(v string) { got = v },
})
clickOption(t, node, "Research")
if got != "res" {
t.Errorf("OnChange got %q, want \"res\"", got)
}
if c.IsOpen() {
t.Error("a combobox should close once an option is chosen")
}
}
// A multi-select picks SEVERAL, so choosing is NOT the end: closing the panel after
// every tick would make it unusable. This is the difference the two have to keep.
func TestMultiSelectStaysOpenWhenAnOptionIsChosen(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
var got []string
m.Open()
node := m.Render(FormMultiSelectProps{
Options: dropdownOptions(),
Value: []string{},
OnChange: func(v []string) { got = v },
})
clickOption(t, node, "Research")
if len(got) != 1 || got[0] != "res" {
t.Errorf("OnChange got %v, want [res]", got)
}
if !m.IsOpen() {
t.Error("a multi-select must STAY OPEN when an option is ticked — you are mid-selection")
}
}
// ---- the search box actually filters ----
//
// It used to be decorative: it rendered, and filtered nothing.
func TestDropdownSearchFilters(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
m.Open()
all := m.filter(dropdownOptions())
if len(all) != 3 {
t.Fatalf("an empty query filtered to %d options, want all 3", len(all))
}
m.search.Set("res")
got := m.filter(dropdownOptions())
if len(got) != 1 || got[0].Value != "res" {
t.Errorf("query \"res\" matched %v, want just Research", got)
}
// Case-insensitive, on the label the user can actually see.
m.search.Set("NETWORK")
if got := m.filter(dropdownOptions()); len(got) != 1 || got[0].Value != "net" {
t.Errorf("query \"NETWORK\" matched %v, want Networking", got)
}
}
// Closing abandons the query — reopening to a stale filter, showing one of three
// options for no visible reason, is worse than retyping.
func TestClosingClearsTheSearch(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
m.Open()
m.search.Set("res")
m.Close()
if m.search.Get() != "" {
t.Errorf("search survived the close: %q", m.search.Get())
}
}
// These open INSIDE other floatings (a filter popover, the calculated-column form).
// Without Standalone the single-open manager reads the dropdown as a rival panel and
// closes the very popover it lives in.
func TestDropdownsAreStandalone(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
if !m.f.opts.Standalone {
t.Error("a dropdown must be Standalone, or opening it closes its own parent popover")
}
}
// clickOption finds an option button by its label and invokes its click handler.
func clickOption(t *testing.T, n *vdom.VNode, label string) {
t.Helper()
if !findAndClick(n, label) {
t.Fatalf("no clickable option labelled %q in the rendered dropdown", label)
}
}
func findAndClick(n *vdom.VNode, label string) bool {
if n == nil {
return false
}
if n.Tag == "button" && nodeText(n) == label {
if h := n.Events[vdom.EVENT_CLICK]; h != nil {
h(fakeEvent{})
return true
}
}
for _, c := range n.Children {
if findAndClick(c, label) {
return true
}
}
return false
}
func nodeText(n *vdom.VNode) string {
if n.Tag == "" {
return n.Text
}
var b strings.Builder
for _, c := range n.Children {
b.WriteString(nodeText(c))
}
return b.String()
}