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.
This commit is contained in:
@@ -21,6 +21,8 @@ type triggerOpts struct {
|
||||
hasSignal bool
|
||||
signalID string
|
||||
value string
|
||||
window bool
|
||||
preventDefault bool
|
||||
}
|
||||
|
||||
type withSignalOpt struct {
|
||||
@@ -34,6 +36,28 @@ func (o withSignalOpt) apply(opts *triggerOpts) {
|
||||
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.
|
||||
func WithSignal(sig *signal, value string) ActionTriggerOption {
|
||||
return withSignalOpt{
|
||||
@@ -54,7 +78,7 @@ func buildOnExpr(base string, opts *triggerOpts) string {
|
||||
if !opts.hasSignal {
|
||||
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 {
|
||||
@@ -92,5 +116,49 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
|
||||
if key != "" {
|
||||
condition = fmt.Sprintf("evt.key==='%s' &&", key)
|
||||
}
|
||||
return h.Data("on:keydown", fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
||||
attrName := "on:keydown"
|
||||
if opts.window {
|
||||
attrName = "on:keydown__window"
|
||||
}
|
||||
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
||||
}
|
||||
|
||||
// KeyBinding pairs a key with an action and per-binding options.
|
||||
type KeyBinding struct {
|
||||
Key string
|
||||
Action *actionTrigger
|
||||
Options []ActionTriggerOption
|
||||
}
|
||||
|
||||
// KeyBind creates a KeyBinding for use with OnKeyDownMap.
|
||||
func KeyBind(key string, action *actionTrigger, options ...ActionTriggerOption) KeyBinding {
|
||||
return KeyBinding{Key: key, Action: action, Options: options}
|
||||
}
|
||||
|
||||
// OnKeyDownMap produces a single window-scoped keydown attribute that dispatches
|
||||
// to different actions based on the pressed key. Each binding can reference a
|
||||
// different action and carry its own signal/preventDefault options.
|
||||
func OnKeyDownMap(bindings ...KeyBinding) h.H {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
95
via_test.go
95
via_test.go
@@ -128,6 +128,101 @@ func TestAction(t *testing.T) {
|
||||
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) {
|
||||
v := New()
|
||||
v.Config(Options{DocumentTitle: "Test"})
|
||||
|
||||
Reference in New Issue
Block a user