3 Commits

Author SHA1 Message Date
Ryan Hamamura
53e5733100 feat: add keyboard grid example
8x8 grid game demonstrating OnKeyDownMap with WASD and arrow key
bindings. Arrow keys use WithPreventDefault to avoid page scrolling.
2026-02-02 08:58:03 -10:00
Ryan Hamamura
11543947bd feat: add OnKeyDownMap and WithWindow for combined key bindings
Add window-scoped keydown dispatching with per-key signal and
preventDefault options. Use comma operator instead of semicolons
in generated ternary expressions to produce valid JavaScript.
2026-02-02 08:57:59 -10:00
Ryan Hamamura
e79bb0e1b0 Revert "feat: add OnKeyDownMap and WithWindow for combined key bindings"
This reverts commit d1e8e3a2ed.
2026-02-02 08:27:07 -10:00
3 changed files with 226 additions and 27 deletions

View File

@@ -3,7 +3,6 @@ package via
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -23,15 +22,9 @@ type triggerOpts struct {
signalID string signalID string
value string value string
window bool window bool
preventDefault bool
} }
type withWindowOpt struct{}
func (o withWindowOpt) apply(opts *triggerOpts) { opts.window = true }
// WithWindow scopes the event listener to the window instead of the element.
func WithWindow() ActionTriggerOption { return withWindowOpt{} }
type withSignalOpt struct { type withSignalOpt struct {
signalID string signalID string
value string value string
@@ -43,6 +36,28 @@ func (o withSignalOpt) apply(opts *triggerOpts) {
opts.value = o.value opts.value = o.value
} }
type withWindowOpt struct{}
func (o withWindowOpt) apply(opts *triggerOpts) {
opts.window = true
}
// WithWindow makes the event listener attach to the window instead of the element.
func WithWindow() ActionTriggerOption {
return withWindowOpt{}
}
type withPreventDefaultOpt struct{}
func (o withPreventDefaultOpt) apply(opts *triggerOpts) {
opts.preventDefault = true
}
// WithPreventDefault calls evt.preventDefault() for matched keys.
func WithPreventDefault() ActionTriggerOption {
return withPreventDefaultOpt{}
}
// WithSignal sets a signal value before triggering the action. // WithSignal sets a signal value before triggering the action.
func WithSignal(sig *signal, value string) ActionTriggerOption { func WithSignal(sig *signal, value string) ActionTriggerOption {
return withSignalOpt{ return withSignalOpt{
@@ -63,7 +78,7 @@ func buildOnExpr(base string, opts *triggerOpts) string {
if !opts.hasSignal { if !opts.hasSignal {
return base return base
} }
return fmt.Sprintf("$%s=%s;%s", opts.signalID, opts.value, base) return fmt.Sprintf("$%s=%s,%s", opts.signalID, opts.value, base)
} }
func applyOptions(options ...ActionTriggerOption) triggerOpts { func applyOptions(options ...ActionTriggerOption) triggerOpts {
@@ -108,27 +123,42 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts))) return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
} }
// KeyBinding pairs a key name with action trigger options for use with OnKeyDownMap. // KeyBinding pairs a key with an action and per-binding options.
type KeyBinding struct { type KeyBinding struct {
Key string Key string
Action *actionTrigger
Options []ActionTriggerOption Options []ActionTriggerOption
} }
// KeyBind creates a KeyBinding for use with OnKeyDownMap. // KeyBind creates a KeyBinding for use with OnKeyDownMap.
func KeyBind(key string, options ...ActionTriggerOption) KeyBinding { func KeyBind(key string, action *actionTrigger, options ...ActionTriggerOption) KeyBinding {
return KeyBinding{Key: key, Options: options} return KeyBinding{Key: key, Action: action, Options: options}
} }
// OnKeyDownMap combines multiple key bindings into a single data-on:keydown__window // OnKeyDownMap produces a single window-scoped keydown attribute that dispatches
// attribute using a JS ternary chain. This avoids HTML attribute deduplication issues // to different actions based on the pressed key. Each binding can reference a
// that occur when multiple OnKeyDown calls target the same element. // different action and carry its own signal/preventDefault options.
func (a *actionTrigger) OnKeyDownMap(bindings ...KeyBinding) h.H { func OnKeyDownMap(bindings ...KeyBinding) h.H {
var parts []string if len(bindings) == 0 {
for _, b := range bindings { return nil
opts := applyOptions(b.Options...)
expr := buildOnExpr(actionURL(a.id), &opts)
parts = append(parts, fmt.Sprintf("evt.key==='%s' ? (%s)", b.Key, expr))
} }
combined := strings.Join(parts, " : ") + " : void 0"
return h.Data("on:keydown__window", combined) expr := ""
for i, b := range bindings {
opts := applyOptions(b.Options...)
branch := ""
if opts.preventDefault {
branch = "evt.preventDefault(),"
}
branch += buildOnExpr(actionURL(b.Action.id), &opts)
if i > 0 {
expr += " : "
}
expr += fmt.Sprintf("evt.key==='%s' ? (%s)", b.Key, branch)
}
expr += " : void 0"
return h.Data("on:keydown__window", expr)
} }

View File

@@ -0,0 +1,74 @@
package main
import (
"fmt"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
const gridSize = 8
func main() {
v := via.New()
v.Config(via.Options{DocumentTitle: "Keyboard", ServerAddress: ":7331"})
v.Page("/", func(c *via.Context) {
x, y := 0, 0
dir := c.Signal("")
move := c.Action(func() {
switch dir.String() {
case "up":
y = max(0, y-1)
case "down":
y = min(gridSize-1, y+1)
case "left":
x = max(0, x-1)
case "right":
x = min(gridSize-1, x+1)
}
c.Sync()
})
c.View(func() h.H {
var rows []h.H
for row := range gridSize {
var cells []h.H
for col := range gridSize {
bg := "#e0e0e0"
if col == x && row == y {
bg = "#4a90d9"
}
cells = append(cells, h.Div(
h.Attr("style", fmt.Sprintf(
"width:48px;height:48px;background:%s;border:1px solid #ccc;",
bg,
)),
))
}
rows = append(rows, h.Div(
append([]h.H{h.Attr("style", "display:flex;")}, cells...)...,
))
}
return h.Div(
h.H1(h.Text("Keyboard Grid")),
h.P(h.Text("Move with WASD or arrow keys")),
h.Div(rows...),
via.OnKeyDownMap(
via.KeyBind("w", move, via.WithSignal(dir, "up")),
via.KeyBind("a", move, via.WithSignal(dir, "left")),
via.KeyBind("s", move, via.WithSignal(dir, "down")),
via.KeyBind("d", move, via.WithSignal(dir, "right")),
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
via.KeyBind("ArrowLeft", move, via.WithSignal(dir, "left"), via.WithPreventDefault()),
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
via.KeyBind("ArrowRight", move, via.WithSignal(dir, "right"), via.WithPreventDefault()),
),
)
})
})
v.Start()
}

View File

@@ -128,6 +128,101 @@ func TestAction(t *testing.T) {
assert.Contains(t, body, "/_action/") assert.Contains(t, body, "/_action/")
} }
func TestOnKeyDownWithWindow(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Div(trigger.OnKeyDown("Enter", WithWindow()))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:keydown__window")
assert.Contains(t, body, "evt.key==='Enter'")
}
func TestOnKeyDownMap(t *testing.T) {
t.Run("multiple bindings with different actions", func(t *testing.T) {
var move, shoot *actionTrigger
var dir *signal
v := New()
v.Page("/", func(c *Context) {
dir = c.Signal("none")
move = c.Action(func() {})
shoot = c.Action(func() {})
c.View(func() h.H {
return h.Div(
OnKeyDownMap(
KeyBind("w", move, WithSignal(dir, "up")),
KeyBind("ArrowUp", move, WithSignal(dir, "up"), WithPreventDefault()),
KeyBind(" ", shoot, WithPreventDefault()),
),
)
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
// Single attribute, window-scoped
assert.Contains(t, body, "data-on:keydown__window")
// Key dispatching
assert.Contains(t, body, "evt.key==='w'")
assert.Contains(t, body, "evt.key==='ArrowUp'")
assert.Contains(t, body, "evt.key===' '")
// Different actions referenced
assert.Contains(t, body, "/_action/"+move.id)
assert.Contains(t, body, "/_action/"+shoot.id)
// preventDefault only on ArrowUp and space branches
assert.Contains(t, body, "evt.key==='ArrowUp' ? (evt.preventDefault()")
assert.Contains(t, body, "evt.key===' ' ? (evt.preventDefault()")
// 'w' branch should NOT have preventDefault
assert.NotContains(t, body, "evt.key==='w' ? (evt.preventDefault()")
})
t.Run("WithSignal per binding", func(t *testing.T) {
var move *actionTrigger
var dir *signal
v := New()
v.Page("/", func(c *Context) {
dir = c.Signal("none")
move = c.Action(func() {})
c.View(func() h.H {
return h.Div(
OnKeyDownMap(
KeyBind("w", move, WithSignal(dir, "up")),
KeyBind("s", move, WithSignal(dir, "down")),
),
)
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "$"+dir.ID()+"='up'")
assert.Contains(t, body, "$"+dir.ID()+"='down'")
})
t.Run("empty bindings returns nil", func(t *testing.T) {
result := OnKeyDownMap()
assert.Nil(t, result)
})
}
func TestConfig(t *testing.T) { func TestConfig(t *testing.T) {
v := New() v := New()
v.Config(Options{DocumentTitle: "Test"}) v.Config(Options{DocumentTitle: "Test"})