From f9b030ddf13849557f33b25fcfa7ce2a6bea6187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 21 Aug 2026 01:49:46 +0300 Subject: [PATCH 1/2] fix(input): default a single-line field's Return key to done The default was `next`, and Flutter resolves `next` to `focusNode.nextFocus()`: the next FOCUSABLE widget, not the next text field. On a real form the traversal order runs through buttons, colour swatches and switches, so one Return key behaved two different ways on one screen. Measured on a status-page form: Return on Name moved focus to a segmented-control button and iOS dismissed the keyboard because nothing editable held focus, while Return on the field below happened to land on a textarea, so the keyboard stayed and the caret jumped. A key labelled Next that lands on a colour swatch is worse than one labelled Done. `done` is now the default, so Return does one predictable thing. Field-to-field advance is still available through `textInputAction`, and that is the only place the intent can be stated correctly: only the form knows its own field order, and whether the next focusable widget is the next input. Multiline keeps `newline`; a Return there inserts a line and the keyboard is dismissed from the toolbar instead. --- lib/src/widgets/w_input.dart | 24 +++++++++++++++++++++--- test/widgets/w_input_test.dart | 20 +++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) 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/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); From 76fa71adcf4a0de711b387a9951ba7317c57a41f Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 13:16:57 +0300 Subject: [PATCH 2/2] docs(input): document the done default and when .next is the right call The old default was undocumented, so the change had nothing to contradict, which is its own problem: a reader had no way to know what the Return key would do before running the app. `doc/widgets/w-input.md` now states the resolved default in the props table and explains under Event Handling why `.next` is an opt-in rather than a default. The skill's forms reference gained section 10, since composing a form is exactly when an agent decides this and it had no guidance at all. It points at WKeyboardActions for the case where the advance order is not a clean run of adjacent fields. Skill version 2.12.0. --- CHANGELOG.md | 4 ++++ doc/widgets/w-input.md | 4 +++- skills/wind-ui/SKILL.md | 4 ++-- skills/wind-ui/references/forms.md | 26 ++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edae21a..b1d69c9 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`) + ### Quality - **48 branches had accumulated, 45 of them PRs that landed months ago.** `delete_branch_on_merge` was off, so every task branch outlived its merge and the list grew by one per PR since December 2025. It is on now, which handles everything from here without a workflow, a token or a cron: the setting fires on the merge event alone, touches only that PR's head branch, and cannot reach `master` or `v0` because both are protected. The 46 leftovers (45 merged, plus a branch from the abandoned release-please setup whose PR #80 was closed unmerged) are deleted. The tempting alternative, a scheduled stale-branch job, was rejected: with the setting on it would only ever catch branches that never merged, this repo has produced exactly one of those in its history, and its "untouched for N days" test cannot tell an abandoned branch from one you set down for a fortnight. A merge is a statement of intent; a date is not. Recorded in `CLAUDE.md` under Branching, because a policy nobody wrote down is not a policy. 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/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 40e56ac..a57ed97 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ name: wind-ui description: "fluttersdk_wind 1.3: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." -version: 2.11.0 +version: 2.12.0 --- - + # Wind UI 1.3 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 |