feat: add WithDebounce and WithThrottle action trigger options
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
108
via_test.go
108
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"})
|
||||
|
||||
Reference in New Issue
Block a user