From d93015891df0706be4d8f43645133e11ef6f978d Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 2 Oct 2026 19:05:30 +0200 Subject: [PATCH] feat(ui): support native multiple-selection selects --- AI.md | 2 +- lib/assets/AI.md | 6 + lib/assets/jaws.js | 12 ++ lib/assets/js_test.go | 75 ++++++++ lib/named/AI.md | 12 +- lib/named/doc.go | 2 + lib/named/example_test.go | 4 +- lib/named/multiselect_test.go | 100 +++++++++++ lib/named/namedboolarray.go | 52 +++++- lib/named/namedboolarray_race_test.go | 5 +- lib/named/selecthandler.go | 12 ++ lib/ui/AI.md | 52 ++++-- lib/ui/doc.go | 12 +- lib/ui/multiselect.go | 88 ++++++++++ lib/ui/multiselect_test.go | 236 ++++++++++++++++++++++++++ lib/ui/select.go | 6 +- 16 files changed, 638 insertions(+), 38 deletions(-) create mode 100644 lib/named/multiselect_test.go create mode 100644 lib/ui/multiselect.go create mode 100644 lib/ui/multiselect_test.go diff --git a/AI.md b/AI.md index c9a57586..154dfab9 100644 --- a/AI.md +++ b/AI.md @@ -33,7 +33,7 @@ The module contains 16 Go packages, each with one package-local guide: * [`github.com/linkdata/jaws/lib/key`](./lib/key/AI.md) -- request-key encoding and parsing. * [`github.com/linkdata/jaws/lib/named`](./lib/named/AI.md) -- named inputs and - single-select collections. + selection collections. * [`github.com/linkdata/jaws/lib/tag`](./lib/tag/AI.md) -- tag expansion, registration, targeting, and rendering. * [`github.com/linkdata/jaws/lib/templatereloader`](./lib/templatereloader/AI.md) diff --git a/lib/assets/AI.md b/lib/assets/AI.md index fd1dbfd5..9205eeb2 100644 --- a/lib/assets/AI.md +++ b/lib/assets/AI.md @@ -66,6 +66,12 @@ Value updates avoid writes when possible and preserve text selection when a textual value changes by insertion or removal. Managed native form reset is not implemented: it does not generate the per-control events JaWS transports. +Native multiple-selection selects send all `selectedOptions` values as a JSON +string array inside the ordinary Input payload. Their Value payload uses the +same encoding and reconciles every option's live `selected` property, including +clearing all options for an empty array. Single-selection selects retain their +plain string payload. Use `ui.MultiSelect` for the matching server-side API. + `JavascriptText` and `JawsCSS` are immutable embedded strings. `ISO8601` is the browser date-input format. `DefaultCookieName` is computed once at package initialization; `MakeCookieName` keeps ASCII letters and digits from the diff --git a/lib/assets/jaws.js b/lib/assets/jaws.js index d1b7d4ee..0ff488a7 100644 --- a/lib/assets/jaws.js +++ b/lib/assets/jaws.js @@ -140,6 +140,8 @@ function jawsInputHandler(e) { e.stopPropagation(); if (jawsIsCheckable(elem.getAttribute('type'))) { val = elem.checked; + } else if (elem.type === 'select-multiple') { + val = JSON.stringify(Array.from(elem.selectedOptions, option => option.value)); } else { val = elem.value; } @@ -267,6 +269,16 @@ function jawsSetValue(elem, str) { } return; } + if (elemtype === 'select-multiple') { + const values = new Set(JSON.parse(str)); + for (const option of elem.options) { + const selected = values.has(option.value); + if (option.selected !== selected) { + option.selected = selected; + } + } + return; + } if (elem.value === str) { return; } diff --git a/lib/assets/js_test.go b/lib/assets/js_test.go index 0c416ff6..4d7975c2 100644 --- a/lib/assets/js_test.go +++ b/lib/assets/js_test.go @@ -497,6 +497,81 @@ process.stdout.write(JSON.stringify({ } } +func TestJawsJS_MultipleSelect(t *testing.T) { + raw := runJawsJSSnippet(t, ` +const assert = require("node:assert/strict"); +jaws = { readyState: 1, sent: [], send(msg) { this.sent.push(msg); } }; +const options = [ + { value: "first", selected: true }, + { value: "second", selected: false }, + { value: "third\t\n\"\\", selected: true } +]; +const listeners = {}; +const multiple = { + id: "Jid.1", + tagName: "SELECT", + type: "select-multiple", + multiple: true, + options, + get selectedOptions() { return this.options.filter(option => option.selected); }, + get value() { return this.selectedOptions[0]?.value || ""; }, + set value(value) { throw new Error("multiple select must update option.selected"); }, + hasAttribute() { return false; }, + getAttribute() { return null; }, + addEventListener(name, handler) { listeners[name] = handler; } +}; +jawsAttach(multiple); +assert.deepEqual(Object.keys(listeners), ["input"]); +let stopped = 0; +const event = { currentTarget: multiple, stopPropagation() { stopped++; } }; +listeners.input(event); +for (const option of options) option.selected = false; +listeners.input(event); +assert.equal(stopped, 2); + +for (const readyState of [0, 2, 3]) { + jaws.readyState = readyState; + listeners.input(event); +} +assert.equal(jaws.sent.length, 2); +assert.equal(stopped, 2); +jaws.readyState = 1; + +const single = { + id: "Jid.2", tagName: "SELECT", type: "select-one", multiple: false, + value: "second", getAttribute() { return null; } +}; +jawsInputHandler({ currentTarget: single, stopPropagation() {} }); +document.getElementById = id => id === multiple.id ? multiple : single; +jawsPerform("Value", multiple.id, JSON.stringify(JSON.stringify(["second", options[2].value]))); +assert.deepEqual(options.map(option => option.selected), [false, true, true]); +jawsPerform("Value", multiple.id, JSON.stringify(JSON.stringify(["first"]))); +assert.deepEqual(options.map(option => option.selected), [true, false, false]); +jawsPerform("Value", multiple.id, JSON.stringify("[]")); +assert.deepEqual(options.map(option => option.selected), [false, false, false]); +jawsPerform("Value", single.id, JSON.stringify("first")); +assert.equal(single.value, "first"); +process.stdout.write(JSON.stringify(jaws.sent)); +`) + var frames []string + if err := json.Unmarshal([]byte(raw), &frames); err != nil { + t.Fatal(err) + } + want := []string{`["first","third\t\n\"\\"]`, `[]`, "second"} + if len(frames) != len(want) { + t.Fatalf("frames = %q, want %d frames", frames, len(want)) + } + for i, frame := range frames { + msg, ok := wire.Parse([]byte(frame)) + if !ok || msg.What != what.Input || msg.Data != want[i] { + t.Errorf("frame %d = %+v, parseable %t, want Input %q", i, msg, ok, want[i]) + } + if (i < 2 && msg.Jid != 1) || (i == 2 && msg.Jid != 2) { + t.Errorf("frame %d targets unexpected Jid %s", i, msg.Jid) + } + } +} + func TestJawsJS_ClickAndInputRoutesRejectNoncanonicalJids(t *testing.T) { raw := runJawsJSSnippet(t, ` function FakeSocket() { this.readyState = 1; this.sent = []; } diff --git a/lib/named/AI.md b/lib/named/AI.md index 2621682f..caf395ce 100644 --- a/lib/named/AI.md +++ b/lib/named/AI.md @@ -6,9 +6,9 @@ exported symbols. ## Values and trust boundaries -`Bool` and `BoolArray` model named choices used by Select, Option, Checkbox, -Radio, and RadioGroup widgets. A name is a browser form value and must be a -non-empty, valid UTF-8 string without U+0000. The zero `Bool` is therefore not +`Bool` and `BoolArray` model named choices used by Select, MultiSelect, Option, +Checkbox, Radio, and RadioGroup widgets. A name is a browser form value and must +be a non-empty, valid UTF-8 string without U+0000. The zero `Bool` is therefore not ready for use; construct entries with `NewBool` or `BoolArray.Add`. Labels use `template.HTML` and are trusted HTML. Escape user-controlled text @@ -24,6 +24,12 @@ in [bind](../bind/AI.md), [htmlio](../htmlio/AI.md), and [ui](../ui/AI.md). Construct it with `NewBoolArray(false)` or use its zero value. Give every option a distinct name. Multi-select arrays and duplicate `Bool` names are unsupported by `ui.Select` and `ui.RadioGroup`. +- Use `NewBoolArray(true)` for `ui.MultiSelect`, with distinct, non-empty option + names. `JawsGetValues` returns all checked names in array order. + `JawsSetValues` replaces the entire selection, ignores unknown names, and + clears absent names, including all names for nil or empty input. On a + single-select array it selects only the first matching name in array order. + It dirties only changed Bools and their array after releasing value locks. - `Bool.JawsSet` acquires the owning array lock before the value lock, changes the selected value, clears peers when needed, releases value locks, and then dirties the affected `Bool` values and the array. Preserve that order. diff --git a/lib/named/doc.go b/lib/named/doc.go index 3d182c9d..c229ffb8 100644 --- a/lib/named/doc.go +++ b/lib/named/doc.go @@ -8,4 +8,6 @@ // A single-select [BoolArray] is the standard shared selection model for // [github.com/linkdata/jaws/lib/ui.Select] and // [github.com/linkdata/jaws/lib/ui.RequestWriter.RadioGroup]. +// A [BoolArray] created with NewBoolArray(true) implements [MultiSelectHandler] +// for [github.com/linkdata/jaws/lib/ui.MultiSelect]. Use distinct option names. package named diff --git a/lib/named/example_test.go b/lib/named/example_test.go index 4ba2ef19..c0cf3776 100644 --- a/lib/named/example_test.go +++ b/lib/named/example_test.go @@ -31,7 +31,7 @@ func ExampleBoolArray_multiSelect() { choices.Set("red", true) choices.Set("green", true) - fmt.Println(choices.IsChecked("red"), choices.IsChecked("green")) + fmt.Println(choices.JawsGetValues(nil)) - // Output: true true + // Output: [red green] } diff --git a/lib/named/multiselect_test.go b/lib/named/multiselect_test.go new file mode 100644 index 00000000..88686a26 --- /dev/null +++ b/lib/named/multiselect_test.go @@ -0,0 +1,100 @@ +package named + +import ( + "errors" + "slices" + "sync/atomic" + "testing" + "testing/synctest" + + "github.com/linkdata/jaws" +) + +func TestBoolArray_JawsSetValues(t *testing.T) { + for _, tt := range []struct { + name string + names []string + want []string + unchanged bool + }{ + {name: "replace", names: []string{"three", "two"}, want: []string{"two", "three"}}, + {name: "nil clears"}, + {name: "empty clears", names: []string{}}, + {name: "unknown clears", names: []string{"missing"}}, + {name: "unknown ignored", names: []string{"missing", "two"}, want: []string{"two"}}, + {name: "unchanged set", names: []string{"two", "one", "two", "missing"}, want: []string{"one", "two"}, unchanged: true}, + } { + t.Run(tt.name, func(t *testing.T) { + nba := NewBoolArray(true).Add("one", "One").Add("two", "Two").Add("three", "Three") + nba.Set("one", true) + nba.Set("two", true) + before := nba.JawsGetValues(nil) + if !slices.Equal(before, []string{"one", "two"}) { + t.Fatalf("initial values = %v", before) + } + + _, rq := newCoreRequest(t) + err := nba.JawsSetValues(rq.NewElement(noopUI{}), tt.names) + if tt.unchanged { + if !errors.Is(err, jaws.ErrValueUnchanged) { + t.Fatalf("error = %v, want ErrValueUnchanged", err) + } + } else if err != nil { + t.Fatal(err) + } + if got := nba.JawsGetValues(nil); !slices.Equal(got, tt.want) { + t.Fatalf("selected values = %v, want %v", got, tt.want) + } + if !slices.Equal(before, []string{"one", "two"}) { + t.Fatalf("getter result changed: %v", before) + } + }) + } +} + +func TestBoolArray_JawsSetValuesSingleSelect(t *testing.T) { + nba := NewBoolArray(false).Add("one", "One").Add("two", "Two").Add("one", "Another one") + nba.Set("two", true) + _, rq := newCoreRequest(t) + elem := rq.NewElement(noopUI{}) + if err := nba.JawsSetValues(elem, []string{"missing", "two", "one"}); err != nil { + t.Fatal(err) + } + if got := nba.JawsGetValues(nil); !slices.Equal(got, []string{"one", "one"}) { + t.Fatalf("selected values = %v, want both Bools named one", got) + } + if err := nba.JawsSetValues(elem, nil); err != nil { + t.Fatal(err) + } + if got := nba.JawsGetValues(nil); got != nil { + t.Fatalf("cleared values = %v, want nil", got) + } +} + +func TestBoolArray_JawsSetValuesDirtiesOnlyChangedBoolsAndArray(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, rq := newTestRequest(t) + defer closeBubbleRequest(jw, rq) + nba := NewBoolArray(true).Add("one", "One").Add("two", "Two").Add("three", "Three") + nba.Set("one", true) + nba.Set("two", true) + var oneHits, twoHits, threeHits, groupHits atomic.Int32 + registerDirtyProbe(rq, nba.data[0], &oneHits) + registerDirtyProbe(rq, nba.data[1], &twoHits) + registerDirtyProbe(rq, nba.data[2], &threeHits) + registerDirtyProbe(rq, nba, &groupHits) + elem := rq.NewElement(noopUI{}) + if err := nba.JawsSetValues(elem, []string{"two", "three"}); err != nil { + t.Fatal(err) + } + waitForDirtyProbes(t, func() bool { + return oneHits.Load() == 1 && twoHits.Load() == 0 && threeHits.Load() == 1 && groupHits.Load() == 1 + }) + if err := nba.JawsSetValues(elem, []string{"three", "two"}); !errors.Is(err, jaws.ErrValueUnchanged) { + t.Fatalf("repeat error = %v, want ErrValueUnchanged", err) + } + waitForDirtyProbes(t, func() bool { + return oneHits.Load() == 1 && twoHits.Load() == 0 && threeHits.Load() == 1 && groupHits.Load() == 1 + }) + }) +} diff --git a/lib/named/namedboolarray.go b/lib/named/namedboolarray.go index 534376fd..566a0905 100644 --- a/lib/named/namedboolarray.go +++ b/lib/named/namedboolarray.go @@ -22,6 +22,7 @@ type BoolArray struct { } var _ SelectHandler = (*BoolArray)(nil) +var _ MultiSelectHandler = (*BoolArray)(nil) // NewBoolArray returns an empty [BoolArray]. // @@ -29,7 +30,8 @@ var _ SelectHandler = (*BoolArray)(nil) // multi is true, multiple values may be checked at the same time. Pass a // single-select array to [github.com/linkdata/jaws/lib/ui.NewSelect] and // [github.com/linkdata/jaws/lib/ui.RequestWriter.RadioGroup]; neither supports -// multi-select arrays. +// multi-select arrays. Pass a multi-select array to +// [github.com/linkdata/jaws/lib/ui.NewMultiSelect]. func NewBoolArray(multi bool) *BoolArray { return &BoolArray{multi: multi} } @@ -100,8 +102,7 @@ func (nba *BoolArray) JawsContains(elem *jaws.Element) (contents []jaws.UI) { // (e.g. template.HTML(template.HTMLEscapeString(s))) when it is derived from // untrusted user input. See [NewBool]. // -// Use distinct names for options rendered in a single-selection select or -// radio group. +// Use distinct names for options rendered in a select or radio group. func (nba *BoolArray) Add(name string, html template.HTML) *BoolArray { nba.mu.Lock() nba.data = append(nba.data, NewBool(nba, name, html, false)) @@ -152,8 +153,8 @@ func (nba *BoolArray) deselectOthersLocked(name string, deselect bool) (changed // Get returns the name of the first checked [Bool]. // -// It returns an empty string if none are checked. Use [BoolArray.ReadLocked] -// to inspect all checked values when multiple values may be checked. +// It returns an empty string if none are checked. Use [BoolArray.JawsGetValues] +// to read all checked values when multiple values may be checked. func (nba *BoolArray) Get() (name string) { nba.mu.RLock() for _, nb := range nba.data { @@ -222,6 +223,47 @@ func (nba *BoolArray) JawsSet(elem *jaws.Element, name string) error { return dirtyChanged(elem, nba, changed) } +// JawsGetValues returns the names of all checked Bools in array order. +// The returned slice is independent of nba and is nil when nothing is checked. +func (nba *BoolArray) JawsGetValues(elem *jaws.Element) (names []string) { + nba.mu.RLock() + for _, nb := range nba.data { + if nb.Checked() { + names = append(names, nb.Name()) + } + } + nba.mu.RUnlock() + return +} + +// JawsSetValues replaces the selected names and updates affected UI. +// Unknown names are ignored; nil or empty names clear the selection. It returns +// [jaws.ErrValueUnchanged] if no checked state changed. +// +// Use NewBoolArray(true) for multiple selections. In single-select mode, only +// the first matching name in array order is selected, including all its Bools. +func (nba *BoolArray) JawsSetValues(elem *jaws.Element, names []string) error { + selected := make(map[string]bool, len(names)) + for _, name := range names { + selected[name] = true + } + nba.mu.Lock() + var changed []*Bool + var first string + for _, nb := range nba.data { + name := nb.Name() + checked := selected[name] && (nba.multi || first == "" || first == name) + if checked { + first = name + } + if nb.Set(checked) { + changed = append(changed, nb) + } + } + nba.mu.Unlock() + return dirtyChanged(elem, nba, changed) +} + // dirtyChanged updates changed Bools and their array after value locks are released. func dirtyChanged(elem *jaws.Element, nba *BoolArray, changed []*Bool) error { if len(changed) == 0 { diff --git a/lib/named/namedboolarray_race_test.go b/lib/named/namedboolarray_race_test.go index bab7e88b..47e97642 100644 --- a/lib/named/namedboolarray_race_test.go +++ b/lib/named/namedboolarray_race_test.go @@ -37,7 +37,7 @@ func TestBoolArray_ConcurrentLockOrdering(t *testing.T) { // JawsContains, and reads. The locked callbacks touch // only the provided slice and Bool methods, honoring the // non-reentrancy contract. - switch i % 11 { + switch i % 12 { case 0: nba.Set(name, true) case 1: @@ -65,6 +65,9 @@ func TestBoolArray_ConcurrentLockOrdering(t *testing.T) { nba.Add(name, template.HTML(name)) case 10: _ = nba.JawsContains(elem) + case 11: + _ = nba.JawsSetValues(elem, []string{name}) + _ = nba.JawsGetValues(elem) } } }(g) diff --git a/lib/named/selecthandler.go b/lib/named/selecthandler.go index 7b7e2cf7..341aac01 100644 --- a/lib/named/selecthandler.go +++ b/lib/named/selecthandler.go @@ -15,3 +15,15 @@ type SelectHandler interface { jaws.Container bind.Setter[string] } + +// MultiSelectHandler renders multi-select options and stores selected values. +// +// Rendered option values must be distinct and non-empty. JawsGetValues returns +// the selected values; JawsSetValues replaces the entire selection, with nil or +// an empty slice clearing it. [BoolArray] created with NewBoolArray(true) is the +// standard implementation. +type MultiSelectHandler interface { + jaws.Container + JawsGetValues(*jaws.Element) []string + JawsSetValues(*jaws.Element, []string) error +} diff --git a/lib/ui/AI.md b/lib/ui/AI.md index c6811922..dada9719 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -12,7 +12,7 @@ request and session engine. Its primary building blocks are: - `HTMLInner` for elements with dynamic inner HTML; - `Input`, `InputText`, `InputBool`, and `InputDate` for typed control state; - `Number` and `Range` for type-preserving numeric input; -- `Container`, `Tbody`, and `Select` for dynamic child lists; +- `Container`, `Tbody`, `Select`, and `MultiSelect` for dynamic child lists; - `Template`, `Handler`, `With`, and `RequestWriter` for template integration. Use [bind](../bind/AI.md) for value adaptation, [tag](../tag/AI.md) for @@ -36,8 +36,8 @@ following standard widgets support multiple live Elements under the conditions documented on their concrete types: - HTML-inner widgets, Img, and Option retain no Element-specific mutable state; -- Template, Container, Tbody, and Select keep that state in each Element's state - slot rather than on the widget definition. +- Template, Container, Tbody, Select, and MultiSelect keep that state in each + Element's state slot rather than on the widget definition. Input widgets and JsVarStore bindings require distinct widget values. To show one binder in two inputs, construct two widgets: @@ -51,9 +51,9 @@ right := ui.NewText(binder) Calling `rw.Text(binder)` twice or rendering `{{$.Text .Binder}}` twice performs the same distinct construction. -Container, Tbody, Select, and Template constructors return values that must be -used as values; pointers to those definitions are unsupported. `NewOption` also -returns a value, but Option is stateless with value-receiver methods, so a +Container, Tbody, Select, MultiSelect, and Template constructors return values +that must be used as values; pointers to those definitions are unsupported. +`NewOption` also returns a value, but Option is stateless with value-receiver methods, so a pointer remains a valid UI. It changes identity to pointer identity and is usually unnecessary. @@ -180,9 +180,9 @@ Request or update synchronized shared state, but a scalar Dot field cannot serve as a request-local readiness gate. Ordinary tag dirtying updates matching Elements on every live Request. -Native form reset is unsupported for managed inputs and Select. A reset button -or `form.reset()` changes browser state without the per-control events JaWS -transports. Use a JaWS-handled `type="button"` action that updates authoritative +Native form reset is unsupported for managed inputs, Select, and MultiSelect. +A reset button or `form.reset()` changes browser state without the per-control +events JaWS transports. Use a JaWS-handled `type="button"` action that updates authoritative Go state and dirties the affected bindings. Each independently constructed Radio is one boolean binding. Native grouping @@ -373,9 +373,9 @@ encoded bytes, not Go heap capacity. An over-limit proposal cancels its Request. ## Container-family widgets -`NewContainer`, `NewTbody`, and `NewSelect` return immutable definition values. -The provider or handler participates in equality and must itself be comparable -and reflexive. Keep application objects containing slices, maps, or functions +`NewContainer`, `NewTbody`, `NewSelect`, and `NewMultiSelect` return immutable +definition values. The provider or handler participates in equality and must +itself be comparable and reflexive. Keep application objects containing slices, maps, or functions behind stable pointers and rebuild with the same pointer: ```go @@ -394,6 +394,20 @@ update require one. Typed nils are called normally. handler, construct it with `named.NewBoolArray(false)` or use the zero value. Do not pass the HTML `multiple` attribute or a multi-select `BoolArray`. +`MultiSelect` enables the native `multiple` attribute and accepts a +`named.MultiSelectHandler`. Use `named.NewBoolArray(true)` as its standard +handler. `JawsGetValues` supplies all selected option values; `JawsSetValues` +replaces the complete selection. Empty or nil values clear every option. Keep +option values distinct and non-empty. Render with `{{$.MultiSelect .Choices}}` +or `ui.NewMultiSelect(choices)`. + +MultiSelect uses JSON string arrays inside the existing input/value messages. +It reconciles all options after initial rendering and after child updates, and +restores authoritative selection after rejected, unchanged, or malformed input. +Malformed input never reaches the handler. Like Select, it uses the handler's +render-time dependency tag to dirty shared state; it also dirties the originating +Element so normalization cannot leave browser-only selections behind. + Each child must render one addressable direct DOM node with its Element Jid. `NewTemplate` supplies that wrapper. The slice returned by `JawsContains` becomes read-only after return. Duplicate child values require a widget type that @@ -406,13 +420,14 @@ does not preserve its Element. ## Element state and reconciliation -Container, Tbody, Select, and Template claim one private state slot on each -Element before callbacks, tag registration, or output. Contention returns +Container, Tbody, Select, MultiSelect, and Template claim one private state slot +on each Element before callbacks, tag registration, or output. Contention returns `jaws.ErrElementStateClaimed` without render side effects. Do not combine two state-owning renderers on one Element. -Updating a Container, Tbody, or Select Element that has not been rendered logs -`ui.ErrElementStateUnclaimed` without calling its provider or queuing work. +Updating a Container, Tbody, Select, or MultiSelect Element that has not been +rendered logs `ui.ErrElementStateUnclaimed` without calling its provider or +queuing work. Container state owns the render-time tag, reconciliation mutex, and children. Widget definitions remain immutable. Provider callbacks and validation run @@ -422,8 +437,9 @@ logging occur after unlocking. Cleanup detaches children under the state lock and recursively unregisters them after unlocking. Failed render and append paths unregister every child and -nested owner they created. A successful Select render queues its selected value -after options; unusable state suppresses reconciliation and that value update. +nested owner they created. A successful Select or MultiSelect render queues its +selection after options; unusable state suppresses reconciliation and that +value update. Template stores the Elements created by each execution in the rendering Element's state. Equal Template values can therefore back multiple Elements and diff --git a/lib/ui/doc.go b/lib/ui/doc.go index 5d80352a..e17d1c63 100644 --- a/lib/ui/doc.go +++ b/lib/ui/doc.go @@ -2,8 +2,8 @@ // // Its main building blocks are [HTMLInner] for dynamic inner HTML; [Input], // [InputText], [InputBool], and [InputDate] for typed controls; [Number] and -// [Range] for numeric controls; [Container], [Tbody], and [Select] for dynamic -// children; and [Template], [Handler], and [RequestWriter] for template integration. +// [Range] for numeric controls; [Container], [Tbody], [Select], and [MultiSelect] +// for dynamic children; and [Template], [Handler], and [RequestWriter] for templates. // // Every non-nil value used as a [github.com/linkdata/jaws.UI] must be comparable // at runtime and equal to itself, and is scoped to one Request. Construct fresh @@ -12,12 +12,12 @@ // // Within one Request, a widget normally backs one live // [github.com/linkdata/jaws.Element]. Widgets based on [HTMLInner], plus [Img], -// [Option], [Template], [Container], [Tbody], and [Select], support multiple live -// Elements under their concrete contracts. Input widgets and [JsVarBinding] require +// [Option], [Template], [Container], [Tbody], [Select], and [MultiSelect], support +// multiple live Elements under their concrete contracts. Input widgets and [JsVarBinding] require // distinct widget values. // -// [NewContainer], [NewTbody], [NewSelect], and [NewTemplate] return definition -// values. Use them as values; taking their addresses replaces definition equality +// [NewContainer], [NewTbody], [NewSelect], [NewMultiSelect], and [NewTemplate] return +// definition values. Use them as values; taking their addresses replaces definition equality // with pointer identity and is unsupported. // // HTML-inner widgets route content through diff --git a/lib/ui/multiselect.go b/lib/ui/multiselect.go new file mode 100644 index 00000000..3f766caa --- /dev/null +++ b/lib/ui/multiselect.go @@ -0,0 +1,88 @@ +package ui + +import ( + "encoding/json" + "io" + "slices" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/named" +) + +// MultiSelect renders an HTML select element with multiple selection enabled. +// +// Its handler supplies the options and all selected values. Option values must +// be non-empty and distinct. The standard handler is [named.BoolArray] +// constructed with named.NewBoolArray(true). An empty or nil selection clears +// every option; values without a matching option select nothing. +// +// Like [Select], MultiSelect is an immutable definition used as a value. Its +// handler must be comparable and equal to itself. Equal definitions may back +// multiple live Elements when the handler and option widgets support that use. +// Keep shared application state synchronized behind stable pointers. +// +// Native form reset does not update Go state. Reset the authoritative selection +// from a JaWS-handled button with type="button", then dirty its tag. +// The complete browser input message must fit the 32 KiB inbound limit; larger +// selections close the Request connection. +type MultiSelect struct { + handler named.MultiSelectHandler +} + +var ( + _ jaws.UI = MultiSelect{} + _ jaws.InputHandler = MultiSelect{} +) + +// NewMultiSelect returns a MultiSelect backed by handler. +// Use named.NewBoolArray(true) for a [named.BoolArray] handler. +func NewMultiSelect(handler named.MultiSelectHandler) MultiSelect { + return MultiSelect{handler: handler} +} + +// JawsRender renders the options and queues their complete selected state. +func (u MultiSelect) JawsRender(elem *jaws.Element, w io.Writer, params []any) error { + return u.container().render(elem, w, append([]any{"multiple"}, params...), func() { u.applyValues(elem) }) +} + +// JawsUpdate reconciles options before queuing their complete selected state. +// Missing, foreign, or in-progress state suppresses both operations. +func (u MultiSelect) JawsUpdate(elem *jaws.Element) { + if u.container().update(elem) { + u.applyValues(elem) + } +} + +func (u MultiSelect) applyValues(elem *jaws.Element) { + values := u.handler.JawsGetValues(elem) + if values == nil { + values = []string{} + } + data, _ := json.Marshal(values) // A string slice is always JSON encodable. + elem.SetValue(string(data)) +} + +func (u MultiSelect) container() Container { + return NewContainer("select", u.handler) +} + +// JawsInput replaces the selected values from a browser JSON array of strings. +// Malformed input is ignored without calling the handler. Every proposal +// reconciles the originating Element, including rejected or unchanged values. +// A nil-interface handler is a no-op; a typed-nil handler is called normally. +func (u MultiSelect) JawsInput(elem *jaws.Element, value string) (err error) { + if u.handler != nil { + var values []string + if json.Unmarshal([]byte(value), &values) == nil && values != nil && !slices.Contains(values, "") { + err = applyDirty(containerDirtyTag(elem), elem, u.handler.JawsSetValues(elem, values)) + } + elem.Dirty(elem) + } + return +} + +// MultiSelect renders an HTML select element with multiple selection enabled. +// See [MultiSelect] for handler requirements and native reset semantics. +func (rw RequestWriter) MultiSelect(handler named.MultiSelectHandler, params ...any) error { + return rw.NewUI(NewMultiSelect(handler), params...) +} diff --git a/lib/ui/multiselect_test.go b/lib/ui/multiselect_test.go new file mode 100644 index 00000000..0ee26170 --- /dev/null +++ b/lib/ui/multiselect_test.go @@ -0,0 +1,236 @@ +package ui + +import ( + "errors" + "io" + "strings" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/named" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" +) + +type multiSelectTestHandler struct { + children []jaws.UI + values []string + containsCalls int + getCalls int +} + +func (h *multiSelectTestHandler) JawsContains(*jaws.Element) []jaws.UI { + h.containsCalls++ + return h.children +} + +func (h *multiSelectTestHandler) JawsGetValues(*jaws.Element) []string { + h.getCalls++ + return h.values +} + +func (*multiSelectTestHandler) JawsSetValues(*jaws.Element, []string) error { + return jaws.ErrValueUnchanged +} + +type multiSelectTestSource struct { + *named.BoolArray + setCalls atomic.Int32 + setError error +} + +func (s *multiSelectTestSource) JawsSetValues(elem *jaws.Element, values []string) error { + s.setCalls.Add(1) + if s.setError != nil { + return s.setError + } + return s.BoolArray.JawsSetValues(elem, values) +} + +func TestMultiSelectInitialValues(t *testing.T) { + checked := named.NewBoolArray(true).Add("1", "one").Add("2", "two") + checked.Set("1", true) + checked.Set("2", true) + for _, tt := range []struct { + name string + handler named.MultiSelectHandler + want string + selected int + }{ + {name: "checked", handler: checked, want: `["1","2"]`, selected: 2}, + {name: "empty", handler: named.NewBoolArray(true).Add("1", "one"), want: `[]`}, + {name: "custom getter", handler: &multiSelectTestHandler{ + children: []jaws.UI{plainSelectOption{value: "1", label: "one"}, plainSelectOption{value: "2", label: "two"}}, + values: []string{"2"}, + }, want: `["2"]`}, + } { + t.Run(tt.name, func(t *testing.T) { + tr := newNumberRangeLiveRequest(t, nil) + rw := RequestWriter{Request: tr.Request, Writer: tr.Recorder} + if err := rw.MultiSelect(tt.handler, `class="choices"`); err != nil { + t.Fatal(err) + } + markup := tr.BodyString() + if !strings.HasPrefix(markup, "