From de020852f9dd8d459b054b18aa0c49c35f56a6e2 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 23 Jun 2026 21:27:41 +0300 Subject: [PATCH 1/6] fix(w-input): wire native text selection via TextSelectionGestureDetectorBuilder The Material-free rewrite (#106) dropped the selection gesture layer by rendering a bare EditableText, so mouse drag-select did nothing on web (only keyboard select-all worked). Adopt the framework's canonical Material-free recipe: _WInputState implements TextSelectionGestureDetectorBuilderDelegate, the whole decorated box is wrapped by buildGestureDetector, and EditableText.rendererIgnoresPointer is true so the gesture layer is the sole pointer handler. The hand-built whole-box GestureDetector is removed; onTap is forwarded via onUserTap. Selection handles are platform-adaptive via the lowercase *HandleControls constants (Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, desktop on Linux/Windows); each mixes in TextSelectionHandleControls so the toolbar stays on the Material-free contextMenuBuilder path and no Overlay-less long-press throws. --- CHANGELOG.md | 5 + lib/src/widgets/w_input.dart | 139 ++++++++- test/widgets/w_input/selection_test.dart | 375 +++++++++++++++++++++++ 3 files changed, 503 insertions(+), 16 deletions(-) create mode 100644 test/widgets/w_input/selection_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f0747..46b6922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed + +- `WInput`: tap-to-focus and `onTap` dispatch now flow through Flutter's native `TextSelectionGestureDetectorBuilder` (the same gesture path `TextField` uses) instead of a hand-built whole-box `GestureDetector`. `onTap` continues to fire when the user taps the field; the change is the gesture mechanism (which also brings drag-select and double-tap word selection), not a new `onTap` contract. + ### Fixed +- `WInput` now supports native text selection: mouse drag-select, double-tap word, and long-press all work again. The Material-free rewrite (#106) had dropped the selection gesture layer, so only keyboard select-all (CTRL+A) worked and dragging on web selected nothing. The field now uses the framework's canonical selectable-input recipe (the one `CupertinoTextField` uses): `_WInputState` implements `TextSelectionGestureDetectorBuilderDelegate`, the whole decorated box is wrapped by a `TextSelectionGestureDetectorBuilder` (so a tap anywhere focuses and a drag over the glyphs selects), and `EditableText.rendererIgnoresPointer` is `true` so the gesture layer is the only pointer handler. Selection handles are platform-adaptive (`*HandleControls`: Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, desktop on Linux/Windows); each mixes in `TextSelectionHandleControls`, so the toolbar still flows through the Material-free `contextMenuBuilder` and no `Overlay`-less long-press throws. Read-only fields stay selectable; disabled fields stay fully inert. (`lib/src/widgets/w_input.dart`; covered by `test/widgets/w_input/selection_test.dart`.) - `WText` with no color in its own `className` now inherits an ancestor `DefaultTextStyle` color (the CSS text-color cascade) before falling back to the platform-brightness baseline. A parent `WDiv` with a `text-*` class publishes its color through `DefaultTextStyle.merge`, but `WText` previously ignored it and forced `Colors.white`/`Colors.black` from the OS platform brightness. That made colorless text vanish whenever the app theme disagreed with the OS theme: a secondary/outline button whose text color lives on the container (e.g. a dialog Cancel button) rendered an invisible label in a light app theme on a dark-mode OS. The brightness-aware baseline still applies only when no ancestor supplies a color (bare text with no Material ancestor), preserving the no-yellow-underline guarantee. (`lib/src/widgets/w_text.dart`; covered by `WText baseline rendering > inherits an ancestor color (CSS cascade)`.) ## [1.1.0] - 2026-06-17 diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index 187ce29..69df2eb 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -1,4 +1,17 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +// Material is imported for two platform-adaptive selection-handle CONSTANTS +// only: `materialTextSelectionHandleControls` and +// `desktopTextSelectionHandleControls` live in material.dart (their Cupertino +// peers live in cupertino.dart). WInput remains Material-free: no Material +// widget, Theme.of, or MaterialLocalizations is used; these constants mix in +// `TextSelectionHandleControls`, so they paint handles without a Material +// ancestor and route the toolbar through `contextMenuBuilder` instead of +// `buildToolbar`. +import 'package:flutter/material.dart' + show + desktopTextSelectionHandleControls, + materialTextSelectionHandleControls; import 'package:flutter/services.dart'; import '../parser/wind_parser.dart'; @@ -272,7 +285,8 @@ class WInput extends StatefulWidget { State createState() => _WInputState(); } -class _WInputState extends State { +class _WInputState extends State + implements TextSelectionGestureDetectorBuilderDelegate { /// Default content padding (12 horizontal / 8 vertical) used when className /// supplies no `p-*`. static const EdgeInsets _defaultContentPadding = @@ -295,13 +309,49 @@ class _WInputState extends State { /// field's ancestor chain between the Row and no-Row layouts; without a /// GlobalKey Flutter would destroy and rebuild the EditableText on that /// switch, dropping focus mid-typing. The key moves the same element instead. - final GlobalKey _editableTextKey = GlobalKey(); + /// + /// Typed `GlobalKey` because it doubles as the delegate's + /// [editableTextKey]: the [TextSelectionGestureDetectorBuilder] reaches + /// through it to `currentState!.renderEditable` to hit-test taps/drags in + /// global coordinates. There is exactly ONE key (no second instance), so the + /// gesture layer and the focus-preserving element move are the same node. + final GlobalKey _editableTextKey = + GlobalKey(); + + /// Builds the native selection gesture layer (tap-to-focus, drag-select, + /// double-tap word, long-press) around the EditableText. This is the + /// framework's canonical Material-free recipe (the one [CupertinoTextField] + /// uses): instead of a hand-rolled whole-box [GestureDetector], the builder + /// hit-tests against the EditableText's [RenderEditable] in global coords, so + /// it handles glyph-level selection AND whole-box taps in one detector. + late final _WInputSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; + + // === TextSelectionGestureDetectorBuilderDelegate === + + /// The single [GlobalKey] the gesture builder uses to reach the EditableText. + @override + GlobalKey get editableTextKey => _editableTextKey; + + /// iOS force-press (3D Touch) selection support. A getter, not an initState + /// cache, so `debugDefaultTargetPlatformOverride` in tests takes effect. + @override + bool get forcePressEnabled => defaultTargetPlatform == TargetPlatform.iOS; + + /// Whether the user may select text. Mirrors the EditableText's own + /// `enableInteractiveSelection` gate: selection UI needs an [Overlay] to host + /// its handles/toolbar and a disabled field exposes none of it. + @override + bool get selectionEnabled => + Overlay.maybeOf(context) != null && widget.enabled; @override void initState() { super.initState(); _initController(); _initFocusNode(); + _selectionGestureDetectorBuilder = + _WInputSelectionGestureDetectorBuilder(state: this); } void _initController() { @@ -501,12 +551,21 @@ class _WInputState extends State { // cursor color. backgroundCursorColor: CupertinoColors.inactiveGray, selectionColor: _isFocused ? selectionColor : null, + // RenderEditable must NOT handle pointers itself: the gesture layer is the + // `_selectionGestureDetectorBuilder` wrapping the whole box (below), which + // hit-tests the RenderEditable in global coords. Leaving this false would + // double-handle taps (the render box and the builder both reacting). + rendererIgnoresPointer: true, // Selection UI needs an Overlay to host its toolbar/handles, and a // disabled field must expose none of it (a read-only field stays - // selectable so its text can be copied). - selectionControls: hasOverlay && widget.enabled - ? cupertinoTextSelectionHandleControls - : null, + // selectable so its text can be copied). The handle controls are + // platform-adaptive `*HandleControls` CONSTANTS: each mixes in + // [TextSelectionHandleControls], which suppresses the legacy + // `buildToolbar` (so the Material-free `contextMenuBuilder` stays the only + // toolbar path and the MaterialLocalizations assert is never hit) and is + // NOT deprecated (so `dart analyze` stays clean). + selectionControls: + hasOverlay && widget.enabled ? _platformSelectionControls() : null, enableInteractiveSelection: hasOverlay && widget.enabled, contextMenuBuilder: hasOverlay && widget.enabled ? _buildContextMenu : null, @@ -585,18 +644,18 @@ class _WInputState extends State { child: field, ); - // Tapping anywhere in the box focuses the field (the box, not just the text - // glyphs, is the tap target) and fires onTap. EditableText alone only reacts - // to taps on the text itself; this restores TextField's whole-box behavior. + // Native selection gesture layer wraps the WHOLE decorated box (padding + + // prefix/suffix Row + DecoratedBox). The builder hit-tests against the + // EditableText's RenderEditable in global coordinates, so a tap anywhere in + // the box focuses the field (whole-box tap target, restoring TextField's + // behavior) while a drag over the glyphs selects text and a double-tap + // selects a word. Translucent behavior lets the box-area taps reach the + // builder even where there is no opaque child. Focus flows through the + // builder's own `onSingleTapUp`/`requestKeyboard`; `onUserTap` only fires + // `widget.onTap` (see the builder subclass below). if (widget.enabled) { - result = GestureDetector( + result = _selectionGestureDetectorBuilder.buildGestureDetector( behavior: HitTestBehavior.translucent, - onTap: () { - if (!_focusNode.hasFocus) { - _focusNode.requestFocus(); - } - widget.onTap?.call(); - }, child: result, ); } else { @@ -791,6 +850,25 @@ class _WInputState extends State { ); } + /// Picks the platform-native selection-handle controls. Each is a + /// `*HandleControls` CONSTANT that mixes in [TextSelectionHandleControls], so + /// the legacy `buildToolbar` is suppressed (the toolbar comes only from + /// [_buildContextMenu] via `contextMenuBuilder`) and no Material ancestor is + /// required to paint the drag handles. A getter-driven `defaultTargetPlatform` + /// read (not cached) keeps this honest under test platform overrides. + TextSelectionControls _platformSelectionControls() { + return switch (defaultTargetPlatform) { + TargetPlatform.iOS => cupertinoTextSelectionHandleControls, + TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls, + TargetPlatform.android || + TargetPlatform.fuchsia => + materialTextSelectionHandleControls, + TargetPlatform.linux || + TargetPlatform.windows => + desktopTextSelectionHandleControls, + }; + } + /// Builds a Material-free selection toolbar (Cupertino chrome) whose action /// labels read from [WidgetsLocalizations], which is always resolvable (via /// `DefaultWidgetsLocalizations`) even with no Material/Cupertino ancestor. @@ -839,6 +917,35 @@ class _WInputState extends State { } } +/// The gesture layer for [WInput]'s native text selection. +/// +/// This is the framework's canonical Material-free recipe: it extends +/// [TextSelectionGestureDetectorBuilder] (the same base [CupertinoTextField] +/// uses) and inherits every default selection gesture (tap-to-focus, +/// drag-select, double-tap word, triple-tap line, long-press, force-press on +/// iOS). It hit-tests against the EditableText's [RenderEditable] in global +/// coordinates via the delegate's `editableTextKey`, so wrapping the whole +/// decorated box preserves both whole-box tap and glyph-level drag-select. +/// +/// Only [onUserTap] is overridden, to forward [WInput.onTap]. Focus is NOT +/// requested here: the builder's own `onSingleTapUp` already calls +/// `requestKeyboard`, which focuses the field. Calling `requestFocus` again +/// would be redundant (and the docs of `onUserTap` warn it fires AFTER the +/// focusing tap handler). +class _WInputSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + _WInputSelectionGestureDetectorBuilder({required _WInputState state}) + : _state = state, + super(delegate: state); + + final _WInputState _state; + + @override + void onUserTap() { + _state.widget.onTap?.call(); + } +} + /// Restricts input to a signed decimal number: an optional leading minus, any /// number of digits, and at most one decimal point. Allows the in-progress /// states a user types through (`-`, `1.`, `.5`). diff --git a/test/widgets/w_input/selection_test.dart b/test/widgets/w_input/selection_test.dart new file mode 100644 index 0000000..f5d365d --- /dev/null +++ b/test/widgets/w_input/selection_test.dart @@ -0,0 +1,375 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme_data.dart'; +import 'package:fluttersdk_wind/src/widgets/w_input.dart'; + +/// Bare harness mirroring [material_free_test.dart]: [Directionality] > +/// [WindTheme] > child, with NO Material ancestor. Selection that needs an +/// Overlay wraps with [_overlayHarness] instead; this one is for assertions +/// that must hold under a Material-free root. +Widget _bareHarness(Widget child) { + return Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: child, + ), + ); +} + +/// Bare harness that DOES provide an [Overlay] (so interactive selection is +/// enabled) but still no Material/Cupertino app. Built on the minimal +/// [WidgetsApp] surface that selection drag/handles need, kept Material-free so +/// the `*HandleControls` mixin path (which suppresses `buildToolbar` and the +/// `MaterialLocalizations` assert) is what is exercised. +Widget _overlayHarness(Widget child) { + return WidgetsApp( + color: const Color(0xFF000000), + // WidgetsApp supplies Directionality + DefaultWidgetsLocalizations + + // MediaQuery (Material-free). The explicit Overlay hosts the selection + // handles/toolbar that interactive selection reaches for, so the field's + // `selectionEnabled` gate (Overlay.maybeOf != null) is satisfied. + builder: (context, _) => Overlay( + initialEntries: [ + OverlayEntry( + builder: (context) => WindTheme( + data: WindThemeData(), + child: child, + ), + ), + ], + ), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('WInput native selection', () { + // QA (1): mouse drag across "hello" produces a non-collapsed selection. + group('mouse drag selection', () { + testWidgets( + 'dragging the mouse across "hello" selects it', + (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + className: 'text-base', + ), + ), + ); + + await tester.enterText(find.byType(EditableText), 'hello world'); + await tester.pump(); + + // Anchor at the very start of the text, drag to just past "hello". + final RenderEditable renderEditable = _findRenderEditable( + tester.renderObject(find.byType(EditableText))); + final Offset startGlyph = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 0)) + .center, + ); + final Offset endOfHello = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 5)) + .center, + ); + + final TestGesture gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: startGlyph); + await gesture.down(startGlyph); + await tester.pump(); + await gesture.moveTo(endOfHello); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect( + controller.selection.isCollapsed, + isFalse, + reason: 'A mouse drag must produce a non-collapsed selection.', + ); + expect( + controller.text.substring( + controller.selection.start, + controller.selection.end, + ), + 'hello', + reason: 'The drag must cover exactly the dragged-over "hello".', + ); + }, + ); + }); + + // QA (2): double-tap selects the tapped word. + group('double tap', () { + testWidgets('double-tapping a word selects that word', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _overlayHarness( + WInput(controller: controller), + ), + ); + + await tester.enterText(find.byType(EditableText), 'hello world'); + await tester.pump(); + + final RenderEditable renderEditable = + _findRenderEditable(tester.renderObject(find.byType(EditableText))); + // Center of the first word "hello" (offset 2 sits inside it). + final Offset insideHello = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 2)) + .center, + ); + + await tester.tapAt(insideHello); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(insideHello); + await tester.pump(); + + expect( + controller.text.substring( + controller.selection.start, + controller.selection.end, + ), + 'hello', + reason: 'A double tap must select the word under the pointer.', + ); + }); + }); + + // QA (3): tapping empty box area focuses the field and fires onTap. + group('whole-box tap', () { + testWidgets('tapping the empty box focuses and fires onTap', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + bool tapped = false; + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + focusNode: focusNode, + onTap: () => tapped = true, + // Wide box so the right padding area is far from the glyphs. + className: 'w-full p-4', + ), + ), + ); + + // Tap the far-right padding region (inside WInput, outside the glyphs). + final Rect rect = tester.getRect(find.byType(WInput)); + await tester.tapAt(Offset(rect.right - 4, rect.center.dy)); + await tester.pump(); + + expect( + focusNode.hasFocus, + isTrue, + reason: 'A whole-box tap must focus the field via the builder.', + ); + expect( + tapped, + isTrue, + reason: 'onTap fires from the builder onUserTap override.', + ); + }); + }); + + // QA (4): disabled field is fully inert (IgnorePointer). + group('disabled', () { + testWidgets('disabled field ignores taps (no focus, no onTap)', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + bool tapped = false; + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + focusNode: focusNode, + enabled: false, + onTap: () => tapped = true, + className: 'w-full p-4', + ), + ), + ); + + final Rect rect = tester.getRect(find.byType(WInput)); + await tester.tapAt(Offset(rect.right - 4, rect.center.dy)); + await tester.pump(); + + // IgnorePointer swallows the tap: the field neither focuses nor fires + // onTap. (No text-entry assertion here: tester.enterText focuses via + // the test binding and would bypass IgnorePointer, so it proves + // nothing about pointer suppression.) + expect(focusNode.hasFocus, isFalse); + expect(tapped, isFalse); + }); + }); + + // QA (5): read-only field stays selectable. + group('read-only', () { + testWidgets('read-only field is still selectable by drag', ( + tester, + ) async { + final controller = TextEditingController(text: 'hello world'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _overlayHarness( + WInput( + controller: controller, + readOnly: true, + ), + ), + ); + await tester.pump(); + + final RenderEditable renderEditable = + _findRenderEditable(tester.renderObject(find.byType(EditableText))); + final Offset startGlyph = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 0)) + .center, + ); + final Offset endOfHello = renderEditable.localToGlobal( + renderEditable + .getLocalRectForCaret(const TextPosition(offset: 5)) + .center, + ); + + final TestGesture gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: startGlyph); + await gesture.down(startGlyph); + await tester.pump(); + await gesture.moveTo(endOfHello); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect( + controller.selection.isCollapsed, + isFalse, + reason: + 'A read-only field must remain selectable so text can be copied.', + ); + }); + }); + + // QA (6): no Overlay ancestor — typing works, long-press does not crash. + group('no Overlay ancestor', () { + testWidgets('typing works and long-press does not crash', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _bareHarness( + WInput(controller: controller, placeholder: 'Type here'), + ), + ); + + await tester.enterText(find.byType(EditableText), 'hello'); + await tester.pump(); + expect(controller.text, 'hello'); + + // A long-press must degrade gracefully (selection UI suppressed), not + // throw on the missing Overlay. + final Offset center = tester.getCenter(find.byType(EditableText)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(const Duration(milliseconds: 600)); + await gesture.up(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + }); + + // QA (7): platform-adaptive handle controls are wired per platform, and the + // bare harness never trips debugCheckHasMaterialLocalizations. + group('platform-adaptive handle controls', () { + final Map expected = { + TargetPlatform.iOS: cupertinoTextSelectionHandleControls.runtimeType, + TargetPlatform.android: materialTextSelectionHandleControls.runtimeType, + TargetPlatform.macOS: + cupertinoDesktopTextSelectionHandleControls.runtimeType, + TargetPlatform.linux: desktopTextSelectionHandleControls.runtimeType, + TargetPlatform.fuchsia: materialTextSelectionHandleControls.runtimeType, + TargetPlatform.windows: desktopTextSelectionHandleControls.runtimeType, + }; + + for (final TargetPlatform platform in expected.keys) { + testWidgets('wires the right controls on $platform', (tester) async { + debugDefaultTargetPlatformOverride = platform; + try { + final controller = TextEditingController(text: 'sample'); + addTearDown(controller.dispose); + + // Bare harness + Overlay: the *HandleControls mixin must keep the + // toolbar off the buildToolbar path so MaterialLocalizations is + // never required under this Material-free root. + await tester.pumpWidget( + _overlayHarness( + WInput(controller: controller), + ), + ); + + final EditableText editable = + tester.widget(find.byType(EditableText)); + expect( + editable.selectionControls.runtimeType, + expected[platform], + reason: 'WInput must wire the $platform handle controls.', + ); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + } + }); + }); +} + +/// Walks down from [object] to the first [RenderEditable] (the EditableText's +/// own render box sits a couple of layers below its element). +RenderEditable _findRenderEditable(RenderObject object) { + if (object is RenderEditable) { + return object; + } + RenderEditable? found; + object.visitChildren((child) { + found ??= _findRenderEditable(child); + }); + if (found == null) { + throw StateError('No RenderEditable found in the subtree.'); + } + return found!; +} From 28032a274277d007756246c8b787356b70db2b59 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 23 Jun 2026 21:27:49 +0300 Subject: [PATCH 2/6] docs(w-input): document native selection and platform-adaptive handles Update the w-input reference, the wind-ui SKILL.md WInput row (version 2.5.0 -> 2.6.0), and the forms reference to describe native text selection (mouse-drag, double-tap word, whole-box tap) and the platform-adaptive handles, replacing the stale Cupertino-on-all-platforms note. Add a web-reality note: Flutter renders selection to a canvas (no native browser input); right-click shows the browser menu by default, and an app opting into WInput's own toolbar calls BrowserContextMenu.disableContextMenu() at startup. --- doc/widgets/w-input.md | 5 ++++- skills/wind-ui/SKILL.md | 6 +++--- skills/wind-ui/references/forms.md | 2 ++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/doc/widgets/w-input.md b/doc/widgets/w-input.md index 5946909..b6ad3ae 100644 --- a/doc/widgets/w-input.md +++ b/doc/widgets/w-input.md @@ -167,7 +167,10 @@ WInput( When the field is `enabled: false`, it is non-editable and reports `isReadOnly` in the semantics tree. > [!NOTE] -> `WInput` uses Cupertino-style selection handles on all platforms. The long-press selection toolbar and handles require an `Overlay` ancestor. Under a bare root without an `Overlay` (unusual in practice), typing, cursor movement, and focus still work; only the selection toolbar does not appear. +> `WInput` supports native text selection (drag-select, double-tap word, long-press) with platform-adaptive selection handles: Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, and desktop on Linux/Windows. The long-press selection toolbar and handles require an `Overlay` ancestor. Under a bare root without an `Overlay` (unusual in practice), typing, cursor movement, focus, and tapping anywhere in the box to focus the field still work; only the selection toolbar and handles are suppressed. + +> [!NOTE] +> **Web:** Flutter renders to a canvas (CanvasKit or Skwasm) and does not use a native browser ``. Mouse drag-select, double-click word, and keyboard copy (Ctrl+C / Cmd+C) all work through Flutter's own selection layer. Right-click opens the browser's native context menu by default (not WInput's selection toolbar). Apps that want WInput's selection toolbar on right-click can call `BrowserContextMenu.disableContextMenu()` once at startup; this is an app-level opt-in and not WInput behavior. No magnifier is shown on web. > [!WARNING] > Passing both `value` and `controller` at the same time throws an `AssertionError` in debug mode. Use either `value` + `onChanged` (controlled) or `controller` (external), not both. diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 86c61af..5a65474 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -3,10 +3,10 @@ name: wind-ui description: "fluttersdk_wind 1.1: utility-first Flutter styling with Tailwind-syntax className strings. 22 public widgets (WDiv, WText, WButton, WInput, WSelect, WCheckbox, WDatePicker, WPopover, WAnchor, WIcon, WImage, WSvg, WSpacer, WBreakpoint, WDynamic, WKeyboardActions, WindAnimationWrapper + 5 WForm* wrappers) consume className through a 19-parser pipeline (19 implementation files organized into 12 token families for teaching) that emits a cached immutable WindStyle. Prefixes stack freely (dark: / hover: / focus: / md: / lg: / ios: / android: / web: / mobile: / selected: / loading: / disabled: / readonly: / error: / checked: / custom). Last class wins; unknown tokens fail silently. Every color token (bg-, text-, border-, ring-, shadow-, fill-) needs a dark: pair in the same className. TRIGGER when: writing or editing any UI in a Flutter app that depends on `fluttersdk_wind`; any className string; any W-prefix widget; any WindTheme / WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend / API / state-management work that does not touch a widget tree; Flutter projects that do not have fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) without Wind content inside them." when_to_use: | Any task that produces, modifies, or audits Wind-styled Flutter UI: composing a className string, picking the right W-widget for a use case, integrating with a Form / FormField, customizing WindThemeData, wiring dark-mode pairs, debugging an unexpected layout, recovering from RenderFlex overflow, building a popover or dropdown, rendering a JSON tree via WDynamic, wiring Wind.installDebugResolver for kDebugMode tooling, or migrating a Tailwind className from web. Apply BEFORE writing the first line of UI in a Wind-using file, not as an audit pass. -version: 2.5.0 +version: 2.6.0 --- - + # Wind UI 1.1 @@ -82,7 +82,7 @@ The headline 20 (table below) are the ones an agent reaches for daily. Two more | `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)`. | | `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | | `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | -| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | +| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are platform-adaptive (Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, desktop on Linux/Windows). An `Overlay` ancestor is required for selection handles and the selection toolbar to appear; without one, typing and cursor movement still work but handles and toolbar are suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | | `WCheckbox` | Form (raw) | none | Boolean checkbox; auto-injects `checked:` state when `value: true`. Default className includes `checked:bg-primary` (requires a `primary` color in theme). | | `WSelect` | Form (raw) | `options: List>` | Single OR multi-select dropdown with overlay. Supports searchable, async search, async create (tagging), pagination via `onLoadMore` + `hasMore`. Auto-flips upward when bottom space < `maxMenuHeight`. | | `WDatePicker` | Form (raw) | none | Single date OR `DateRange` mode; popover-based calendar; min/max constraints. | diff --git a/skills/wind-ui/references/forms.md b/skills/wind-ui/references/forms.md index 7a9486c..cb7917c 100644 --- a/skills/wind-ui/references/forms.md +++ b/skills/wind-ui/references/forms.md @@ -25,6 +25,8 @@ Building forms with validation. Use this file when picking between raw `W*` and Both families share the same className surface and visual output. The split is purely about validation flow. +`WInput` (and by extension `WFormInput`) uses native text selection: mouse-drag selects a substring, double-click or double-tap selects a word, and tapping the box moves the cursor. Selection handles are platform-adaptive (Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, desktop on Linux/Windows). An `Overlay` ancestor is required for selection handles and the selection toolbar to render; without one, typing and cursor movement still work but handles and toolbar are suppressed. Both widget families are Material-free at their core (`EditableText`), so no Material ancestor is required for the input itself. + --- ## 2. Standard form skeleton From af524fdce5a121b072ddc341f95cec2cb6af816d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 23 Jun 2026 21:27:49 +0300 Subject: [PATCH 3/6] docs(example): add selectable WInput demo to input_basic Add a Text Selection section with a prefilled read-only WInput and a caption explaining drag-to-select, double-tap word, and platform-adaptive handles, so the demo gallery showcases the native selection behavior. --- example/lib/pages/forms/input_basic.dart | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/example/lib/pages/forms/input_basic.dart b/example/lib/pages/forms/input_basic.dart index 6c18f90..92ab365 100644 --- a/example/lib/pages/forms/input_basic.dart +++ b/example/lib/pages/forms/input_basic.dart @@ -14,6 +14,11 @@ class _InputBasicExamplePageState extends State { String _email = ''; bool _hasError = false; + // Realistic prefilled text for the selection demo. + static const _selectionSample = + 'Order #ORD-20240614 was shipped to 42 Maple Street, Portland OR 97201. ' + 'Expected delivery: Monday 17 June. Tracking: 1Z999AA10123456784.'; + static const _inputCls = ''' w-full px-3 py-2 rounded-lg bg-white dark:bg-slate-800 @@ -111,6 +116,58 @@ class _InputBasicExamplePageState extends State { ], ), ), + ExampleSection( + title: 'Text Selection', + description: + 'Mouse-drag to select a range, double-click to select a word, ' + 'or long-press on mobile for platform-adaptive handles. ' + 'The field is read-only so the value stays intact while you explore.', + child: WDiv( + className: 'flex flex-col gap-3', + children: [ + WInput( + readOnly: true, + value: _selectionSample, + className: ''' + w-full px-3 py-2 rounded-lg + bg-white dark:bg-slate-800 + border border-slate-300 dark:border-slate-600 + text-slate-800 dark:text-slate-200 + focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 + ''', + ), + WDiv( + className: ''' + flex flex-row items-start gap-2 px-3 py-2 rounded-lg + bg-blue-50 dark:bg-blue-950 + border border-blue-200 dark:border-blue-800 + ''', + children: [ + WIcon( + Icons.info_outline_rounded, + className: + 'text-blue-500 dark:text-blue-400 w-4 h-4 mt-0.5', + ), + WDiv( + className: 'flex flex-col gap-1', + children: [ + WText( + 'Native text selection', + className: + 'text-sm font-semibold text-blue-700 dark:text-blue-300', + ), + WText( + 'Desktop / web: drag to select, double-click for a word, Ctrl+A for all. ' + 'Mobile: long-press for handles, then drag.', + className: 'text-xs text-blue-600 dark:text-blue-400', + ), + ], + ), + ], + ), + ], + ), + ), ExampleSection( title: 'Disabled vs Read-only', description: From 252055ee696645c2a9ad3e9b0bbc79c1893e0b35 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 23 Jun 2026 21:54:06 +0300 Subject: [PATCH 4/6] fix(w-input): keep selection handles cupertino-only (drop material.dart import) Restore w_input.dart to its deliberate cupertino-only state from #106 (the only widget file that imports cupertino.dart and not material.dart). The platform-adaptive handle switch added the package:flutter/material.dart import back for materialTextSelectionHandleControls / desktopTextSelectionHandleControls, which #106 explicitly dropped. WInput now wires cupertinoTextSelectionHandleControls on every platform (unchanged from the 1.1.0 shipped behavior), keeping the widget cupertino-only while the gesture-builder fix (the actual web mouse drag-select bug) is untouched. Tests assert Cupertino handles on every TargetPlatform under a bare Material-free Overlay harness. --- lib/src/widgets/w_input.dart | 49 +++++------------------- test/widgets/w_input/selection_test.dart | 28 +++++--------- 2 files changed, 20 insertions(+), 57 deletions(-) diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index 69df2eb..702c73d 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -1,17 +1,5 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; -// Material is imported for two platform-adaptive selection-handle CONSTANTS -// only: `materialTextSelectionHandleControls` and -// `desktopTextSelectionHandleControls` live in material.dart (their Cupertino -// peers live in cupertino.dart). WInput remains Material-free: no Material -// widget, Theme.of, or MaterialLocalizations is used; these constants mix in -// `TextSelectionHandleControls`, so they paint handles without a Material -// ancestor and route the toolbar through `contextMenuBuilder` instead of -// `buildToolbar`. -import 'package:flutter/material.dart' - show - desktopTextSelectionHandleControls, - materialTextSelectionHandleControls; import 'package:flutter/services.dart'; import '../parser/wind_parser.dart'; @@ -558,14 +546,16 @@ class _WInputState extends State rendererIgnoresPointer: true, // Selection UI needs an Overlay to host its toolbar/handles, and a // disabled field must expose none of it (a read-only field stays - // selectable so its text can be copied). The handle controls are - // platform-adaptive `*HandleControls` CONSTANTS: each mixes in - // [TextSelectionHandleControls], which suppresses the legacy - // `buildToolbar` (so the Material-free `contextMenuBuilder` stays the only - // toolbar path and the MaterialLocalizations assert is never hit) and is - // NOT deprecated (so `dart analyze` stays clean). - selectionControls: - hasOverlay && widget.enabled ? _platformSelectionControls() : null, + // selectable so its text can be copied). Cupertino handle controls on + // every platform keep WInput cupertino-only (no `package:flutter/material.dart` + // import): `cupertinoTextSelectionHandleControls` mixes in + // [TextSelectionHandleControls], which suppresses the legacy `buildToolbar` + // (so the Material-free `contextMenuBuilder` stays the only toolbar path and + // the MaterialLocalizations assert is never hit) and is NOT deprecated (so + // `dart analyze` stays clean). + selectionControls: hasOverlay && widget.enabled + ? cupertinoTextSelectionHandleControls + : null, enableInteractiveSelection: hasOverlay && widget.enabled, contextMenuBuilder: hasOverlay && widget.enabled ? _buildContextMenu : null, @@ -850,25 +840,6 @@ class _WInputState extends State ); } - /// Picks the platform-native selection-handle controls. Each is a - /// `*HandleControls` CONSTANT that mixes in [TextSelectionHandleControls], so - /// the legacy `buildToolbar` is suppressed (the toolbar comes only from - /// [_buildContextMenu] via `contextMenuBuilder`) and no Material ancestor is - /// required to paint the drag handles. A getter-driven `defaultTargetPlatform` - /// read (not cached) keeps this honest under test platform overrides. - TextSelectionControls _platformSelectionControls() { - return switch (defaultTargetPlatform) { - TargetPlatform.iOS => cupertinoTextSelectionHandleControls, - TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls, - TargetPlatform.android || - TargetPlatform.fuchsia => - materialTextSelectionHandleControls, - TargetPlatform.linux || - TargetPlatform.windows => - desktopTextSelectionHandleControls, - }; - } - /// Builds a Material-free selection toolbar (Cupertino chrome) whose action /// labels read from [WidgetsLocalizations], which is always resolvable (via /// `DefaultWidgetsLocalizations`) even with no Material/Cupertino ancestor. diff --git a/test/widgets/w_input/selection_test.dart b/test/widgets/w_input/selection_test.dart index f5d365d..7138f6a 100644 --- a/test/widgets/w_input/selection_test.dart +++ b/test/widgets/w_input/selection_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; @@ -312,21 +311,13 @@ void main() { }); }); - // QA (7): platform-adaptive handle controls are wired per platform, and the - // bare harness never trips debugCheckHasMaterialLocalizations. - group('platform-adaptive handle controls', () { - final Map expected = { - TargetPlatform.iOS: cupertinoTextSelectionHandleControls.runtimeType, - TargetPlatform.android: materialTextSelectionHandleControls.runtimeType, - TargetPlatform.macOS: - cupertinoDesktopTextSelectionHandleControls.runtimeType, - TargetPlatform.linux: desktopTextSelectionHandleControls.runtimeType, - TargetPlatform.fuchsia: materialTextSelectionHandleControls.runtimeType, - TargetPlatform.windows: desktopTextSelectionHandleControls.runtimeType, - }; - - for (final TargetPlatform platform in expected.keys) { - testWidgets('wires the right controls on $platform', (tester) async { + // QA (7): WInput wires Cupertino handle controls on EVERY platform (it stays + // cupertino-only / no package:flutter/material.dart import), and the bare + // harness never trips debugCheckHasMaterialLocalizations on any platform. + group('cupertino-only handle controls', () { + for (final TargetPlatform platform in TargetPlatform.values) { + testWidgets('wires Cupertino handle controls on $platform', + (tester) async { debugDefaultTargetPlatformOverride = platform; try { final controller = TextEditingController(text: 'sample'); @@ -345,8 +336,9 @@ void main() { tester.widget(find.byType(EditableText)); expect( editable.selectionControls.runtimeType, - expected[platform], - reason: 'WInput must wire the $platform handle controls.', + cupertinoTextSelectionHandleControls.runtimeType, + reason: 'WInput must wire Cupertino handle controls on every ' + 'platform (cupertino-only, no material.dart import).', ); expect(tester.takeException(), isNull); } finally { From 42765f86ce571b7cec6e8c03b4f937b7a1ed922c Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 23 Jun 2026 21:54:06 +0300 Subject: [PATCH 5/6] docs(w-input): note cupertino-only selection handles Update the w-input doc, the selectable example caption, the wind-ui skill + forms reference, and the CHANGELOG to state selection handles are Cupertino-style on all platforms (cupertino-only, no material.dart import), replacing the platform-adaptive wording. --- CHANGELOG.md | 2 +- doc/widgets/w-input.md | 2 +- example/lib/pages/forms/input_basic.dart | 2 +- skills/wind-ui/SKILL.md | 2 +- skills/wind-ui/references/forms.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b6922..ab1a372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Fixed -- `WInput` now supports native text selection: mouse drag-select, double-tap word, and long-press all work again. The Material-free rewrite (#106) had dropped the selection gesture layer, so only keyboard select-all (CTRL+A) worked and dragging on web selected nothing. The field now uses the framework's canonical selectable-input recipe (the one `CupertinoTextField` uses): `_WInputState` implements `TextSelectionGestureDetectorBuilderDelegate`, the whole decorated box is wrapped by a `TextSelectionGestureDetectorBuilder` (so a tap anywhere focuses and a drag over the glyphs selects), and `EditableText.rendererIgnoresPointer` is `true` so the gesture layer is the only pointer handler. Selection handles are platform-adaptive (`*HandleControls`: Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, desktop on Linux/Windows); each mixes in `TextSelectionHandleControls`, so the toolbar still flows through the Material-free `contextMenuBuilder` and no `Overlay`-less long-press throws. Read-only fields stay selectable; disabled fields stay fully inert. (`lib/src/widgets/w_input.dart`; covered by `test/widgets/w_input/selection_test.dart`.) +- `WInput` now supports native text selection: mouse drag-select, double-tap word, and long-press all work again. The Material-free rewrite (#106) had dropped the selection gesture layer, so only keyboard select-all (CTRL+A) worked and dragging on web selected nothing. The field now uses the framework's canonical selectable-input recipe (the one `CupertinoTextField` uses): `_WInputState` implements `TextSelectionGestureDetectorBuilderDelegate`, the whole decorated box is wrapped by a `TextSelectionGestureDetectorBuilder` (so a tap anywhere focuses and a drag over the glyphs selects), and `EditableText.rendererIgnoresPointer` is `true` so the gesture layer is the only pointer handler. Selection handles stay Cupertino-style on all platforms (unchanged from 1.1.0), keeping `WInput` cupertino-only with no `package:flutter/material.dart` import; `cupertinoTextSelectionHandleControls` mixes in `TextSelectionHandleControls`, so the toolbar still flows through the Material-free `contextMenuBuilder` and no `Overlay`-less long-press throws. Read-only fields stay selectable; disabled fields stay fully inert. (`lib/src/widgets/w_input.dart`; covered by `test/widgets/w_input/selection_test.dart`.) - `WText` with no color in its own `className` now inherits an ancestor `DefaultTextStyle` color (the CSS text-color cascade) before falling back to the platform-brightness baseline. A parent `WDiv` with a `text-*` class publishes its color through `DefaultTextStyle.merge`, but `WText` previously ignored it and forced `Colors.white`/`Colors.black` from the OS platform brightness. That made colorless text vanish whenever the app theme disagreed with the OS theme: a secondary/outline button whose text color lives on the container (e.g. a dialog Cancel button) rendered an invisible label in a light app theme on a dark-mode OS. The brightness-aware baseline still applies only when no ancestor supplies a color (bare text with no Material ancestor), preserving the no-yellow-underline guarantee. (`lib/src/widgets/w_text.dart`; covered by `WText baseline rendering > inherits an ancestor color (CSS cascade)`.) ## [1.1.0] - 2026-06-17 diff --git a/doc/widgets/w-input.md b/doc/widgets/w-input.md index b6ad3ae..c7d4f82 100644 --- a/doc/widgets/w-input.md +++ b/doc/widgets/w-input.md @@ -167,7 +167,7 @@ WInput( When the field is `enabled: false`, it is non-editable and reports `isReadOnly` in the semantics tree. > [!NOTE] -> `WInput` supports native text selection (drag-select, double-tap word, long-press) with platform-adaptive selection handles: Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, and desktop on Linux/Windows. The long-press selection toolbar and handles require an `Overlay` ancestor. Under a bare root without an `Overlay` (unusual in practice), typing, cursor movement, focus, and tapping anywhere in the box to focus the field still work; only the selection toolbar and handles are suppressed. +> `WInput` supports native text selection (drag-select, double-tap word, long-press) with Cupertino-style selection handles on all platforms. This keeps `WInput` cupertino-only (no `package:flutter/material.dart` import) and consistent with the rest of Wind's own look. The long-press selection toolbar and handles require an `Overlay` ancestor. Under a bare root without an `Overlay` (unusual in practice), typing, cursor movement, focus, and tapping anywhere in the box to focus the field still work; only the selection toolbar and handles are suppressed. > [!NOTE] > **Web:** Flutter renders to a canvas (CanvasKit or Skwasm) and does not use a native browser ``. Mouse drag-select, double-click word, and keyboard copy (Ctrl+C / Cmd+C) all work through Flutter's own selection layer. Right-click opens the browser's native context menu by default (not WInput's selection toolbar). Apps that want WInput's selection toolbar on right-click can call `BrowserContextMenu.disableContextMenu()` once at startup; this is an app-level opt-in and not WInput behavior. No magnifier is shown on web. diff --git a/example/lib/pages/forms/input_basic.dart b/example/lib/pages/forms/input_basic.dart index 92ab365..6d43684 100644 --- a/example/lib/pages/forms/input_basic.dart +++ b/example/lib/pages/forms/input_basic.dart @@ -120,7 +120,7 @@ class _InputBasicExamplePageState extends State { title: 'Text Selection', description: 'Mouse-drag to select a range, double-click to select a word, ' - 'or long-press on mobile for platform-adaptive handles. ' + 'or long-press on mobile for selection handles. ' 'The field is read-only so the value stays intact while you explore.', child: WDiv( className: 'flex flex-col gap-3', diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 5a65474..ca336b2 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -82,7 +82,7 @@ The headline 20 (table below) are the ones an agent reaches for daily. Two more | `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)`. | | `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | | `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | -| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are platform-adaptive (Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, desktop on Linux/Windows). An `Overlay` ancestor is required for selection handles and the selection toolbar to appear; without one, typing and cursor movement still work but handles and toolbar are suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | +| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for selection handles and the selection toolbar to appear; without one, typing and cursor movement still work but handles and toolbar are suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | | `WCheckbox` | Form (raw) | none | Boolean checkbox; auto-injects `checked:` state when `value: true`. Default className includes `checked:bg-primary` (requires a `primary` color in theme). | | `WSelect` | Form (raw) | `options: List>` | Single OR multi-select dropdown with overlay. Supports searchable, async search, async create (tagging), pagination via `onLoadMore` + `hasMore`. Auto-flips upward when bottom space < `maxMenuHeight`. | | `WDatePicker` | Form (raw) | none | Single date OR `DateRange` mode; popover-based calendar; min/max constraints. | diff --git a/skills/wind-ui/references/forms.md b/skills/wind-ui/references/forms.md index cb7917c..8d9d8ae 100644 --- a/skills/wind-ui/references/forms.md +++ b/skills/wind-ui/references/forms.md @@ -25,7 +25,7 @@ Building forms with validation. Use this file when picking between raw `W*` and Both families share the same className surface and visual output. The split is purely about validation flow. -`WInput` (and by extension `WFormInput`) uses native text selection: mouse-drag selects a substring, double-click or double-tap selects a word, and tapping the box moves the cursor. Selection handles are platform-adaptive (Cupertino on iOS, Cupertino-desktop on macOS, Material on Android/Fuchsia, desktop on Linux/Windows). An `Overlay` ancestor is required for selection handles and the selection toolbar to render; without one, typing and cursor movement still work but handles and toolbar are suppressed. Both widget families are Material-free at their core (`EditableText`), so no Material ancestor is required for the input itself. +`WInput` (and by extension `WFormInput`) uses native text selection: mouse-drag selects a substring, double-click or double-tap selects a word, and tapping the box moves the cursor. Selection handles are Cupertino-style on all platforms (keeps `WInput` cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for selection handles and the selection toolbar to render; without one, typing and cursor movement still work but handles and toolbar are suppressed. Both widget families are Material-free at their core (`EditableText`), so no Material ancestor is required for the input itself. --- From f12fe028aa693f1e874e0c1bb8c1e4cf1aa3907b Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 23 Jun 2026 22:11:02 +0300 Subject: [PATCH 6/6] docs(w-input): correct no-Overlay wording per PR review Copilot flagged that the docs/comment claimed only the selection toolbar and handles are suppressed without an Overlay. In fact interactive selection is fully gated on an Overlay (enableInteractiveSelection and the gesture builder delegate's selectionEnabled both check Overlay.maybeOf), so without one drag-select, double-tap word, and long-press are all off too; only typing and focus work. Reword the class dartdoc, the selectionControls comment, the w-input doc note, and the wind-ui skill + forms reference to state this accurately. --- doc/widgets/w-input.md | 2 +- lib/src/widgets/w_input.dart | 19 +++++++++++-------- skills/wind-ui/SKILL.md | 2 +- skills/wind-ui/references/forms.md | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/doc/widgets/w-input.md b/doc/widgets/w-input.md index c7d4f82..893084b 100644 --- a/doc/widgets/w-input.md +++ b/doc/widgets/w-input.md @@ -167,7 +167,7 @@ WInput( When the field is `enabled: false`, it is non-editable and reports `isReadOnly` in the semantics tree. > [!NOTE] -> `WInput` supports native text selection (drag-select, double-tap word, long-press) with Cupertino-style selection handles on all platforms. This keeps `WInput` cupertino-only (no `package:flutter/material.dart` import) and consistent with the rest of Wind's own look. The long-press selection toolbar and handles require an `Overlay` ancestor. Under a bare root without an `Overlay` (unusual in practice), typing, cursor movement, focus, and tapping anywhere in the box to focus the field still work; only the selection toolbar and handles are suppressed. +> `WInput` supports native text selection (drag-select, double-tap word, long-press) with Cupertino-style selection handles on all platforms. This keeps `WInput` cupertino-only (no `package:flutter/material.dart` import) and consistent with the rest of Wind's own look. Interactive selection requires an `Overlay` ancestor (`MaterialApp` / `CupertinoApp` / `WidgetsApp` all provide one). Under a bare root with no `Overlay` (unusual in practice), typing and focus still work, but all interactive selection (drag-select, double-tap word, long-press, the drag handles, and the toolbar) is suppressed rather than throwing. > [!NOTE] > **Web:** Flutter renders to a canvas (CanvasKit or Skwasm) and does not use a native browser ``. Mouse drag-select, double-click word, and keyboard copy (Ctrl+C / Cmd+C) all work through Flutter's own selection layer. Right-click opens the browser's native context menu by default (not WInput's selection toolbar). Apps that want WInput's selection toolbar on right-click can call `BrowserContextMenu.disableContextMenu()` once at startup; this is an app-level opt-in and not WInput behavior. No magnifier is shown on web. diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index 702c73d..2579163 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -38,11 +38,11 @@ enum InputType { /// ancestor. /// /// ### Ancestor requirements: -/// - Interactive text selection (the long-press toolbar and drag handles) -/// needs an [Overlay] ancestor. `MaterialApp`, `CupertinoApp`, and -/// `WidgetsApp` all provide one; under a custom root with no `Overlay`, -/// typing, cursor movement, and focus still work, but selection UI is -/// suppressed rather than throwing. +/// - Interactive text selection (drag-select, double-tap word, long-press, the +/// drag handles, and the toolbar) needs an [Overlay] ancestor. `MaterialApp`, +/// `CupertinoApp`, and `WidgetsApp` all provide one; under a custom root with +/// no `Overlay`, typing and focus still work, but all interactive selection +/// is suppressed rather than throwing. /// - A tap anywhere in the input box (not only on the text glyphs) focuses the /// field and fires [onTap], restoring `TextField`'s whole-box tap target. /// @@ -544,9 +544,12 @@ class _WInputState extends State // hit-tests the RenderEditable in global coords. Leaving this false would // double-handle taps (the render box and the builder both reacting). rendererIgnoresPointer: true, - // Selection UI needs an Overlay to host its toolbar/handles, and a - // disabled field must expose none of it (a read-only field stays - // selectable so its text can be copied). Cupertino handle controls on + // Interactive selection is gated on an Overlay (which hosts the + // handles/toolbar): with no Overlay, `enableInteractiveSelection` below + // is false too, so drag-select / double-tap / long-press are all off and + // only typing + focus work. A disabled field must expose none of it (a + // read-only field stays selectable so its text can be copied). Cupertino + // handle controls on // every platform keep WInput cupertino-only (no `package:flutter/material.dart` // import): `cupertinoTextSelectionHandleControls` mixes in // [TextSelectionHandleControls], which suppresses the legacy `buildToolbar` diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index ca336b2..8b4addc 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -82,7 +82,7 @@ The headline 20 (table below) are the ones an agent reaches for daily. Two more | `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)`. | | `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | | `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | -| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for selection handles and the selection toolbar to appear; without one, typing and cursor movement still work but handles and toolbar are suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | +| `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | | `WCheckbox` | Form (raw) | none | Boolean checkbox; auto-injects `checked:` state when `value: true`. Default className includes `checked:bg-primary` (requires a `primary` color in theme). | | `WSelect` | Form (raw) | `options: List>` | Single OR multi-select dropdown with overlay. Supports searchable, async search, async create (tagging), pagination via `onLoadMore` + `hasMore`. Auto-flips upward when bottom space < `maxMenuHeight`. | | `WDatePicker` | Form (raw) | none | Single date OR `DateRange` mode; popover-based calendar; min/max constraints. | diff --git a/skills/wind-ui/references/forms.md b/skills/wind-ui/references/forms.md index 8d9d8ae..c310683 100644 --- a/skills/wind-ui/references/forms.md +++ b/skills/wind-ui/references/forms.md @@ -25,7 +25,7 @@ Building forms with validation. Use this file when picking between raw `W*` and Both families share the same className surface and visual output. The split is purely about validation flow. -`WInput` (and by extension `WFormInput`) uses native text selection: mouse-drag selects a substring, double-click or double-tap selects a word, and tapping the box moves the cursor. Selection handles are Cupertino-style on all platforms (keeps `WInput` cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for selection handles and the selection toolbar to render; without one, typing and cursor movement still work but handles and toolbar are suppressed. Both widget families are Material-free at their core (`EditableText`), so no Material ancestor is required for the input itself. +`WInput` (and by extension `WFormInput`) uses native text selection: mouse-drag selects a substring, double-click or double-tap selects a word, and tapping the box moves the cursor. Selection handles are Cupertino-style on all platforms (keeps `WInput` cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Both widget families are Material-free at their core (`EditableText`), so no Material ancestor is required for the input itself. ---