diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8bb56..555bcdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **`skills/wind-ui/references/design-culture.md`: the taste layer the skill never had.** Every other reference answers "does this token exist and what does it do"; nothing answered "which token should this be", so an agent handed a screen with no design spec picked plausible values and produced work that rendered correctly and looked wrong. The new file carries the three-level hierarchy with the type scale that implements it, the semantic / status / dark-surface color pair tables (routed through the seeded `primary` token rather than a literal `blue-600`), the spacing scale with the touch-target floors, HSL palette construction, the depth scale plus the border-free alternatives, mobile form / loading / empty / feedback / list patterns, the iOS navigation and gesture contracts, and a 14-row anti-pattern wall. It came from the distribution repo (`fluttersdk/ai`), which is a mirror: content that only lived there was one `rsync --delete` away from disappearing, and no wind consumer ever received it. Wired into SKILL.md section 11 and the section 14 reference table. Skill version 2.11.0. (`skills/wind-ui/references/design-culture.md`, `skills/wind-ui/SKILL.md`) +### Changed + +- **BREAKING (behavioural): a single-line `WInput` defaults its Return key to `TextInputAction.done` instead of `.next`.** Multiline still defaults to `.newline`, and an explicit `textInputAction` still wins, so the escape hatch is unchanged. The old default was wrong in a way that only shows up on a real form: Flutter implements `.next` as `focusNode.nextFocus()`, which moves to the next FOCUSABLE widget rather than the next text field. Measured on a status-page form ordered `Name → [segmented control] → Slug → [8 colour swatches] → [Replace/Remove] → Initials → Description`, Return on **Name** focused the segmented-control button, so iOS dismissed the keyboard with nothing editable holding focus, while Return on **Initials** happened to land on the Description textarea, so the keyboard stayed and the caret jumped. Reported by a user as "why does Enter close the keyboard on one field and move to another on the next one". A key labelled Next that lands on a colour swatch is worse than one labelled Done. Field-to-field advance stays available through `textInputAction`, which is the only place the intent can be stated correctly, since only the form knows its own field order; `WKeyboardActions` remains the option for an explicit, data-driven advance order. Any form that relied on the old default gets its behaviour back by passing `textInputAction: TextInputAction.next` per field. (`lib/src/widgets/w_input.dart`, `doc/widgets/w-input.md`, `skills/wind-ui/references/forms.md`) + ### Fixed - **A `justify-between` row starved the one child that asked for the space.** Space distribution wrapped every child in a `Flexible` to reproduce the CSS `flex: 0 1 auto` shrink default, but Flutter splits free space equally between flex children, so the wrap also handed a share to siblings that never asked for one. Measured on a two-child page header at 402pt: the row gave 185pt to a `flex-1` title column and 185pt to an icon column that painted 24pt of it, and the title column, a loose flex child with no leftover left to take, laid out at ZERO width while 140pt of the row sat blank. A grow claim on any child (`flex-1`, `flex-{n}`, `grow`, `flex-grow`, `flex-auto`, a bare `w-full`, or a raw `Expanded` / `Flexible`) now turns the wrap off for the row: the growing child takes the whole remainder and its siblings keep their content width, which is what `justify-content: space-between` does in CSS, where the free space is distributed BETWEEN items rather than made flexible. There is nothing left to distribute once a child grows, so the wrap was only ever about shrinking: `overflow-hidden` keeps it unconditionally because that token asks for shrinking on purpose, and the shrink-only tokens (`shrink`, `flex-shrink`, `flex-initial`, CSS `flex: 0 1 auto`) still self-wrap to shrink without counting as a claim. A bare `w-full` counts because the Row composer already turns exactly that child into an `Expanded`; leaving it out would have capped it at half the row while `flex-1` took the remainder, and the two are documented as equivalent on a row child. A PREFIXED grow token does not count: `hover:flex-1` and `md:grow` are conditional and cannot be resolved from the class string, so counting one strips the shrink wrap off every sibling at a state or breakpoint where nothing actually grows, which measured a text sibling laying out at 504 in a 100pt row with `A RenderFlex overflowed by 424 pixels on the right`. That mirrors the policy `md:w-full` already had for the same reason, and it leaves a prefixed grow token on the pre-existing equal-share split while its variant is active rather than trading a starved child for an overflowing row. (`lib/src/widgets/w_div.dart`, `doc/layout/flexbox.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/layouts.md`) diff --git a/doc/widgets/w-input.md b/doc/widgets/w-input.md index 893084b..8bfec9a 100644 --- a/doc/widgets/w-input.md +++ b/doc/widgets/w-input.md @@ -89,7 +89,7 @@ const WInput({ | `enabled` | `bool` | `true` | Whether the input is interactive | | `readOnly` | `bool` | `false` | Whether the input is read-only | | `autofocus` | `bool` | `false` | Autofocus on mount | -| `textInputAction` | `TextInputAction?` | `null` | Keyboard action (e.g., `.done`, `.next`) | +| `textInputAction` | `TextInputAction?` | `null` | Keyboard action (e.g., `.done`, `.next`). When `null`, resolves to `.done` on a single-line field and `.newline` on a multiline one. | | `onSubmitted` | `ValueChanged?` | `null` | Callback when action button is pressed | | `onTapOutside` | `TapRegionCallback?` | `null` | Callback when tapping outside (useful for blur) | | `maxLines` | `int?` | `null` | Max lines for multiline input | @@ -142,6 +142,8 @@ WInput( ) ``` +> **The Return key closes the keyboard by default.** With no `textInputAction`, a single-line field resolves to `TextInputAction.done` and a multiline one to `.newline`. Passing `.next` is a deliberate opt-in, because Flutter implements it as `focusNode.nextFocus()`: the next FOCUSABLE widget, not the next text field. On a form whose traversal order runs through buttons, colour swatches and switches, the same Return key does two different things on one screen, dismissing the keyboard where the next focusable is a button and jumping past several fields where it happens to be editable. Reach for `.next` only where you know the following focusable IS the next input, which is a fact only the form itself has. For a toolbar that advances through a known field order instead, see [WKeyboardActions](./w-keyboard-actions.md). + ## State Variants diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index 2579163..2fd33c5 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -139,9 +139,14 @@ class WInput extends StatefulWidget { /// The action button on the keyboard (e.g., done, next, search, send). /// + /// Defaults to `done` on a single-line field and `newline` on a multiline one. + /// /// Common values: - /// - `TextInputAction.done` - Shows "Done" button - /// - `TextInputAction.next` - Shows "Next" button (moves to next field) + /// - `TextInputAction.done` - Shows "Done" button (dismisses the keyboard) + /// - `TextInputAction.next` - Shows "Next" button. Flutter moves focus to the + /// next FOCUSABLE widget, which on a real form is often a button rather than + /// a field, so pass this only where the following focusable IS the next + /// input. /// - `TextInputAction.search` - Shows "Search" button /// - `TextInputAction.send` - Shows "Send" button /// - `TextInputAction.go` - Shows "Go" button @@ -522,10 +527,23 @@ class _WInputState extends State obscureText: obscureText, readOnly: widget.readOnly || !widget.enabled, autofocus: widget.autofocus, + // A single-line field defaults to `done`, so Return closes the keyboard. + // + // It used to default to `next`, and Flutter's own handling of `next` is + // `focusNode.nextFocus()`: the next FOCUSABLE widget, not the next text + // field. On a real form that order runs through buttons, colour swatches + // and switches, so the same Return key closed the keyboard on one field + // (the next focusable was a button) and jumped to a textarea on the next + // one (the next focusable happened to be editable). A key labelled Next + // that lands on a colour swatch is worse than one labelled Done. + // + // A form that wants field-to-field advance still asks for it explicitly + // through [textInputAction], which is the only place that intent can be + // stated correctly: only the form knows its own field order. textInputAction: widget.textInputAction ?? (widget.type == InputType.multiline ? TextInputAction.newline - : TextInputAction.next), + : TextInputAction.done), textCapitalization: widget.textCapitalization, autocorrect: widget.autocorrect, enableSuggestions: widget.enableSuggestions, diff --git a/skills/wind-ui/references/forms.md b/skills/wind-ui/references/forms.md index f38f1bb..9fe2057 100644 --- a/skills/wind-ui/references/forms.md +++ b/skills/wind-ui/references/forms.md @@ -13,6 +13,7 @@ Building forms with validation. Use this file when picking between raw `W*` and 7. [TextEditingController vs FormField](#7-texteditingcontroller-vs-formfield) 8. [WFormDatePicker range mode gotcha](#8-wformdatepicker-range-mode-gotcha) 9. [Multi-step / wizard forms](#9-multi-step--wizard-forms) +10. [The Return key and field-to-field advance](#10-the-return-key-and-field-to-field-advance) --- @@ -345,6 +346,31 @@ For very long forms inside a `ListView.builder` (each row is a field), pass each --- +## 10. The Return key and field-to-field advance + +`WInput` / `WFormInput` resolve `textInputAction` to `TextInputAction.done` on a single-line field and `.newline` on a multiline one when you pass nothing. So Return closes the keyboard by default. + +`.next` is an opt-in, and it is worth knowing what it actually does before reaching for it: Flutter implements `.next` as `focusNode.nextFocus()`, which moves to the next FOCUSABLE widget rather than the next text field. A real form's traversal order runs through buttons, colour swatches, switches and segmented controls, so one Return dismisses the keyboard (the next focusable was a button, nothing editable took focus) while the Return on the field below it jumps past three fields into a textarea. Same key, same screen, two behaviours. + +Pass `.next` only where you know the following focusable IS the next input, and pass it per field rather than blanket-applying it, because only the form knows its own order: + +```dart +WFormInput( + label: 'Email', + type: InputType.email, + textInputAction: TextInputAction.next, // the Password field is next in tree order +), +WFormInput( + label: 'Password', + type: InputType.password, + textInputAction: TextInputAction.done, // last field: submit-shaped +), +``` + +When the order is not a clean run of adjacent fields, do not try to fix it with `.next`. Reach for `WKeyboardActions` instead: it renders a toolbar above the keyboard whose Previous / Next buttons walk an explicit `focusNodes` list, so the advance order is data you control rather than a side effect of widget tree layout. + +--- + ## Anti-patterns | Wrong | Why | Right | diff --git a/test/widgets/w_input_test.dart b/test/widgets/w_input_test.dart index ad66a38..61fa11b 100644 --- a/test/widgets/w_input_test.dart +++ b/test/widgets/w_input_test.dart @@ -450,11 +450,29 @@ void main() { expect(editableText.enableSuggestions, isFalse); }); - testWidgets('default textInputAction is next for single line', ( + testWidgets('default textInputAction is done for single line', ( tester, ) async { + // `next` was the old default, and Flutter resolves it to the next + // FOCUSABLE widget rather than the next field: on a form whose order + // runs through buttons and colour swatches, one Return closed the + // keyboard and the next jumped into a textarea. A form that wants + // field-to-field advance passes `next` itself, because only the form + // knows its own order. await tester.pumpWidget(wrapWithTheme(const WInput())); + final editableText = + tester.widget(find.byType(EditableText)); + expect(editableText.textInputAction, TextInputAction.done); + }); + + testWidgets('an explicit textInputAction still wins', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const WInput(textInputAction: TextInputAction.next), + ), + ); + final editableText = tester.widget(find.byType(EditableText)); expect(editableText.textInputAction, TextInputAction.next);