diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index e28bad14..5ea4b711 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -92,6 +92,16 @@ 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 with `ui.NewText`, `ui.NewPassword`, `ui.NewTextarea`, + `ui.NewCheckbox`, `ui.NewRadio`, `ui.NewNumber`, `ui.NewRange`, and `ui.NewDate` + must give `Element.ApplyGetter` a dirty target that successfully expands to at + least one stable usable key. Prefer `bind.New`; otherwise use a pointer-valued + custom setter or implement `JawsGetTag`. A `JawsGetTag` result takes precedence + over the setter's own identity. +- Input widgets retain only that setter-derived target for post-set dirtying. An + explicit render-param tag registers the Element but does not substitute for it; + without the setter-derived target, rejected or normalized browser values are not + automatically reconciled. ### When to use which diff --git a/README.md b/README.md index 10072bcf..319d5308 100644 --- a/README.md +++ b/README.md @@ -601,6 +601,71 @@ 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` +must also provide a stable dirty target. During rendering, the widget retains the +target derived from the setter. After each set result that does not match +`jaws.ErrValueUnchanged`, it dirties that target so the authoritative server +value can reconcile rejected or normalized browser input. `bind.New(&mu, &value)` +is the usual choice because it exposes the backing pointer. A custom setter can +instead be passed as a stable pointer value or implement `JawsGetTag` and return a +target that successfully expands to at least one stable usable key. When a setter +implements `JawsGetTag`, that result takes precedence over the setter's own +identity. + +For example, this validating setter contains a slice and is not comparable as a +value, so `JawsGetTag` exposes its backing value pointer: + +```go +var errValueNotAllowed = errors.New("value not allowed") + +type validatedText struct { + mu *sync.RWMutex + value *string + allowed []string // immutable +} + +func (s validatedText) JawsGet(*jaws.Element) (value string) { + s.mu.RLock() + value = *s.value + s.mu.RUnlock() + return +} + +func (s validatedText) JawsSet(_ *jaws.Element, value string) (err error) { + if !slices.Contains(s.allowed, value) { + err = errValueNotAllowed + return + } + s.mu.Lock() + if *s.value == value { + err = jaws.ErrValueUnchanged + } else { + *s.value = value + } + s.mu.Unlock() + return +} + +func (s validatedText) JawsGetTag() any { return s.value } + +func newStatusInput(mu *sync.RWMutex, value *string) *ui.Text { + return ui.NewText(validatedText{ + mu: mu, + value: value, + allowed: []string{"draft", "published"}, + }) +} +``` + +Without a setter-derived dirty target, input events still reach `JawsSet`, but a +rejected value can remain visible in the browser and a normalized accepted value +is not reflected automatically. An explicit tag passed as a widget rendering +parameter does not substitute for the setter-derived target: it registers the +Element for external dirtying, but the input does not retain it for post-set +dirtying. 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 3d1ba6c3..5b5058eb 100644 --- a/lib/ui/README.md +++ b/lib/ui/README.md @@ -266,7 +266,9 @@ Each base handles: - tracking last rendered value - receiving `what.Input` -- applying dirty tags on successful set +- retaining the setter-derived tag for reconciliation after every set result + that does not match `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..3a966977 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. +// +// g must meet the post-set dirty-tag requirement documented 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..10939b73 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. // +// g must meet the post-set dirty-tag requirement documented 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 1206ec77..8896c454 100644 --- a/lib/ui/input_widgets.go +++ b/lib/ui/input_widgets.go @@ -21,6 +21,24 @@ import ( // [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. +// +// During rendering, Input retains the dirty target [jaws.Element.ApplyGetter] +// derives from the bound setter. After [bind.Setter.JawsSet] returns a result that +// does not match [jaws.ErrValueUnchanged], Input dirties that target so an update +// can read the server value back and reconcile the browser. A writable setter must +// therefore produce a target that expands to at least one stable usable key. If +// the setter implements [github.com/linkdata/jaws/lib/tag.TagGetter], its +// JawsGetTag result is used; otherwise the setter's dynamic value must be +// comparable at runtime and equal to itself, typically a pointer. The selected +// target must expand successfully through +// [github.com/linkdata/jaws/lib/tag.TagExpand]. [bind.New] provides the backing +// pointer as its target. +// +// If rendering registers no setter-derived keys, browser input events still reach +// JawsSet, but a rejected or normalized value is not automatically reconciled and +// can remain visible while server state differs. A tag supplied separately in +// render params registers the Element for that tag, but Input does not retain it +// as its post-set dirty target. 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..bda863d7 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. // +// g must meet the post-set dirty-tag requirement documented 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..4c2d7e67 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. +// +// g must meet the post-set dirty-tag requirement documented 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..fcd28e31 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. +// +// g must meet the post-set dirty-tag requirement documented 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..11514839 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. // +// g must meet the post-set dirty-tag requirement documented 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..d666be70 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. +// +// g must meet the post-set dirty-tag requirement documented 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..8a67a850 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. +// +// g must meet the post-set dirty-tag requirement documented by [Input]. func NewTextarea(g bind.Setter[string]) *Textarea { return &Textarea{InputText{Setter: g}} } // JawsRender renders ui as an HTML textarea.