diff --git a/CHANGELOG.md b/CHANGELOG.md index b81e152..8e8bb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### 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`) +- **`WKeyboardActions` hosted its toolbar in the nearest `Overlay`, so in a nested one the toolbar rendered off-screen.** The toolbar is positioned in screen terms (`bottom: viewInsets.bottom`, so it sits on top of the keyboard), and a nested overlay is measured in its own box rather than the screen's. Measured on an iPhone in an app whose page host owns an `Overlay` inside a scroll view: two overlays, one 402x874 (the screen) and one 402x2146 (the scrolled content), both reporting the same 335pt bottom inset. The entry went into the second, so `bottom: 335` put the toolbar 335pt from the bottom of the CONTENT, roughly 1300pt below the viewport. It was in the semantics tree the whole time and nowhere on the screen, which is the worst shape this bug can take: a tree assertion passes and the user still has nothing to press. It now inserts into `Overlay.of(context, rootOverlay: true)`, and consumers need no `Overlay` of their own since `MaterialApp` and `CupertinoApp` both provide the root one. (`lib/src/widgets/w_keyboard_actions.dart`, `doc/widgets/w-keyboard-actions.md`, `skills/wind-ui/references/theme.md`) ### Quality diff --git a/doc/widgets/w-keyboard-actions.md b/doc/widgets/w-keyboard-actions.md index f1400b1..ad45e46 100644 --- a/doc/widgets/w-keyboard-actions.md +++ b/doc/widgets/w-keyboard-actions.md @@ -69,6 +69,8 @@ class _MyFormState extends State { } ``` +> **The toolbar is hosted in the ROOT `Overlay`, not the nearest one.** It positions itself in screen terms (`bottom: viewInsets.bottom`, so it sits on top of the keyboard), and a nested `Overlay` is measured in its own box rather than the screen's. An app whose page host owns an `Overlay` inside a scroll view gives that overlay the height of the scrolled content, so a nearest-overlay toolbar would render hundreds of points below the viewport: present in the widget tree, absent from the screen. You need no `Overlay` of your own for this to work; `MaterialApp` and `CupertinoApp` both provide the root one. + ## Constructor diff --git a/lib/src/widgets/w_keyboard_actions.dart b/lib/src/widgets/w_keyboard_actions.dart index 6c999e7..faf24ab 100644 --- a/lib/src/widgets/w_keyboard_actions.dart +++ b/lib/src/widgets/w_keyboard_actions.dart @@ -8,7 +8,9 @@ import 'w_keyboard_platform.dart'; /// A Wind-styled wrapper that adds keyboard actions (Done button, navigation) /// to input fields, especially for iOS numeric keyboards. /// -/// The widget renders an above-keyboard toolbar in the app's `Overlay` while +/// The widget renders an above-keyboard toolbar in the app's ROOT `Overlay` +/// (the toolbar is positioned in screen terms, so a nested overlay measured in +/// its own box would place it off-screen) while /// any of its `focusNodes` holds focus, providing: /// - Done button for dismissing numeric keyboards on iOS /// - Up/Down navigation between multiple input fields @@ -210,7 +212,14 @@ class _WKeyboardActionsState extends State { void _insertOrUpdateOverlay() { if (_overlayEntry == null) { _overlayEntry = OverlayEntry(builder: _buildToolbar); - Overlay.of(context).insert(_overlayEntry!); + // The ROOT overlay, never the nearest one. The toolbar is positioned in + // SCREEN terms (`bottom: viewInsets.bottom`, so it sits on the keyboard), + // and a nested overlay is measured in its own box: an app whose page host + // owns an overlay inside a scroll view is 2146pt tall, so `bottom: 335` + // there put the toolbar 335pt from the bottom of the CONTENT, roughly + // 1300pt below the viewport. It was inserted, it was in the semantics + // tree, and it was nowhere on the screen. + Overlay.of(context, rootOverlay: true).insert(_overlayEntry!); // 1. Start a per-frame platform guard while the toolbar is active. // The guard fires in the transient-callbacks phase (before build), so // `_overlayEntry.remove()` triggers the Overlay's internal rebuild in diff --git a/skills/wind-ui/references/theme.md b/skills/wind-ui/references/theme.md index 7ce3bf1..0bd03ba 100644 --- a/skills/wind-ui/references/theme.md +++ b/skills/wind-ui/references/theme.md @@ -491,3 +491,5 @@ class _MyMenuState extends State { `WPopover` and `WSelect` already handle this internally. The caveat surfaces when you build a custom overlay (showing a tooltip via `OverlayEntry`, a custom popup, a tour overlay). The safe pattern in practice: don't call `WindParser.parse` directly from inside `OverlayEntry.builder`. Instead, build W-widgets inside the overlay; they parse with the right context themselves. + +**A second, separate decision: WHICH overlay hosts the entry.** `Overlay.of(context)` returns the nearest one, which is right for an entry positioned against surrounding content (a menu anchored to its trigger). An entry positioned in SCREEN terms needs `Overlay.of(context, rootOverlay: true)`, because a nested overlay is measured in its own box rather than the screen's. `WKeyboardActions` is the worked example: its toolbar sits at `bottom: viewInsets.bottom` so it lands on the keyboard, and in an app whose page host owned an `Overlay` inside a scroll view the nearest overlay was as tall as the scrolled content, so the toolbar rendered roughly 1300pt below the viewport. It was in the semantics tree the whole time, which is the worst shape this bug takes: a tree assertion passes and the user still has nothing to press. diff --git a/test/widgets/w_keyboard_actions_test.dart b/test/widgets/w_keyboard_actions_test.dart index 4aa0d5d..8845ec4 100644 --- a/test/widgets/w_keyboard_actions_test.dart +++ b/test/widgets/w_keyboard_actions_test.dart @@ -401,4 +401,63 @@ void main() { } }); }); + + group('WKeyboardActions overlay host', () { + testWidgets('the toolbar lands on the screen even under a nested overlay', + (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + // An app whose page host owns an Overlay INSIDE a scroll view is the + // shape this failed on. The nested overlay is as tall as the scrolled + // content, and the toolbar positions itself in screen terms + // (`bottom: viewInsets.bottom`), so hosting it there put it hundreds of + // points below the viewport: present in the tree, absent from the + // screen. + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: SingleChildScrollView( + child: SizedBox( + height: 2400, + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (_) => WKeyboardActions( + focusNodes: [focusNode], + child: TextField(focusNode: focusNode), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(const Duration(milliseconds: 16)); + + final Finder toolbar = find.byType(TextButton); + expect(toolbar, findsOneWidget); + + final double screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getRect(toolbar).bottom, + lessThanOrEqualTo(screenHeight), + reason: 'a toolbar below the viewport is a toolbar the user cannot ' + 'reach, however present it is in the tree', + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + }); }