rename packages, refactor out tailwind compiler,
This commit is contained in:
16
.vscode/launch.json
vendored
16
.vscode/launch.json
vendored
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
// Debug configs for the gowasm example. cwd is the example dir because the
|
// 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.
|
// 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",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
@@ -11,7 +16,8 @@
|
|||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
|
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
|
||||||
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
|
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
|
||||||
"args": ["-addr", ":8085"]
|
"args": ["-addr", ":8085"],
|
||||||
|
"preLaunchTask": "gowasm: prebuild"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gowasm: dev server (no watch)",
|
"name": "gowasm: dev server (no watch)",
|
||||||
@@ -20,7 +26,11 @@
|
|||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
|
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
|
||||||
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
|
"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)",
|
"name": "gowasm: codegen (wasmgen)",
|
||||||
|
|||||||
74
.vscode/tasks.json
vendored
74
.vscode/tasks.json
vendored
@@ -2,14 +2,62 @@
|
|||||||
// Tasks for the kjol repo. The gowasm example is a nested module at
|
// 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/cmd/examples/go-wasm-web, so its tasks set cwd there; the module-wide
|
||||||
// Go tasks run in go/.
|
// 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",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "gowasm: dev server (hot reload)",
|
"label": "gowasm: prebuild",
|
||||||
"detail": "Run the go-wasm-web example: codegen + SSR + hot reload on :8085",
|
"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",
|
"type": "shell",
|
||||||
"command": "go run ./server",
|
"command": "go",
|
||||||
|
"args": ["run", "kjol/cmd/wasmgen", "./app"],
|
||||||
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
|
"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,
|
"isBackground": true,
|
||||||
"problemMatcher": {
|
"problemMatcher": {
|
||||||
"owner": "go",
|
"owner": "go",
|
||||||
@@ -19,27 +67,19 @@
|
|||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"activeOnStart": true,
|
"activeOnStart": true,
|
||||||
"beginsPattern": "rebuilding",
|
"beginsPattern": "rebuilding|recompiling",
|
||||||
"endsPattern": "serving|reloading clients"
|
"endsPattern": "serving|reloading clients|stylesheet updated"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"presentation": { "reveal": "always", "panel": "dedicated", "clear": true },
|
"presentation": { "reveal": "always", "panel": "dedicated", "clear": true },
|
||||||
"group": { "kind": "build", "isDefault": true }
|
"group": { "kind": "build", "isDefault": true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "gowasm: build (build.sh)",
|
"label": "gowasm: build",
|
||||||
"detail": "One-off build of the example: codegen + wasm + stage wasm_exec.js",
|
"detail": "One-off cold build of the example: codegen + Tailwind + wasm + stage wasm_exec.js",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "./build.sh",
|
"command": "go",
|
||||||
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
|
"args": ["run", "./build"],
|
||||||
"problemMatcher": ["$go"],
|
|
||||||
"group": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "gowasm: codegen",
|
|
||||||
"detail": "Regenerate app/*.gen.go from //gowasm: directives",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "go run kjol/cmd/wasmgen ./app",
|
|
||||||
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
|
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
|
||||||
"problemMatcher": ["$go"],
|
"problemMatcher": ["$go"],
|
||||||
"group": "build"
|
"group": "build"
|
||||||
|
|||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -24,23 +24,29 @@ Each top-level directory is one language / build root:
|
|||||||
```
|
```
|
||||||
kjol/
|
kjol/
|
||||||
go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/<pkg>`.
|
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/
|
# future: cpp/ kotlin/ swift/
|
||||||
```
|
```
|
||||||
|
|
||||||
Language-first, **not** feature-first. Consequence: the **bundler is Go** and lives in
|
Language-first, **not** feature-first. Consequence: the **web bundler is Go** and lives in
|
||||||
`go/bundler` even though it builds `web/`. Don't "fix" this by splitting it.
|
`go/webbundler` even though it builds `web/`. Don't "fix" this by splitting it.
|
||||||
|
|
||||||
### go/ — module `kjol`
|
### go/ — module `kjol`
|
||||||
|
|
||||||
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
|
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
|
`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
|
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
|
example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays
|
||||||
out of kjol).
|
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):
|
Build / test (run from repo root):
|
||||||
```
|
```
|
||||||
go -C go build ./...
|
go -C go build ./...
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// Thin CLI wrapper around kjol/bundler. The bundler wires its own Go-native
|
// Thin CLI wrapper around kjol/webbundler. The bundler wires its own Go-native
|
||||||
// Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic.
|
// Solid JSX compiler (see webbundler.Build), so this wrapper carries no build logic.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"kjol/bundler"
|
"kjol/webbundler"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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).")
|
genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default <app>/src/ui/generated).")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
cfg := bundler.Config{
|
cfg := webbundler.Config{
|
||||||
AppFrontend: *app,
|
AppFrontend: *app,
|
||||||
WebDir: *web,
|
WebDir: *web,
|
||||||
Output: *out,
|
Output: *out,
|
||||||
GenGoDir: *genGo,
|
GenGoDir: *genGo,
|
||||||
GenTSDir: *genTS,
|
GenTSDir: *genTS,
|
||||||
}
|
}
|
||||||
if err := bundler.Build(cfg); err != nil {
|
if err := webbundler.Build(cfg); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
during SSR: the server pre-renders the page's **spinner**, and the client runs
|
||||||
them for real after hydration.
|
them for real after hydration.
|
||||||
|
|
||||||
Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`,
|
Styling is **Tailwind**: the build runs `kjol/cmd/twcss`, which scans the Go markup +
|
||||||
which scans the Go markup + the `webui` kit for utility classes and compiles
|
the `webui` kit for utility classes and compiles `css/app.css` → `wwwroot/app.css` with
|
||||||
`css/app.css` → `wwwroot/app.css` with kjol's native Tailwind v4 engine. There is
|
kjol's native Tailwind v4 engine (`kjol/tw`). There is **no Bootstrap and no
|
||||||
**no Bootstrap and no hand-written CSS**. (`build.sh` does a one-off build.)
|
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
|
## This is a separate module
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
41
go/cmd/examples/go-wasm-web/build/main.go
Normal file
41
go/cmd/examples/go-wasm-web/build/main.go
Normal 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")
|
||||||
|
}
|
||||||
106
go/cmd/examples/go-wasm-web/buildsteps/buildsteps.go
Normal file
106
go/cmd/examples/go-wasm-web/buildsteps/buildsteps.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -13,15 +13,13 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"kjol/httputil"
|
"kjol/httputil"
|
||||||
"kjol/vdom"
|
"kjol/vdom"
|
||||||
"kjol/wasmdevserver"
|
"kjol/wasmdevserver"
|
||||||
|
|
||||||
"gowasmweb/app"
|
"gowasmweb/app"
|
||||||
|
"gowasmweb/buildsteps"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -34,7 +32,8 @@ func main() {
|
|||||||
Dir: "./wwwroot",
|
Dir: "./wwwroot",
|
||||||
Watch: *watch,
|
Watch: *watch,
|
||||||
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
|
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,
|
Render: render,
|
||||||
Document: document,
|
Document: document,
|
||||||
Handle: apiRoutes,
|
Handle: apiRoutes,
|
||||||
@@ -89,29 +88,3 @@ func document(inner string) string {
|
|||||||
</body>
|
</body>
|
||||||
</html>`
|
</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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
// The wasm client entry point (main.go) builds only under GOOS=js GOARCH=wasm.
|
// 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
|
// 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
|
// `go build ./...` succeeds; the real client is built by ./build (or the dev
|
||||||
// server with GOOS=js GOARCH=wasm.
|
// server) with GOOS=js GOARCH=wasm.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
func main() {}
|
func main() {}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"kjol/bundler"
|
"kjol/tw"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -32,7 +32,7 @@ func main() {
|
|||||||
fmt.Fprintln(os.Stderr, "twcss:", err)
|
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
css, err := bundler.CompileTailwind(string(src), *base, flag.Args())
|
css, err := tw.CompileFiles(string(src), *base, flag.Args())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "twcss:", err)
|
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package tw
|
||||||
|
|
||||||
// Native Go Tailwind v4 compiler. A from-scratch, pure-Go implementation of the
|
// Native Go Tailwind v4 compiler. A from-scratch, pure-Go implementation of the
|
||||||
// Tailwind v4 engine — CSS parser (AST), candidate scanner, utility/variant
|
// Tailwind v4 engine — CSS parser (AST), candidate scanner, utility/variant
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package tw
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
59
go/tw/tw.go
Normal file
59
go/tw/tw.go
Normal 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) }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !(js && wasm)
|
||||||
|
|
||||||
// Package wasmdevserver is a reusable development server for gowasm apps: it serves
|
// 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 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
|
// the /rsc server-component endpoint, and hot-swaps the freshly built wasm into
|
||||||
@@ -33,14 +35,21 @@ import (
|
|||||||
// Config wires an app into the dev server. Render and Document are called per
|
// Config wires an app into the dev server. Render and Document are called per
|
||||||
// request; Build is called for the initial build and on every source change.
|
// request; Build is called for the initial build and on every source change.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Addr string // listen address (default ":8085")
|
Addr string // listen address (default ":8085")
|
||||||
Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot")
|
Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot")
|
||||||
Watch bool // rebuild on change + hot reload
|
Watch bool // rebuild on change + hot reload
|
||||||
WatchDirs []string // source dirs to watch when Watch is set
|
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
|
||||||
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
|
// BuildCSS recompiles ONLY the stylesheet. When a save touched stylesheets and no
|
||||||
Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve builds once (in watch mode), wires the routes, and blocks serving.
|
// Serve builds once (in watch mode), wires the routes, and blocks serving.
|
||||||
@@ -116,15 +125,45 @@ func rootHandler(cfg Config) http.HandlerFunc {
|
|||||||
|
|
||||||
// ---- build + watch ------------------------------------------------------
|
// ---- 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) {
|
func watchLoop(cfg Config, h *hub) {
|
||||||
prev := fingerprint(cfg.WatchDirs)
|
prevGo := fingerprintExt(cfg.WatchDirs, ".go")
|
||||||
|
prevCSS := fingerprintExt(cfg.WatchDirs, ".css")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
time.Sleep(300 * time.Millisecond)
|
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
|
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…")
|
log.Println("change detected, rebuilding…")
|
||||||
h.broadcast(`{"type":"building"}`)
|
h.broadcast(`{"type":"building"}`)
|
||||||
if cfg.Build == nil {
|
if cfg.Build == nil {
|
||||||
@@ -141,12 +180,20 @@ func watchLoop(cfg Config, h *hub) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fingerprint changes whenever any .go file under dirs is modified.
|
// fingerprintExt changes whenever a file of the given extension under dirs is
|
||||||
func fingerprint(dirs []string) int64 {
|
// 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
|
var fp int64
|
||||||
for _, d := range dirs {
|
for _, d := range dirs {
|
||||||
filepath.WalkDir(d, func(path string, e fs.DirEntry, err error) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
if info, err := e.Info(); err == 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
|
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
|
// 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
|
// app underneath keeps running (and its state), so fixing the code and saving
|
||||||
// clears the overlay and hot-swaps without losing anything.
|
// 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 = {};
|
var msg = {};
|
||||||
try { msg = JSON.parse(e.data); } catch (_) { return; }
|
try { msg = JSON.parse(e.data); } catch (_) { return; }
|
||||||
if (msg.type === "reload") { hotSwap(); } // build ok: swap in place
|
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 === "error") { showOverlay(msg.msg); } // build failed: show compiler output
|
||||||
else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); }
|
else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); }
|
||||||
};
|
};
|
||||||
|
|||||||
11
go/wasmdevserver/devserver_wasm.go
Normal file
11
go/wasmdevserver/devserver_wasm.go
Normal 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
// genssr.go public-page SSR bake + Go registry generation
|
// genssr.go public-page SSR bake + Go registry generation
|
||||||
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
|
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
|
||||||
// watch.go poll-and-rebuild watch loop
|
// watch.go poll-and-rebuild watch loop
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja).
|
// 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 {
|
func isSpace(b byte) bool {
|
||||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
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')
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
|
// Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
|
||||||
//
|
//
|
||||||
@@ -712,7 +712,7 @@ func (g *solidGen) isConstBinding(name string) bool {
|
|||||||
if !reSimpleIdent.MatchString(name) {
|
if !reSimpleIdent.MatchString(name) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if regexp.MustCompile(`\b(?:let|var)\s+`+regexp.QuoteMeta(name)+`\b`).MatchString(g.source) {
|
if regexp.MustCompile(`\b(?:let|var)\s+` + regexp.QuoteMeta(name) + `\b`).MatchString(g.source) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
|
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
|
||||||
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
|
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
|
||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"github.com/tdewolff/minify/v2"
|
"github.com/tdewolff/minify/v2"
|
||||||
mincss "github.com/tdewolff/minify/v2/css"
|
mincss "github.com/tdewolff/minify/v2/css"
|
||||||
|
|
||||||
|
"kjol/tw"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tailwind source patterns - configured here rather than in CSS so each bundle
|
// 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
|
// 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
|
// dir) plus the shared kit tree scanned directly, so kit component classes are
|
||||||
// present even though the kit lives outside the app frontend.
|
// present even though the kit lives outside the app frontend.
|
||||||
candidates := scanSources(cssDir, twSources)
|
candidates := tw.Scan(cssDir, twSources)
|
||||||
kitCands := scanSources(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
|
kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
|
||||||
candidates = dedupStrings(append(candidates, kitCands...))
|
candidates = dedupStrings(append(candidates, kitCands...))
|
||||||
|
|
||||||
// Prepend the shared @theme scaffold (kjol web/styles/theme.css) ahead of the
|
// 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 {
|
if err != nil {
|
||||||
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
|
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// defaultExportShimPlugin bridges two ESM-strictness mismatches that
|
// defaultExportShimPlugin bridges two ESM-strictness mismatches that
|
||||||
// existed in the previous custom bundler:
|
// existed in the previous custom bundler:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -200,7 +200,7 @@ func parseFASvg(svg string) (x, y, w, h, path string, ok bool) {
|
|||||||
return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is
|
return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is
|
||||||
}
|
}
|
||||||
mx, my := vw/10, vh/10
|
mx, my := vw/10, vh/10
|
||||||
return numStr(mx), numStr(my), numStr(vw-2*mx), numStr(vh-2*my), strings.Join(ds, " "), true
|
return numStr(mx), numStr(my), numStr(vw - 2*mx), numStr(vh - 2*my), strings.Join(ds, " "), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
|
func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// Public-route code generation. The single source of truth is the TypeScript
|
// Public-route code generation. The single source of truth is the TypeScript
|
||||||
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +
|
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// Public-page SSR code generation. For each page in the manifest
|
// Public-page SSR code generation. For each page in the manifest
|
||||||
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component
|
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// The development HMR server: it serves the SPA source tree as unbundled native
|
// 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
|
// 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
|
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
|
||||||
// a non-boundary module bubbles up to a full page reload.
|
// 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,
|
// 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
|
// 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.
|
// production server and the plain `cmd/bundle` build carry none of it.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
|
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
|
||||||
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
|
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
|
// 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
|
// (no external dependency). A source-module change is turned into an HMR update
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
|
// 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
|
// 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
|
// 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
|
// 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.
|
// HMR support is behind `//go:build dev`, so prod builds compile none of it.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build dev
|
//go:build dev
|
||||||
|
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
|
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
|
||||||
// which lets misnamed specifiers slip through. This check catches them up
|
// which lets misnamed specifiers slip through. This check catches them up
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time
|
// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time
|
||||||
// deployment environment (appenv.Environment) into the JS, where
|
// deployment environment (appenv.Environment) into the JS, where
|
||||||
// frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT
|
// 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.
|
// the client takeover.
|
||||||
func esbuildDefine() map[string]string {
|
func esbuildDefine() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
@@ -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
|
// optimized Solid dom-expressions output (template cloning + fine-grained
|
||||||
// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler
|
// 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);
|
// 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
|
// 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
|
// 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.
|
// loads under HMR); there is no on-disk cache and no compiler bootstrap.
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// Top-level source segmentation. segmentTopLevel splits a module into contiguous
|
// Top-level source segmentation. segmentTopLevel splits a module into contiguous
|
||||||
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to
|
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the
|
// 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 {
|
func walkRepoTSX(t *testing.T) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := filepath.Join("..", "..", "frontend", "src")
|
root := filepath.Join("..", "..", "frontend", "src")
|
||||||
@@ -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
|
// 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
|
// 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
|
// 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.
|
// is imported by the server; the build-time entries are used by the bundler.
|
||||||
//
|
//
|
||||||
// -mta
|
// -mta
|
||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -132,7 +132,7 @@ func TestRenderHomeSkeleton(t *testing.T) {
|
|||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`class="page-home"`,
|
`class="page-home"`,
|
||||||
`The Ultimate Funding and Investing Solution`,
|
`The Ultimate Funding and Investing Solution`,
|
||||||
`viewBox="0 0 24 24"`, // SVG attribute case preserved
|
`viewBox="0 0 24 24"`, // SVG attribute case preserved
|
||||||
`background-image: url(/images/public/hero-bg.jpg)`, // static string style baked into the template
|
`background-image: url(/images/public/hero-bg.jpg)`, // static string style baked into the template
|
||||||
`animate-pulse`, // skeleton bars (SSR forces showSkeleton)
|
`animate-pulse`, // skeleton bars (SSR forces showSkeleton)
|
||||||
`90 Day`, // term labels shown in the skeleton
|
`90 Day`, // term labels shown in the skeleton
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
// Build-time caching + parallelism for public-page SSR (see genssr.go).
|
// Build-time caching + parallelism for public-page SSR (see genssr.go).
|
||||||
//
|
//
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bundler
|
package webbundler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1686,8 +1686,8 @@ type AutoTableState struct {
|
|||||||
// thRefs memoizes header-cell refs, so a resize can measure them.
|
// thRefs memoizes header-cell refs, so a resize can measure them.
|
||||||
thRefs map[string]*vdom.Ref
|
thRefs map[string]*vdom.Ref
|
||||||
|
|
||||||
// openSignals memoizes per-dropdown open state (see openSignal).
|
// multiSelects memoizes the dropdown controllers (see multiSelect).
|
||||||
openSignals map[string]*vdom.Signal[bool]
|
multiSelects map[string]*MultiSelect
|
||||||
|
|
||||||
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
|
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
|
||||||
// what the current filter actually selected.
|
// what the current filter actually selected.
|
||||||
@@ -2116,34 +2116,33 @@ func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, value
|
|||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
options = append(options, FormSelectOption{Value: v, Label: v})
|
options = append(options, FormSelectOption{Value: v, Label: v})
|
||||||
}
|
}
|
||||||
open := s.openSignal(identifier)
|
|
||||||
|
|
||||||
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
|
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
|
||||||
FormMultiSelect(FormMultiSelectProps{
|
s.multiSelect(identifier).Render(FormMultiSelectProps{
|
||||||
Options: options,
|
Options: options,
|
||||||
Value: s.SearchValues(identifier),
|
Value: s.SearchValues(identifier),
|
||||||
Placeholder: pick(placeholder, "Any"),
|
Placeholder: pick(placeholder, "Any"),
|
||||||
|
Searchable: true,
|
||||||
ShowSelectAll: true,
|
ShowSelectAll: true,
|
||||||
Open: open.Get(),
|
|
||||||
OnToggle: func() { open.Set(!open.Get()) },
|
|
||||||
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
|
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openSignal memoizes the open state of a dropdown, keyed by identifier. It has to
|
// multiSelect memoizes a dropdown controller, keyed by identifier.
|
||||||
// be memoized: a signal created fresh inside a render would reset to closed on
|
//
|
||||||
// every render, so the dropdown could never stay open.
|
// It MUST be memoized. A controller built inside a render would get fresh refs and a
|
||||||
func (s *AutoTableState) openSignal(key string) *vdom.Signal[bool] {
|
// fresh closed state every frame — the dropdown could never stay open, and the
|
||||||
if s.openSignals == nil {
|
// outside-click listener would be watching an element that no longer exists.
|
||||||
s.openSignals = map[string]*vdom.Signal[bool]{}
|
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 {
|
if !ok {
|
||||||
sig = vdom.NewSignal(false)
|
ms = NewMultiSelect(DropdownOptions{})
|
||||||
s.openSignals[key] = sig
|
s.multiSelects[key] = ms
|
||||||
}
|
}
|
||||||
return sig
|
return ms
|
||||||
}
|
}
|
||||||
|
|
||||||
// DateSearch renders a date field bound to an identifier. A date RANGE is two of
|
// 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 {
|
if len(options) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
open := s.openSignal("__columns__")
|
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
|
||||||
|
|
||||||
return FormMultiSelect(FormMultiSelectProps{
|
|
||||||
Options: options,
|
Options: options,
|
||||||
Value: selected,
|
Value: selected,
|
||||||
Placeholder: "Columns",
|
Placeholder: "Columns",
|
||||||
Searchable: true,
|
Searchable: true,
|
||||||
ShowSelectAll: true,
|
ShowSelectAll: true,
|
||||||
FieldWidth: "w-52",
|
FieldWidth: "w-52",
|
||||||
Open: open.Get(),
|
|
||||||
OnToggle: func() { open.Set(!open.Get()) },
|
|
||||||
OnChange: func(visible []string) {
|
OnChange: func(visible []string) {
|
||||||
show := map[string]bool{}
|
show := map[string]bool{}
|
||||||
for _, k := range visible {
|
for _, k := range visible {
|
||||||
@@ -3266,10 +3261,14 @@ type calcEditor struct {
|
|||||||
position *vdom.Signal[int]
|
position *vdom.Signal[int]
|
||||||
errorMsg *vdom.Signal[string]
|
errorMsg *vdom.Signal[string]
|
||||||
|
|
||||||
opOpen *vdom.Signal[bool]
|
opSelect *MultiSelect // the Basic tab's operand picker
|
||||||
colMenu *Menu
|
|
||||||
fnMenu *Menu
|
// The function menu's own search box.
|
||||||
constMenu *Menu
|
fnSearch *vdom.Signal[string]
|
||||||
|
fnSearchRef *vdom.Ref
|
||||||
|
colMenu *Menu
|
||||||
|
fnMenu *Menu
|
||||||
|
constMenu *Menu
|
||||||
|
|
||||||
// The textarea and the highlight overlay behind it. Both are needed: the caret
|
// The textarea and the highlight overlay behind it. Both are needed: the caret
|
||||||
// insert writes into the textarea, and the overlay's scroll has to follow it.
|
// insert writes into the textarea, and the overlay's scroll has to follow it.
|
||||||
@@ -3282,27 +3281,40 @@ func (s *AutoTableState) calcEditorState() *calcEditor {
|
|||||||
return s.editor
|
return s.editor
|
||||||
}
|
}
|
||||||
e := &calcEditor{
|
e := &calcEditor{
|
||||||
view: vdom.NewSignal("menu"),
|
view: vdom.NewSignal("menu"),
|
||||||
editingID: vdom.NewSignal(""),
|
editingID: vdom.NewSignal(""),
|
||||||
advanced: vdom.NewSignal(false),
|
advanced: vdom.NewSignal(false),
|
||||||
name: vdom.NewSignal(""),
|
name: vdom.NewSignal(""),
|
||||||
fn: vdom.NewSignal(string(CALC_FN_SUM)),
|
fn: vdom.NewSignal(string(CALC_FN_SUM)),
|
||||||
operands: vdom.NewSignal([]string{}),
|
operands: vdom.NewSignal([]string{}),
|
||||||
formula: vdom.NewSignal(""),
|
formula: vdom.NewSignal(""),
|
||||||
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
|
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
|
||||||
precision: vdom.NewSignal(""),
|
precision: vdom.NewSignal(""),
|
||||||
position: vdom.NewSignal(int(COL_POS_RIGHT)),
|
position: vdom.NewSignal(int(COL_POS_RIGHT)),
|
||||||
errorMsg: vdom.NewSignal(""),
|
errorMsg: vdom.NewSignal(""),
|
||||||
opOpen: vdom.NewSignal(false),
|
opSelect: NewMultiSelect(DropdownOptions{}),
|
||||||
formulaRef: vdom.NewRef(),
|
fnSearch: vdom.NewSignal(""),
|
||||||
overlayRef: vdom.NewRef(),
|
fnSearchRef: vdom.NewRef(),
|
||||||
|
formulaRef: vdom.NewRef(),
|
||||||
|
overlayRef: vdom.NewRef(),
|
||||||
// Standalone: these menus live INSIDE the editor's popover, and without it the
|
// 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
|
// single-open manager would read them as a rival panel and close their own
|
||||||
// parent as they opened.
|
// parent as they opened.
|
||||||
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
||||||
fnMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
|
||||||
constMenu: 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{
|
e.pop = NewPopover(PopoverProps{
|
||||||
Placement: PlacementBottomEnd,
|
Placement: PlacementBottomEnd,
|
||||||
// Closing the popover abandons the draft — reopening should start clean rather
|
// Closing the popover abandons the draft — reopening should start clean rather
|
||||||
@@ -3382,12 +3394,124 @@ var calcDataTypes = []struct {
|
|||||||
{CALC_TYPE_PLAIN, "Plain"},
|
{CALC_TYPE_PLAIN, "Plain"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormulaFunctionNames are the functions a formula may call, for the insert menu.
|
// FormulaFunction is one entry in the insert menu: what to type, what it takes, and
|
||||||
var FormulaFunctionNames = []string{
|
// what it does. The signature and the description are not decoration — they are what
|
||||||
"SUM", "AVERAGE", "MEDIAN", "MODE", "MIN", "MAX", "COUNT",
|
// makes a list of thirty-five names usable by someone who does not already know them,
|
||||||
"ABS", "ROUND", "FLOOR", "CEILING", "SQRT", "POWER", "MOD", "EXP", "LN", "LOG",
|
// and they are what the menu's search matches against.
|
||||||
"SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "ATAN2", "SINH", "COSH", "TANH",
|
type FormulaFunction struct {
|
||||||
"RADIANS", "DEGREES", "IF", "AND", "OR", "NOT", "ROW",
|
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
|
// 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})
|
msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label})
|
||||||
}
|
}
|
||||||
children = append(children, calcField("Columns (combined per row)",
|
children = append(children, calcField("Columns (combined per row)",
|
||||||
FormMultiSelect(FormMultiSelectProps{
|
e.opSelect.Render(FormMultiSelectProps{
|
||||||
Options: msOpts,
|
Options: msOpts,
|
||||||
Value: e.operands.Get(),
|
Value: e.operands.Get(),
|
||||||
Placeholder: "Select columns…",
|
Placeholder: "Select columns…",
|
||||||
Searchable: true,
|
Searchable: true,
|
||||||
Small: true,
|
Small: true,
|
||||||
Open: e.opOpen.Get(),
|
|
||||||
OnToggle: func() { e.opOpen.Set(!e.opOpen.Get()) },
|
|
||||||
OnChange: func(v []string) { e.operands.Set(v) },
|
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))
|
// The function menu, grouped by category and searchable. Thirty-five names in one
|
||||||
for _, name := range FormulaFunctionNames {
|
// flat list is a wall; grouped by what they are FOR, with a signature and a
|
||||||
fname := name
|
// sentence, it is something you can actually find SUM in.
|
||||||
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
|
fnItems := []*vdom.VNode{e.fnSearchBox()}
|
||||||
OnClick: func() { e.insertAtCaret(fname + "()") },
|
groups := filterFormulaGroups(e.fnSearch.Get())
|
||||||
}, vdom.Span(vdom.Attr("class", "font-mono text-emerald-700"), vdom.Text(fname))))
|
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.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))
|
constItems := make([]*vdom.VNode, 0, len(FormulaConstantOptions))
|
||||||
for name := range FormulaConstants {
|
for _, c := range FormulaConstantOptions {
|
||||||
constNames = append(constNames, name)
|
cc := c
|
||||||
}
|
|
||||||
sort.Strings(constNames)
|
|
||||||
constItems := make([]*vdom.VNode, 0, len(constNames))
|
|
||||||
for _, name := range constNames {
|
|
||||||
cname := name
|
|
||||||
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
|
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
|
||||||
OnClick: func() { e.insertAtCaret(cname) },
|
OnClick: func() { e.insertAtCaret(cc.Name) },
|
||||||
}, vdom.Span(vdom.Attr("class", "font-mono text-amber-600"), vdom.Text(cname))))
|
},
|
||||||
|
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"),
|
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...)
|
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
|
// 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.
|
// fires on every scroll frame, and a signal write would re-render the whole table.
|
||||||
func (e *calcEditor) syncOverlayScroll() {
|
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
|
// 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
|
// the whole point of an insert menu. It needs the caret position and focus back
|
||||||
// afterwards, both of which come from the host API.
|
// 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()
|
src := e.formula.Get()
|
||||||
start, end := wasmruntime.SelectionRange(e.formulaRef)
|
start, end := wasmruntime.SelectionRange(e.formulaRef)
|
||||||
if start < 0 || start > len(src) || end < start || end > len(src) {
|
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
|
// 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 can only be placed once that render has landed.
|
||||||
caret := start + len(text)
|
caret := start + len(text) - back
|
||||||
wasmruntime.AfterRender(func() {
|
wasmruntime.AfterRender(func() {
|
||||||
wasmruntime.Focus(e.formulaRef)
|
wasmruntime.Focus(e.formulaRef)
|
||||||
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)
|
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)
|
||||||
|
|||||||
@@ -490,3 +490,115 @@ func TestEditingJumpsToTheRightForm(t *testing.T) {
|
|||||||
t.Error("reset did not return the editor to a clean chooser")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"kjol/vdom"
|
"kjol/vdom"
|
||||||
|
"kjol/wasmruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Port of web/kit/Forms.tsx. Reactive accessors collapse to plain values, and
|
// 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...)
|
return FormSelect(p, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- combobox / multi-select (approximated) ------------------------------------
|
// -- combobox / multi-select ---------------------------------------------------
|
||||||
//
|
//
|
||||||
// NOTE: FormCombobox, FormSearchableSelect and FormMultiSelect in the TSX open a
|
// Both are built on the Floating controller, which is where their close behaviour
|
||||||
// solid-js Portal, measure the trigger with getBoundingClientRect + a
|
// comes from: an outside mousedown, and Escape closing the topmost panel only. The
|
||||||
// requestAnimationFrame position tracker (floating-ui style), focus-trap the
|
// previous port took `Open bool` + `OnToggle func()` from the caller and had NEITHER
|
||||||
// search box, filter options against the query, track a keyboard-highlighted
|
// — a dropdown, once open, stayed open until you clicked its trigger again. It also
|
||||||
// index, and close on document mousedown. None of that has a neutral-runtime
|
// rendered its panel in-flow, so it was clipped by any scrolling ancestor, and its
|
||||||
// equivalent, so these ports:
|
// search box was decorative: it filtered nothing.
|
||||||
// - take `Open bool` + `OnToggle func()` instead of an internal open signal,
|
//
|
||||||
// - render the dropdown inline, positioned with `absolute top-full` utilities
|
// # Where a multi-select differs from a combobox, deliberately
|
||||||
// rather than fixed computed coordinates (no Portal),
|
//
|
||||||
// - render ALL options (the search box is decorative — client-side filtering,
|
// A combobox picks ONE thing, so choosing closes it. A multi-select picks SEVERAL, so
|
||||||
// keyboard nav, and highlight tracking are dropped),
|
// choosing does NOT — you are mid-selection, and closing the panel under you after
|
||||||
// - keep selection fully functional through Value + OnChange.
|
// 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 {
|
type FormComboboxProps struct {
|
||||||
Options []FormSelectOption
|
Options []FormSelectOption
|
||||||
Value string
|
Value string
|
||||||
@@ -819,13 +1032,10 @@ type FormComboboxProps struct {
|
|||||||
OnDark bool
|
OnDark bool
|
||||||
MaxDisplayLength int
|
MaxDisplayLength int
|
||||||
Disabled bool
|
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.
|
// Render draws the field and its panel.
|
||||||
func FormCombobox(p FormComboboxProps) *vdom.VNode {
|
func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
|
||||||
var selected *FormSelectOption
|
var selected *FormSelectOption
|
||||||
for i := range p.Options {
|
for i := range p.Options {
|
||||||
if p.Options[i].Value == p.Value {
|
if p.Options[i].Value == p.Value {
|
||||||
@@ -840,13 +1050,7 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
|
|||||||
placeholderCls = "text-text-on-dark-muted"
|
placeholderCls = "text-text-on-dark-muted"
|
||||||
}
|
}
|
||||||
if selected != nil {
|
if selected != nil {
|
||||||
display = selected.Label
|
display = truncateRunes(selected.Label, p.MaxDisplayLength)
|
||||||
if p.MaxDisplayLength > 0 {
|
|
||||||
r := []rune(display)
|
|
||||||
if len(r) > p.MaxDisplayLength {
|
|
||||||
display = strings.TrimRight(string(r[:p.MaxDisplayLength]), " ") + "…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placeholderCls = ""
|
placeholderCls = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,89 +1059,72 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
|
|||||||
chevronCls = "text-text-on-dark-muted"
|
chevronCls = "text-text-on-dark-muted"
|
||||||
}
|
}
|
||||||
chevron := "chevron-down"
|
chevron := "chevron-down"
|
||||||
if p.Open {
|
if c.IsOpen() {
|
||||||
chevron = "chevron-up"
|
chevron = "chevron-up"
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerMods := []vdom.Mod{
|
trigger := c.f.Trigger(FloatingTriggerProps{
|
||||||
vdom.Attr("type", "button"),
|
Class: formTriggerCls(p.Small, p.OnDark),
|
||||||
vdom.Attr("class", formTriggerCls(p.Small, p.OnDark)),
|
AriaHasPopup: "listbox",
|
||||||
|
},
|
||||||
vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
|
vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
|
||||||
IconInline(chevron, 16, chevronCls),
|
IconInline(chevron, 16, chevronCls),
|
||||||
}
|
)
|
||||||
if p.Disabled {
|
if p.Disabled {
|
||||||
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
|
trigger.Attrs["disabled"] = "disabled"
|
||||||
}
|
|
||||||
if p.OnToggle != nil {
|
|
||||||
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.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))}
|
|
||||||
|
|
||||||
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...")),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
// NewSearchableSelect is a Combobox with the search box on — the old
|
||||||
func FormSearchableSelect(p FormComboboxProps) *vdom.VNode {
|
// FormSearchableSelect.
|
||||||
p.Searchable = true
|
func NewSearchableSelect(o DropdownOptions) *Combobox { return NewCombobox(o) }
|
||||||
return FormCombobox(p)
|
|
||||||
|
// ---- 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)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormMultiSelectProps configures FormMultiSelect.
|
// FormMultiSelectProps configures MultiSelect.Render.
|
||||||
type FormMultiSelectProps struct {
|
type FormMultiSelectProps struct {
|
||||||
Options []FormSelectOption
|
Options []FormSelectOption
|
||||||
Value []string
|
Value []string
|
||||||
@@ -951,14 +1138,10 @@ type FormMultiSelectProps struct {
|
|||||||
Small bool
|
Small bool
|
||||||
Class string
|
Class string
|
||||||
FieldWidth 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
|
// Render draws the field and its panel.
|
||||||
// select-all row. Selection is functional through Value + OnChange.
|
func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||||
func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
|
|
||||||
maxTags := p.MaxTagsBeforeCollapse
|
maxTags := p.MaxTagsBeforeCollapse
|
||||||
if maxTags == 0 {
|
if maxTags == 0 {
|
||||||
maxTags = 3
|
maxTags = 3
|
||||||
@@ -971,138 +1154,133 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger content: placeholder, tag list, or "N items selected".
|
// The trigger's face: a placeholder, tags, or "N selected" once there are too many
|
||||||
var triggerContent *vdom.VNode
|
// to show without the field growing unboundedly.
|
||||||
|
var face *vdom.VNode
|
||||||
switch {
|
switch {
|
||||||
case len(selected) == 0:
|
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:
|
case len(selected) > maxTags:
|
||||||
n := len(selected)
|
face = vdom.Span(vdom.Attr("class", "truncate"),
|
||||||
suffix := "s"
|
vdom.Text(strconv.Itoa(len(selected))+" selected"))
|
||||||
if n == 1 {
|
|
||||||
suffix = ""
|
|
||||||
}
|
|
||||||
triggerContent = vdom.Span(vdom.Text(strconv.Itoa(n) + " item" + suffix + " selected"))
|
|
||||||
default:
|
default:
|
||||||
tagSize := "py-0.5 px-2 text-sm"
|
tags := []vdom.Mod{vdom.Attr("class", "flex flex-wrap items-center gap-1 min-w-0")}
|
||||||
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")}
|
|
||||||
for _, opt := range selected {
|
for _, opt := range selected {
|
||||||
ov := opt.Value
|
ov := opt.Value
|
||||||
// NOTE: the remove button nests inside the trigger button (as in the TSX);
|
remove := []vdom.Mod{
|
||||||
// the neutral Event has no stopPropagation, so a remove click also toggles
|
|
||||||
// the dropdown. Harmless given selection is idempotent through OnChange.
|
|
||||||
rmMods := []vdom.Mod{
|
|
||||||
vdom.Attr("type", "button"),
|
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-neutral-900"),
|
||||||
|
vdom.Attr("aria-label", "Remove "+opt.Label),
|
||||||
IconInline("xmark", 10, ""),
|
IconInline("xmark", 10, ""),
|
||||||
}
|
}
|
||||||
if p.OnChange != nil {
|
if p.OnChange != nil {
|
||||||
oc := p.OnChange
|
oc, cur := p.OnChange, p.Value
|
||||||
cur := p.Value
|
remove = append(remove, vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
|
||||||
rmMods = append(rmMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formRemoveStr(cur, ov)) }))
|
// 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.Text(opt.Label),
|
||||||
vdom.Button(rmMods...),
|
vdom.Button(remove...),
|
||||||
)
|
))
|
||||||
tagsMods = append(tagsMods, tag)
|
|
||||||
}
|
}
|
||||||
triggerContent = vdom.Div(tagsMods...)
|
face = vdom.Div(tags...)
|
||||||
}
|
}
|
||||||
|
|
||||||
pad := "p-2"
|
pad := "p-2"
|
||||||
if p.Small {
|
if p.Small {
|
||||||
pad = "p-1"
|
pad = "p-1"
|
||||||
}
|
}
|
||||||
triggerCls := cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad)
|
|
||||||
chevron := "chevron-down"
|
chevron := "chevron-down"
|
||||||
if p.Open {
|
if m.IsOpen() {
|
||||||
chevron = "chevron-up"
|
chevron = "chevron-up"
|
||||||
}
|
}
|
||||||
triggerMods := []vdom.Mod{
|
|
||||||
vdom.Attr("type", "button"),
|
trigger := m.f.Trigger(FloatingTriggerProps{
|
||||||
vdom.Attr("class", triggerCls),
|
Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad),
|
||||||
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), triggerContent),
|
AriaHasPopup: "listbox",
|
||||||
|
},
|
||||||
|
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), face),
|
||||||
IconInline(chevron, 16, "text-neutral-400"),
|
IconInline(chevron, 16, "text-neutral-400"),
|
||||||
}
|
)
|
||||||
if p.Disabled {
|
if p.Disabled {
|
||||||
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
|
trigger.Attrs["disabled"] = "disabled"
|
||||||
}
|
|
||||||
if p.OnToggle != nil {
|
|
||||||
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rootMods := []vdom.Mod{
|
filtered := m.filter(p.Options)
|
||||||
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
|
rows := make([]*vdom.VNode, 0, len(filtered)+2)
|
||||||
vdom.Button(triggerMods...),
|
if p.Searchable && m.IsOpen() {
|
||||||
|
rows = append(rows, m.searchBox(p.SearchPlaceholder, false, len(filtered)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Open {
|
// Select-all acts on what is VISIBLE. Selecting all of a filtered list is what the
|
||||||
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", formDropdown))}
|
// user is looking at and asked for; quietly selecting the hidden ones too would be
|
||||||
|
// a nasty surprise.
|
||||||
if p.Searchable {
|
if p.ShowSelectAll && len(filtered) > 0 {
|
||||||
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownSearchWrap),
|
all := formAllSelected(filtered, p.Value)
|
||||||
vdom.Input(vdom.Attr("type", "text"),
|
label := "Select All"
|
||||||
vdom.Attr("class", formDropdownSearchInput),
|
if all {
|
||||||
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
|
label = "Deselect All"
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
|
||||||
if p.ShowSelectAll && len(p.Options) > 0 {
|
if p.OnChange != nil {
|
||||||
all := formAllSelected(p.Options, p.Value)
|
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
|
||||||
label := "Select All"
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
if all {
|
if allNow {
|
||||||
label = "Deselect All"
|
next := cur
|
||||||
}
|
for _, o := range opts {
|
||||||
saMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
|
next = formRemoveStr(next, o.Value)
|
||||||
if p.OnChange != nil {
|
|
||||||
oc := p.OnChange
|
|
||||||
opts := p.Options
|
|
||||||
cur := p.Value
|
|
||||||
allNow := all
|
|
||||||
saMods = append(saMods, vdom.On(vdom.EVENT_CLICK, func() {
|
|
||||||
if allNow {
|
|
||||||
oc(formDeselectAll(opts, cur))
|
|
||||||
} else {
|
|
||||||
oc(formSelectAllValues(opts, cur))
|
|
||||||
}
|
}
|
||||||
}))
|
oc(next)
|
||||||
}
|
return
|
||||||
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formSelectAllWrap), vdom.Button(saMods...)))
|
}
|
||||||
|
oc(formSelectAllValues(opts, cur))
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
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")))
|
|
||||||
}
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
optMods := []vdom.Mod{
|
|
||||||
vdom.Attr("type", "button"),
|
|
||||||
vdom.Attr("class", formDropdownOption),
|
|
||||||
vdom.Input(cbMods...),
|
|
||||||
vdom.Text(opt.Label),
|
|
||||||
}
|
|
||||||
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)) }))
|
|
||||||
}
|
|
||||||
ddMods = append(ddMods, vdom.Button(optMods...))
|
|
||||||
}
|
|
||||||
rootMods = append(rootMods, vdom.Div(ddMods...))
|
|
||||||
}
|
}
|
||||||
return vdom.Div(rootMods...)
|
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
rows = append(rows, m.noResults(false))
|
||||||
|
}
|
||||||
|
for i := range filtered {
|
||||||
|
opt := filtered[i]
|
||||||
|
on := formContainsStr(p.Value, opt.Value)
|
||||||
|
rows = append(rows, m.optionButton(opt, i, on, false, p.Small, func() {
|
||||||
|
if p.OnChange != nil {
|
||||||
|
p.OnChange(formToggleStr(p.Value, opt.Value))
|
||||||
|
}
|
||||||
|
// Deliberately NOT closing: this is a MULTI-select, and the user is very
|
||||||
|
// likely about to tick another one.
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.IsOpen() {
|
||||||
|
m.matchFieldWidth()
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.Div(
|
||||||
|
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
|
||||||
|
trigger,
|
||||||
|
m.panel(false, rows...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimRight(string(r[:max]), " ") + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- []string selection helpers (for FormMultiSelect) --------------------------
|
// -- []string selection helpers (for FormMultiSelect) --------------------------
|
||||||
|
|||||||
221
go/webui/forms_dropdown_test.go
Normal file
221
go/webui/forms_dropdown_test.go
Normal 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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user