From 08b7dbd17f97231b8452b5031f40d3811c2b51bb Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:44:58 -1000 Subject: [PATCH] feat: add WithDebounce and WithThrottle action trigger options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify event attribute construction through buildAttrKey() so debounce, throttle, and window modifiers compose cleanly. OnChange no longer hardcodes a 200ms debounce — callers opt in explicitly. --- actiontrigger.go | 64 +++++++++++++++++++++------- via_test.go | 108 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 16 deletions(-) diff --git a/actiontrigger.go b/actiontrigger.go index 17309c8..8863481 100644 --- a/actiontrigger.go +++ b/actiontrigger.go @@ -3,6 +3,7 @@ package via import ( "fmt" "strconv" + "time" "github.com/ryanhamamura/via/h" ) @@ -23,6 +24,8 @@ type triggerOpts struct { value string window bool preventDefault bool + debounce time.Duration + throttle time.Duration } type withSignalOpt struct { @@ -58,6 +61,41 @@ func WithPreventDefault() ActionTriggerOption { return withPreventDefaultOpt{} } +type withDebounceOpt struct{ d time.Duration } + +func (o withDebounceOpt) apply(opts *triggerOpts) { opts.debounce = o.d } + +// WithDebounce adds a debounce modifier to the event trigger. +func WithDebounce(d time.Duration) ActionTriggerOption { return withDebounceOpt{d} } + +type withThrottleOpt struct{ d time.Duration } + +func (o withThrottleOpt) apply(opts *triggerOpts) { opts.throttle = o.d } + +// WithThrottle adds a throttle modifier to the event trigger. +func WithThrottle(d time.Duration) ActionTriggerOption { return withThrottleOpt{d} } + +// formatDuration renders a duration as e.g. "200ms" for Datastar modifiers. +func formatDuration(d time.Duration) string { + return fmt.Sprintf("%dms", d.Milliseconds()) +} + +// buildAttrKey constructs a Datastar attribute key with modifiers. +// Order: event → debounce/throttle → window. +func buildAttrKey(event string, opts *triggerOpts) string { + key := "on:" + event + if opts.debounce > 0 { + key += "__debounce." + formatDuration(opts.debounce) + } + if opts.throttle > 0 { + key += "__throttle." + formatDuration(opts.throttle) + } + if opts.window { + key += "__window" + } + return key +} + // WithSignal sets a signal value before triggering the action. func WithSignal(sig *signal, value string) ActionTriggerOption { return withSignalOpt{ @@ -97,62 +135,62 @@ func actionURL(id string) string { // to element nodes in a view. func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("click", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnChange returns a via.h DOM attribute that triggers on input change. It can be added // to element nodes in a view. func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("change", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnSubmit returns a via.h DOM attribute that triggers on form submit. func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:submit", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("submit", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnInput returns a via.h DOM attribute that triggers on input (without debounce). func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:input", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("input", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnFocus returns a via.h DOM attribute that triggers when the element gains focus. func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:focus", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("focus", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnBlur returns a via.h DOM attribute that triggers when the element loses focus. func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:blur", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("blur", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element. func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:mouseenter", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("mouseenter", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element. func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:mouseleave", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("mouseleave", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnScroll returns a via.h DOM attribute that triggers on scroll. func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:scroll", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("scroll", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnDblClick returns a via.h DOM attribute that triggers on double click. func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H { opts := applyOptions(options...) - return h.Data("on:dblclick", buildOnExpr(actionURL(a.id), &opts)) + return h.Data(buildAttrKey("dblclick", &opts), buildOnExpr(actionURL(a.id), &opts)) } // OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed. @@ -164,11 +202,7 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h. if key != "" { condition = fmt.Sprintf("evt.key==='%s' &&", key) } - attrName := "on:keydown" - if opts.window { - attrName = "on:keydown__window" - } - return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts))) + return h.Data(buildAttrKey("keydown", &opts), fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts))) } // KeyBinding pairs a key with an action and per-binding options. diff --git a/via_test.go b/via_test.go index 8f22771..aa0c52e 100644 --- a/via_test.go +++ b/via_test.go @@ -127,7 +127,7 @@ func TestAction(t *testing.T) { v.mux.ServeHTTP(w, req) body := w.Body.String() assert.Contains(t, body, "data-on:click") - assert.Contains(t, body, "data-on:change__debounce.200ms") + assert.Contains(t, body, "data-on:change") assert.Contains(t, body, "data-on:keydown") assert.Contains(t, body, "/_action/") } @@ -281,6 +281,112 @@ func TestOnKeyDownMap(t *testing.T) { }) } +func TestFormatDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {200 * time.Millisecond, "200ms"}, + {1 * time.Second, "1000ms"}, + {50 * time.Millisecond, "50ms"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, formatDuration(tt.d)) + } +} + +func TestBuildAttrKey(t *testing.T) { + tests := []struct { + name string + event string + opts triggerOpts + want string + }{ + {"bare event", "click", triggerOpts{}, "on:click"}, + {"debounce only", "change", triggerOpts{debounce: 200 * time.Millisecond}, "on:change__debounce.200ms"}, + {"throttle only", "scroll", triggerOpts{throttle: 100 * time.Millisecond}, "on:scroll__throttle.100ms"}, + {"window only", "keydown", triggerOpts{window: true}, "on:keydown__window"}, + {"debounce + window", "input", triggerOpts{debounce: 300 * time.Millisecond, window: true}, "on:input__debounce.300ms__window"}, + {"throttle + window", "scroll", triggerOpts{throttle: 500 * time.Millisecond, window: true}, "on:scroll__throttle.500ms__window"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, buildAttrKey(tt.event, &tt.opts)) + }) + } +} + +func TestWithDebounce(t *testing.T) { + var trigger *actionTrigger + v := New() + v.Page("/", func(c *Context) { + trigger = c.Action(func() {}) + c.View(func() h.H { + return h.Button(trigger.OnClick(WithDebounce(300 * time.Millisecond))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + v.mux.ServeHTTP(w, req) + body := w.Body.String() + assert.Contains(t, body, "data-on:click__debounce.300ms") + assert.Contains(t, body, "/_action/"+trigger.id) +} + +func TestWithThrottle(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.OnScroll(WithThrottle(100 * time.Millisecond))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + v.mux.ServeHTTP(w, req) + body := w.Body.String() + assert.Contains(t, body, "data-on:scroll__throttle.100ms") + assert.Contains(t, body, "/_action/"+trigger.id) +} + +func TestWithDebounceOnChange(t *testing.T) { + var trigger *actionTrigger + v := New() + v.Page("/", func(c *Context) { + trigger = c.Action(func() {}) + c.View(func() h.H { + return h.Input(trigger.OnChange(WithDebounce(200 * time.Millisecond))) + }) + }) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + v.mux.ServeHTTP(w, req) + body := w.Body.String() + assert.Contains(t, body, "data-on:change__debounce.200ms") + assert.Contains(t, body, "/_action/"+trigger.id) +} + +func TestDebounceWithWindow(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", WithDebounce(150*time.Millisecond), 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__debounce.150ms__window") +} + func TestConfig(t *testing.T) { v := New() v.Config(Options{DocumentTitle: "Test"})