Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions lib/ui/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,10 @@ precedence over setter identity.

Tags passed as render params register dependencies but do not replace the
source-derived target. Editable Number and Range fail rendering without a usable
target. The reusable nonnumeric input bases render but cannot automatically
restore rejected or normalized browser values without one.
target. `InputText`, `InputBool`, and `InputDate` record their setter-derived
target when `RenderInput` renders an input. Their promoted `JawsInput` methods
use that target to reconcile rejected or normalized browser values. Without a
usable setter-derived target, automatic reconciliation does not occur.

A custom setter containing a slice is not comparable, so expose its synchronized
backing pointer explicitly:
Expand Down Expand Up @@ -445,9 +447,22 @@ func (w *Article) JawsRender(e *jaws.Element, wr io.Writer, params []any) error
}
```

For string, bool, and date controls, embed or compose the corresponding typed
input base. Number and Range are complete widgets rather than reusable numeric
bases because they own parsing, formatting, and event baselines.
For a custom HTML input, embed the matching string, bool, or date base and call
its `RenderInput` method from `JawsRender`. The base supplies `JawsInput` and
`JawsUpdate`:

```go
type Email struct{ ui.InputText }

func (u *Email) JawsRender(e *jaws.Element, w io.Writer, params []any) error {
return u.RenderInput(e, w, "email", params...)
}
```

Construct it with `&Email{InputText: ui.InputText{Setter: bind.New(&mu, &value)}}`.
`RenderInput` emits an `<input>` element; `Textarea` has its own render path.
Number and Range are complete widgets rather than reusable numeric bases
because they own parsing, formatting, and event baselines.

For a container with only a distinct type and tag, embed a Container value. If
extra behavior is needed, keep it in a named field and delegate render and update
Expand Down
2 changes: 1 addition & 1 deletion lib/ui/checkbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func NewCheckbox(g bind.Setter[bool]) *Checkbox { return &Checkbox{InputBool{Set

// JawsRender renders ui as an HTML checkbox input.
func (u *Checkbox) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.renderBoolInput(elem, w, "checkbox", params...)
return u.RenderInput(elem, w, "checkbox", params...)
}

// Checkbox renders an HTML checkbox input.
Expand Down
2 changes: 1 addition & 1 deletion lib/ui/date.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func NewDate(g bind.Setter[time.Time]) *Date { return &Date{InputDate{Setter: g}

// JawsRender renders ui as an HTML date input.
func (u *Date) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.renderDateInput(elem, w, "date", params...)
return u.RenderInput(elem, w, "date", params...)
}

// Date renders an HTML date input.
Expand Down
164 changes: 164 additions & 0 deletions lib/ui/input_external_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package ui_test

import (
"context"
"errors"
"io"
"strings"
"sync"
"testing"
"time"

"github.com/linkdata/jaws"
"github.com/linkdata/jaws/jawstest"
"github.com/linkdata/jaws/lib/bind"
"github.com/linkdata/jaws/lib/ui"
"github.com/linkdata/jaws/lib/what"
)

type rejectingInputBinder[T comparable] struct {
bind.Binder[T]
err error
}

func (b rejectingInputBinder[T]) JawsSet(*jaws.Element, T) error { return b.err }

func newRejectingInputBinder[T comparable](value T, err error) rejectingInputBinder[T] {
var mu sync.Mutex
return rejectingInputBinder[T]{bind.New(&mu, &value), err}
}

type normalizingInputBinder struct{ bind.Binder[string] }

func (b normalizingInputBinder) JawsSet(elem *jaws.Element, value string) error {
return b.Binder.JawsSet(elem, strings.TrimSpace(value))
}

func newNormalizingInputBinder(value string) normalizingInputBinder {
var mu sync.Mutex
return normalizingInputBinder{bind.New(&mu, &value)}
}

type externalEmailInput struct{ ui.InputText }

// JawsRender renders an email input through the embedded string base.
func (u *externalEmailInput) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.RenderInput(elem, w, "email", params...)
}

type externalCheckboxInput struct{ ui.InputBool }

// JawsRender renders a checkbox through the embedded boolean base.
func (u *externalCheckboxInput) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.RenderInput(elem, w, "checkbox", params...)
}

type externalDateInput struct{ ui.InputDate }

// JawsRender renders a date input through the embedded date base.
func (u *externalDateInput) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.RenderInput(elem, w, "date", params...)
}

type externalInputWidget interface {
jaws.UI
jaws.InputHandler
}

func TestExternalInputRenderReconcilesInput(t *testing.T) {
rejected := errors.New("rejected")
tests := []struct {
name string
widget externalInputWidget
htmlType string
initial string
input string
want string
wantErr error
}{
{
name: "string",
widget: &externalEmailInput{ui.InputText{Setter: newRejectingInputBinder("server", rejected)}},
htmlType: "email",
initial: `value="server"`,
input: "typed",
want: "server",
wantErr: rejected,
},
{
name: "normalized string",
widget: &externalEmailInput{ui.InputText{Setter: newNormalizingInputBinder("server")}},
htmlType: "email",
initial: `value="server"`,
input: " next ",
want: "next",
},
{
name: "bool",
widget: &externalCheckboxInput{ui.InputBool{Setter: newRejectingInputBinder(true, rejected)}},
htmlType: "checkbox",
initial: " checked",
input: "false",
want: "true",
wantErr: rejected,
},
{
name: "date",
widget: &externalDateInput{ui.InputDate{Setter: newRejectingInputBinder(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), rejected)}},
htmlType: "date",
initial: `value="2024-01-02"`,
input: "2025-03-04",
want: "2024-01-02",
wantErr: rejected,
},
{
name: "date clear",
widget: &externalDateInput{ui.InputDate{Setter: newRejectingInputBinder(time.Time{}, jaws.ErrValueUnchanged)}},
htmlType: "date",
initial: `value="0001-01-01"`,
input: "",
want: "0001-01-01",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jw, err := jaws.New()
if err != nil {
t.Fatal(err)
}
t.Cleanup(jw.Close)
go jw.Serve()

tr := jawstest.NewTestRequest(jw, nil)
defer tr.Close()
<-tr.ReadyCh

elem := tr.NewElement(tt.widget)
var html strings.Builder
if err := elem.JawsRender(&html, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(html.String(), `type="`+tt.htmlType+`"`) {
t.Fatalf("rendered input = %q, want type %q", html.String(), tt.htmlType)
}
if !strings.Contains(html.String(), tt.initial) {
t.Fatalf("rendered input = %q, want %q", html.String(), tt.initial)
}
if err := tt.widget.JawsInput(elem, tt.input); !errors.Is(err, tt.wantErr) {
t.Fatalf("JawsInput error = %v, want %v", err, tt.wantErr)
}

ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
select {
case msg := <-tr.OutCh:
if msg.What != what.Value || msg.Jid != elem.Jid() || msg.Data != tt.want {
t.Fatalf("update = %#v, want Value %q for %v", msg, tt.want, elem.Jid())
}
case <-ctx.Done():
t.Fatal("no corrective update received")
}
})
}
}
34 changes: 29 additions & 5 deletions lib/ui/input_widgets.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,20 @@ func (u *Input) maybeDirty(elem *jaws.Element, inErr error) (err error) {

// InputText is the reusable base for string input widgets.
//
// A widget embedding InputText must back at most one live [jaws.Element].
// A widget embedding InputText must back at most one live [jaws.Element]. Call
// [InputText.RenderInput] from the embedding widget's JawsRender method.
type InputText struct {
Input
bind.Setter[string]
}

func (u *InputText) renderStringInput(elem *jaws.Element, w io.Writer, htmlType string, params ...any) (err error) {
// RenderInput renders a string-backed HTML <input> element.
//
// It registers the setter through [jaws.Element.ApplyGetter], applies render
// parameters and initial setter attributes, and remembers the rendered value
// for JawsUpdate. With a usable setter tag, JawsInput can reconcile rejected or
// normalized browser values. htmlType becomes the escaped type attribute.
func (u *InputText) RenderInput(elem *jaws.Element, w io.Writer, htmlType string, params ...any) (err error) {
getterAttrs := u.applyGetterAttrs(elem, u.Setter)
attrs := append(elem.ApplyParams(params), getterAttrs...)
v := u.JawsGet(elem)
Expand Down Expand Up @@ -94,13 +101,21 @@ func (u *InputText) JawsInput(elem *jaws.Element, value string) (err error) {

// InputBool is the reusable base for boolean input widgets.
//
// A widget embedding InputBool must back at most one live [jaws.Element].
// A widget embedding InputBool must back at most one live [jaws.Element]. Call
// [InputBool.RenderInput] from the embedding widget's JawsRender method.
type InputBool struct {
Input
bind.Setter[bool]
}

func (u *InputBool) renderBoolInput(elem *jaws.Element, w io.Writer, htmlType string, params ...any) (err error) {
// RenderInput renders a boolean-backed HTML <input> element.
//
// It registers the setter through [jaws.Element.ApplyGetter], applies render
// parameters and initial setter attributes, and remembers the rendered value
// for JawsUpdate. With a usable setter tag, JawsInput can reconcile rejected or
// normalized browser values. Use "checkbox" or "radio" for htmlType so the
// browser sends the checked-state values JawsInput expects.
func (u *InputBool) RenderInput(elem *jaws.Element, w io.Writer, htmlType string, params ...any) (err error) {
getterAttrs := u.applyGetterAttrs(elem, u.Setter)
attrs := append(elem.ApplyParams(params), getterAttrs...)
v := u.JawsGet(elem)
Expand Down Expand Up @@ -149,6 +164,8 @@ func (u *InputBool) JawsInput(elem *jaws.Element, value string) (err error) {
// value's own location, but a browser edit normalizes the bound [time.Time] to
// midnight UTC of the picked date, and only years 1 through 9999 round-trip; see
// [InputDate.JawsInput].
//
// Call [InputDate.RenderInput] from an embedding widget's JawsRender method.
type InputDate struct {
Input
bind.Setter[time.Time]
Expand All @@ -158,7 +175,14 @@ func (u *InputDate) str(v time.Time) string {
return v.Format(assets.ISO8601)
}

func (u *InputDate) renderDateInput(elem *jaws.Element, w io.Writer, htmlType string, params ...any) (err error) {
// RenderInput renders a date-backed HTML <input> element.
//
// It registers the setter through [jaws.Element.ApplyGetter], applies render
// parameters and initial setter attributes, and remembers the rendered value
// for JawsUpdate. With a usable setter tag, JawsInput can reconcile rejected or
// normalized browser values. Use "date" for htmlType: [InputDate.JawsInput]
// parses non-empty values as YYYY-MM-DD.
func (u *InputDate) RenderInput(elem *jaws.Element, w io.Writer, htmlType string, params ...any) (err error) {
getterAttrs := u.applyGetterAttrs(elem, u.Setter)
attrs := append(elem.ApplyParams(params), getterAttrs...)
v := u.JawsGet(elem)
Expand Down
2 changes: 1 addition & 1 deletion lib/ui/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func NewPassword(g bind.Setter[string]) *Password { return &Password{InputText{S

// JawsRender renders ui as an HTML password input.
func (u *Password) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.renderStringInput(elem, w, "password", params...)
return u.RenderInput(elem, w, "password", params...)
}

// Password renders an HTML password input.
Expand Down
2 changes: 1 addition & 1 deletion lib/ui/radio.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func NewRadio(g bind.Setter[bool]) *Radio { return &Radio{InputBool{Setter: g}}

// JawsRender renders ui as an HTML radio input.
func (u *Radio) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.renderBoolInput(elem, w, "radio", params...)
return u.RenderInput(elem, w, "radio", params...)
}

// Radio renders an HTML radio input.
Expand Down
2 changes: 1 addition & 1 deletion lib/ui/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func NewText(g bind.Setter[string]) *Text { return &Text{InputText{Setter: g}} }

// JawsRender renders ui as an HTML text input.
func (u *Text) JawsRender(elem *jaws.Element, w io.Writer, params []any) error {
return u.renderStringInput(elem, w, "text", params...)
return u.RenderInput(elem, w, "text", params...)
}

// Text renders an HTML text input.
Expand Down
Loading