diff --git a/actiontrigger.go b/actiontrigger.go index 65668e4..7f47a4f 100644 --- a/actiontrigger.go +++ b/actiontrigger.go @@ -18,9 +18,11 @@ type ActionTriggerOption interface { } type triggerOpts struct { - hasSignal bool - signalID string - value string + 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) } diff --git a/via_test.go b/via_test.go index d1b1400..4566b1e 100644 --- a/via_test.go +++ b/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"})