|
| 1 | +--- |
| 2 | +path: "lib/src/ui/**/*.dart" |
| 3 | +--- |
| 4 | + |
| 5 | +# Building Views That Are Stable and E2E-Drivable |
| 6 | + |
| 7 | +Views are part of the definition of done only when their primary user flows can be driven end to end by a dusk agent or a CI test. The rules below keep widget identity and semantic labels stable enough for label-based resolution (rules 1-3) and make dusk drivability part of the definition of done (rule 4). |
| 8 | + |
| 9 | +## 1. Drive button loading state with `processingListenable` + `MagicBuilder` |
| 10 | + |
| 11 | +Use a targeted subtree rebuild instead of a full-view `setState`. This keeps the button tree stable, avoids identity loss, and prevents the dusk agent from losing its reference mid-action. |
| 12 | + |
| 13 | +Pattern (`magic_form_data.dart:267`, `magic_builder.dart:76`): |
| 14 | + |
| 15 | +```dart |
| 16 | +MagicBuilder<bool>( |
| 17 | + listenable: form.processingListenable, |
| 18 | + builder: (isProcessing) => WButton( |
| 19 | + semanticLabel: 'Save profile', |
| 20 | + isLoading: isProcessing, |
| 21 | + // Disable while processing: form.process() throws StateError if |
| 22 | + // called again mid-flight (magic_form_data.dart:285), and it blocks |
| 23 | + // duplicate submits / double-taps. |
| 24 | + onTap: isProcessing ? null : () => form.process(_submit), |
| 25 | + child: WText(trans('common.save')), |
| 26 | + ), |
| 27 | +) |
| 28 | +``` |
| 29 | + |
| 30 | +- `form.processingListenable` is a `ValueListenable<bool>` on `MagicFormData` — only the `MagicBuilder` subtree rebuilds. |
| 31 | +- `MagicBuilder<T>` wraps `ValueListenableBuilder` with a cleaner API; see `magic_builder.dart:76`. |
| 32 | +- Cross-link: Step M1 makes `processingListenable` + `MagicBuilder` the scaffolded default in generated view stubs. |
| 33 | +- Never replace this with `setState(() => _loading = ...)` on the parent widget — it tears down and remounts interactive descendants, breaking dusk locators. |
| 34 | + |
| 35 | +## 2. Assign stable `ValueKey` to interactive elements that can be conditionally rendered or reordered |
| 36 | + |
| 37 | +Widget identity in the Flutter element tree is position-based by default. When a button toggles between states or appears in a dynamic list, a stable key guarantees dusk resolves the same element across frames. |
| 38 | + |
| 39 | +```dart |
| 40 | +// Submit button that conditionally renders a loading state |
| 41 | +WButton( |
| 42 | + key: const ValueKey('submit-profile'), |
| 43 | + semanticLabel: 'Save profile', |
| 44 | + ... |
| 45 | +), |
| 46 | +
|
| 47 | +// Action button inside a dynamic list item |
| 48 | +WButton( |
| 49 | + key: ValueKey('delete-item-${item.id}'), |
| 50 | + semanticLabel: 'Delete ${item.name}', |
| 51 | + ... |
| 52 | +), |
| 53 | +``` |
| 54 | + |
| 55 | +Rule of thumb: any interactive widget (`WButton`, `WInput`, custom controls) inside a `Column`/`ListView` with conditional siblings, or one that can be toggled visible/invisible, needs a `ValueKey`. |
| 56 | + |
| 57 | +## 3. Set `semanticLabel` on icon-only and ambiguous interactive widgets |
| 58 | + |
| 59 | +The dusk agent resolves elements by semantic label. Without a label, an icon-only button is invisible to label-based resolution. |
| 60 | + |
| 61 | +`WButton.semanticLabel` (`wind/lib/src/widgets/w_button.dart:113`) propagates to the `Semantics` node wrapping the interactive surface: |
| 62 | + |
| 63 | +```dart |
| 64 | +// Icon-only button — no visible text, must have a label |
| 65 | +WButton( |
| 66 | + key: const ValueKey('back-button'), |
| 67 | + semanticLabel: 'Go back', |
| 68 | + onTap: () => Route.back(), |
| 69 | + child: WIcon(Icons.arrow_back), |
| 70 | +), |
| 71 | +
|
| 72 | +// Ambiguous action (same icon used multiple times on screen) |
| 73 | +WButton( |
| 74 | + key: ValueKey('edit-user-${user.id}'), |
| 75 | + semanticLabel: 'Edit user ${user.name}', |
| 76 | + onTap: () => _edit(user), |
| 77 | + child: WIcon(Icons.edit), |
| 78 | +), |
| 79 | +``` |
| 80 | + |
| 81 | +For generic Flutter widgets that do not accept `semanticLabel` directly, wrap with `Semantics(label: '...', child: widget)`. |
| 82 | + |
| 83 | +## 4. Definition of done for view work includes dusk drivability |
| 84 | + |
| 85 | +A view is done when: |
| 86 | +- Its primary user flows (form submit, list action, navigation) can be driven by a dusk agent using semantic labels and stable keys. |
| 87 | +- No interactive element in a primary flow is label-free or identity-unstable across a state change. |
| 88 | +- The processingListenable + MagicBuilder pattern is used for any submit/loading state. |
| 89 | + |
| 90 | +"Tests pass" and "looks correct" are necessary but not sufficient — dusk drivability is the third gate. |
0 commit comments