From 06602a6a4303e6a7175a23a3fb75f5bbd51f902c Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 6 Aug 2026 21:47:12 +0200 Subject: [PATCH] docs(ui): document input setter dirty targets --- .agents/skills/jaws/SKILL.md | 8 ++++++ README.md | 49 ++++++++++++++++++++++++++++++++++++ lib/ui/README.md | 4 ++- lib/ui/checkbox.go | 2 ++ lib/ui/date.go | 2 ++ lib/ui/input_widgets.go | 12 ++++++++- lib/ui/number.go | 2 ++ lib/ui/password.go | 2 ++ lib/ui/radio.go | 2 ++ lib/ui/range.go | 2 ++ lib/ui/text.go | 2 ++ lib/ui/textarea.go | 2 ++ 12 files changed, 87 insertions(+), 2 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index 79c12bcd..624b2b3c 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -92,6 +92,14 @@ These are the two usual building blocks for widget handlers passed to `$.Button` - `.Clicked(fn)` / `.ContextMenu(fn)` — attach click/context handlers to the same bound variable. - `.InitialHTMLAttr(fn)` — attach attribute hooks. - Use `bind.New` for input widgets and for content whose natural key is the backing variable. Multiple widgets bound to the same pointer share a tag automatically, so `Request.Dirty(&field)` refreshes all of them. +- Writable setters used by `ui.Input`-based widgets require a stable target + accepted by `Element.ApplyGetter` for post-set reconciliation. `bind.New` + supplies its backing pointer; otherwise use a pointer-valued setter or + `JawsGetTag` returning at least one stable, usable key. A `JawsGetTag` result + takes precedence over the setter's identity. +- Render-param tags register the Element but do not replace that setter-derived + target. Without a valid target, rejected or normalized browser values are not + automatically reconciled. ### When to use which diff --git a/README.md b/README.md index 10072bcf..76ecee41 100644 --- a/README.md +++ b/README.md @@ -601,6 +601,55 @@ Since all data access need to be protected with locks, you will usually use `bin that combines a (RW)Locker and a pointer to a value of type `T`. It also allows you to add chained setters, getters and on-success handlers. +Writable setters used with `ui.NewText`, `ui.NewPassword`, `ui.NewTextarea`, +`ui.NewCheckbox`, `ui.NewRadio`, `ui.NewNumber`, `ui.NewRange`, and `ui.NewDate` +need a stable dirty target derived from the setter. After each `JawsSet` result +that does not match `jaws.ErrValueUnchanged`, the widget dirties that target so +the server value can reconcile rejected or normalized browser input. +`bind.New(&mu, &value)` exposes the backing pointer. A custom setter can instead +be pointer-valued or implement `JawsGetTag` and return a target that expands to +at least one stable, usable key; `JawsGetTag` takes precedence over the setter's +identity. Tags passed as render parameters register the Element but do not +replace its setter-derived dirty target. + +This validator rejects forbidden username characters while accepting ordinary +incremental edits. Its slice makes the setter value non-comparable, so +`JawsGetTag` exposes the backing pointer: + +```go +type validatedText struct { + bind.Setter[string] + dirtyTag *string + forbidden []rune // immutable +} + +func (s validatedText) JawsSet(elem *jaws.Element, value string) (err error) { + for _, r := range value { + if slices.Contains(s.forbidden, r) { + err = errors.New("value contains a forbidden character") + return + } + } + err = s.Setter.JawsSet(elem, value) + return +} + +func (s validatedText) JawsGetTag() any { return s.dirtyTag } + +func newUsernameInput(mu *sync.RWMutex, value *string) *ui.Text { + return ui.NewText(validatedText{ + Setter: bind.New(mu, value), + dirtyTag: value, + forbidden: []rune{' ', '/', '\\'}, + }) +} +``` + +Without a valid setter-derived target, rejected or normalized browser values are +not automatically reconciled. See +[`ui.Input`](https://pkg.go.dev/github.com/linkdata/jaws/lib/ui#Input) for the +complete contract. + ### Session handling JaWS has non-persistent session handling integrated. Sessions won't diff --git a/lib/ui/README.md b/lib/ui/README.md index 4fc36c35..5a8319ff 100644 --- a/lib/ui/README.md +++ b/lib/ui/README.md @@ -247,7 +247,9 @@ Each base handles: - tracking last rendered value - receiving `what.Input` -- applying dirty tags on successful set +- retaining and dirtying the setter-derived tag after any set result other than + `jaws.ErrValueUnchanged`; see + [`Input`](https://pkg.go.dev/github.com/linkdata/jaws/lib/ui#Input) - update-driven `SetValue` pushes ## Adding a container widget diff --git a/lib/ui/checkbox.go b/lib/ui/checkbox.go index a5e8e88d..d8d9cb6c 100644 --- a/lib/ui/checkbox.go +++ b/lib/ui/checkbox.go @@ -14,6 +14,8 @@ import ( type Checkbox struct{ InputBool } // NewCheckbox returns a checkbox input widget bound to g. +// +// For writable use, g must provide the setter-derived dirty target described by [Input]. func NewCheckbox(g bind.Setter[bool]) *Checkbox { return &Checkbox{InputBool{Setter: g}} } // JawsRender renders ui as an HTML checkbox input. diff --git a/lib/ui/date.go b/lib/ui/date.go index 63ec3749..48d8143c 100644 --- a/lib/ui/date.go +++ b/lib/ui/date.go @@ -20,6 +20,8 @@ type Date struct{ InputDate } // NewDate returns a date input widget bound to g. // +// For writable use, g must provide the setter-derived dirty target described by [Input]. +// // The widget is date-only; see [InputDate.JawsInput] for how a browser edit // normalizes the bound [time.Time] to midnight UTC and which years round-trip. func NewDate(g bind.Setter[time.Time]) *Date { return &Date{InputDate{Setter: g}} } diff --git a/lib/ui/input_widgets.go b/lib/ui/input_widgets.go index cc406259..80d7c0a4 100644 --- a/lib/ui/input_widgets.go +++ b/lib/ui/input_widgets.go @@ -17,10 +17,20 @@ import ( // Input stores common state for interactive input widgets. // -// An Input value retains the last browser value and dirty tag for one live +// An Input value retains the last browser value and dirty target for one live // [jaws.Element]. A widget embedding Input must therefore back at most one live // Element. To render the same bound state more than once, construct distinct // widgets that share the setter. +// +// For post-set reconciliation, a writable setter must expose at least one stable, +// usable tag through [jaws.Element.ApplyGetter]. [bind.New] exposes its backing +// pointer. +// +// After [bind.Setter.JawsSet] returns a result that does not match +// [jaws.ErrValueUnchanged], Input dirties the setter-derived tag so the server +// value can reconcile rejected or normalized browser input. Tags supplied as +// render parameters register the Element but do not replace that dirty target. +// Without a valid setter-derived target, automatic reconciliation does not occur. type Input struct { // tag is the dirty tag, written once during render and read on the event // goroutine (JawsInput). The render-completes-before-events lifecycle makes diff --git a/lib/ui/number.go b/lib/ui/number.go index 5073e7e5..66ff5178 100644 --- a/lib/ui/number.go +++ b/lib/ui/number.go @@ -15,6 +15,8 @@ type Number struct{ InputFloat } // NewNumber returns a number input widget bound to g. // +// For writable use, g must provide the setter-derived dirty target described by [Input]. +// // The bound value must be finite. A non-finite value (NaN or ±Inf) has no valid // rendering or wire representation, so rendering, updating, or receiving one from // the browser cancels the [jaws.Request] with a cause wrapping diff --git a/lib/ui/password.go b/lib/ui/password.go index 8f2d97a3..2a30cab2 100644 --- a/lib/ui/password.go +++ b/lib/ui/password.go @@ -14,6 +14,8 @@ import ( type Password struct{ InputText } // NewPassword returns a password input widget bound to g. +// +// For writable use, g must provide the setter-derived dirty target described by [Input]. func NewPassword(g bind.Setter[string]) *Password { return &Password{InputText{Setter: g}} } // JawsRender renders ui as an HTML password input. diff --git a/lib/ui/radio.go b/lib/ui/radio.go index f2f1e4bd..9dc96e35 100644 --- a/lib/ui/radio.go +++ b/lib/ui/radio.go @@ -14,6 +14,8 @@ import ( type Radio struct{ InputBool } // NewRadio returns a radio input widget bound to g. +// +// For writable use, g must provide the setter-derived dirty target described by [Input]. func NewRadio(g bind.Setter[bool]) *Radio { return &Radio{InputBool{Setter: g}} } // JawsRender renders ui as an HTML radio input. diff --git a/lib/ui/range.go b/lib/ui/range.go index 753163f8..05d0f29b 100644 --- a/lib/ui/range.go +++ b/lib/ui/range.go @@ -15,6 +15,8 @@ type Range struct{ InputFloat } // NewRange returns a range input widget bound to g. // +// For writable use, g must provide the setter-derived dirty target described by [Input]. +// // The bound value must be finite. A non-finite value (NaN or ±Inf) has no valid // rendering or wire representation, so rendering, updating, or receiving one from // the browser cancels the [jaws.Request] with a cause wrapping diff --git a/lib/ui/text.go b/lib/ui/text.go index 2f003e20..d8fd8ae1 100644 --- a/lib/ui/text.go +++ b/lib/ui/text.go @@ -14,6 +14,8 @@ import ( type Text struct{ InputText } // NewText returns a text input widget bound to g. +// +// For writable use, g must provide the setter-derived dirty target described by [Input]. func NewText(g bind.Setter[string]) *Text { return &Text{InputText{Setter: g}} } // JawsRender renders ui as an HTML text input. diff --git a/lib/ui/textarea.go b/lib/ui/textarea.go index 47486cae..85d7e9dc 100644 --- a/lib/ui/textarea.go +++ b/lib/ui/textarea.go @@ -16,6 +16,8 @@ import ( type Textarea struct{ InputText } // NewTextarea returns a textarea widget bound to g. +// +// For writable use, g must provide the setter-derived dirty target described by [Input]. func NewTextarea(g bind.Setter[string]) *Textarea { return &Textarea{InputText{Setter: g}} } // JawsRender renders ui as an HTML textarea.