Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
4 changes: 3 additions & 1 deletion doc/widgets/w-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>?` | `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 |
Expand Down Expand Up @@ -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).

<a name="state-variants"></a>
## State Variants

Expand Down
24 changes: 21 additions & 3 deletions lib/src/widgets/w_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -522,10 +527,23 @@ class _WInputState extends State<WInput>
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,
Expand Down
26 changes: 26 additions & 0 deletions skills/wind-ui/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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 |
Expand Down
20 changes: 19 additions & 1 deletion test/widgets/w_input_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditableText>(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<EditableText>(find.byType(EditableText));
expect(editableText.textInputAction, TextInputAction.next);
Expand Down
Loading