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