diff --git a/docs/faq.mdx b/docs/faq.mdx index 8de0068..d647295 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -89,6 +89,14 @@ over `String?` like the built-ins. See [Validators](./validation/validators.mdx# `mustBeTrue(MyError.mustAccept)` covers the checkbox that has to be ticked. There is no `Set` validator, and `notEmpty` is for `List`, so a rule on a multi-select is a one-line closure: `(chosen) => chosen.isEmpty ? MyError.pickOne : null`. + + + +By design: the built-in select is a fixed list of choices. For a list that changes while the form is open, extend +`AdvancedFieldController` yourself with a mutable list and a `setOptions` that clears a value no longer on it. +It is about thirty lines and is written out in +[A select whose options change](./fields/custom.mdx#a-select-whose-options-change). + diff --git a/docs/fields/custom.mdx b/docs/fields/custom.mdx index df34790..e2f6064 100644 --- a/docs/fields/custom.mdx +++ b/docs/fields/custom.mdx @@ -68,6 +68,72 @@ class PhoneFieldController extends AdvancedTextFieldController { Normalise `initialValue` before it reaches `super`, as above: `reset()` returns to *that* value without passing through `setValue`. +## A select whose options change + +`AdvancedSingleSelectFieldController` carries its `options` as a `final` list: the choices are fixed for the life of +the field. When they are not — instructors filtered by the chosen aircraft, cities loaded once the country is known — +build the select yourself on the concrete base class. It is a short class, and it keeps the value and the list in one +place: + +```dart +class DependentSelectController + extends AdvancedFieldController { + DependentSelectController({ + required super.initialValue, + required List options, + super.validator, + super.asyncValidation, + super.focusNode, + super.name, + }) : _options = List.unmodifiable(options); + + List _options; + + /// The options to show right now. Replaced as a whole by [setOptions]. + List get options => _options; + + /// Swaps the list. A selected value that is no longer on it is cleared + /// with `prefill`, so this counts as the program's doing, not the user's. + void setOptions(List options) { + _options = List.unmodifiable(options); + final current = fieldValue; + if (current != null && !_options.contains(current)) { + prefill(null); + } + revalidateSync(); // "required" shows up at once — if the user had edited the field + notifyListeners(); // the options are not part of the state, so rebuild by hand + } + + void select(V? option) { + assert( + option == null || _options.contains(option), + 'Option $option is not one of the options of this field.', + ); + setValue(option); + } +} +``` + +`setOptions` clears with `prefill`, not `setValue`, so a field the user never touched stays untouched and shows no +error until `validate()`, as the [validation model](../validation/modes.mdx) has it. `revalidateSync()` obeys the same +gate: on a field the user *had* picked from, the `notNull` rule runs right away and "required" appears the moment the +old choice vanished. The `notifyListeners()` call is what makes `AdvancedFieldBuilder` rebuild when only the list +changed: the options are not part of `AdvancedFieldState`, and a state that did not change does not notify. + +Wire the list change to whatever drives it — a subscription on the other field or the completion of a request: + +```dart +aircraft.addListener(() { + instructor.setOptions(instructorsFor(aircraft.fieldValue)); +}); +``` + + + `contains` compares with `==`. A `Pilot` deserialized from the server is a different object from the `Pilot` you + put on the list, so with the default identity `==` the value is cleared every time the list is refreshed. Make `V` + a value type (`Equatable`, a record, an enum) or select by id and resolve to the instance that is on the list. + + ## A list as the error type The example app's diff --git a/docs/fields/index.mdx b/docs/fields/index.mdx index b49b063..e88ce6a 100644 --- a/docs/fields/index.mdx +++ b/docs/fields/index.mdx @@ -131,7 +131,8 @@ Defaults to `false`. The one rule a boolean usually needs is built in: `validato Holds a `V?` and a non-nullable `List options`. A required dropdown is `initialValue: null` plus `validator: notNull(MyError.required)`. `select(option)` asserts in debug builds that the option is one of `options`; `select(null)` clears and is always allowed. `initialValue` is never checked, so an off-list initial value is the way -to represent "unknown". +to represent "unknown". The list is `final`; for options that change while the form is open, see +[A select whose options change](./custom.mdx#a-select-whose-options-change). ## Multi select diff --git a/skills/advanced_forms/SKILL.md b/skills/advanced_forms/SKILL.md index d8befb5..70f7a6d 100644 --- a/skills/advanced_forms/SKILL.md +++ b/skills/advanced_forms/SKILL.md @@ -142,6 +142,10 @@ getters `fieldValue`, `error`, `name`, `lastFailure`, `isDisposed`. `toggleElement` when it adds. `prefill` does not assert, so check server-supplied values. - The multi-select copies the set and list you pass, so mutating them later never reaches the field. `const {}` is a **Map**: an empty initial selection is `const {}`. +- `options` is **final** on both selects: the choices are fixed for the life of the field. For a + list that depends on another field or on a request, do not fight the built-in select — extend + `AdvancedFieldController` with a mutable list (below, "Select with changing options"). + Membership is `==`: a server copy of an option is not the option on the list. - With no `validator`, `E` infers to its bound `Object`. That compiles and then breaks every `switch` on your error enum, so spell it out: `AdvancedTextFieldController()`. - `AdvancedFieldController` is **concrete** — construct it directly for any value with no @@ -173,6 +177,56 @@ class PhoneFieldController } ``` +**Select with changing options.** The built-in select's `options` is `final`. When the list +depends on another field (instructors for the chosen aircraft) or arrives from the server, build +the select on the concrete base class — keep the package's model, add the one mutable list: + +```dart +class DependentSelectController + extends AdvancedFieldController { + DependentSelectController({ + required super.initialValue, + required List options, + super.validator, + super.asyncValidation, + super.focusNode, + super.name, + }) : _options = List.unmodifiable(options); + + List _options; + + /// The options to show right now. Replaced as a whole by [setOptions]. + List get options => _options; + + /// Swaps the list. A selected value that is no longer on it is cleared + /// with `prefill`, so this counts as the program's doing, not the user's. + void setOptions(List options) { + _options = List.unmodifiable(options); + final current = fieldValue; + if (current != null && !_options.contains(current)) { + prefill(null); + } + revalidateSync(); // "required" shows up at once — if the user had edited the field + notifyListeners(); // the options are not part of the state, so rebuild by hand + } + + void select(V? option) { + assert( + option == null || _options.contains(option), + 'Option $option is not one of the options of this field.', + ); + setValue(option); + } +} +``` + +`prefill`, not `setValue`, does the clearing: an untouched field stays untouched (rule 2 below). +`revalidateSync()` obeys the gate, so a field the user *had* picked from shows "required" at once. +`notifyListeners()` is needed because options are not in `AdvancedFieldState`, so a list change +alone rebuilds nothing. Drive it with `aircraft.addListener(() => instructor.setOptions(...))`. +Bind with `AdvancedFieldBuilder` and a `DropdownButton` over `field.options`, as for the +built-in select. + Every write to `textController` — keystroke, paste, programmatic `.text =` — goes through the public `setValue`, so the override always runs and the transformed value is written back with the caret kept on the same characters. Three writes **bypass** `setValue` and need the same