From bf77a65cd2d14dcf4867ce2d32cf4e5446e7f9d8 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 12:35:45 +0300 Subject: [PATCH 01/10] fix(w-anchor): make a control reachable by keyboard and remote, and cost one stop onTap now answers ActivateIntent, which WidgetsApp raises for Enter, Space, numpad Enter, the gamepad A button and select, the D-pad centre on Android TV. No key is matched here: a key the platform adds later arrives for free, and only onTap is bound because ActivateIntent is the primary action. The other half is what made activation worth nothing on its own. WDiv wraps itself in a gestureless WAnchor whenever its className carries hover:, focus: or active:, and that wrapper was a full focus stop publishing its own state, so WAnchor(onTap:) > WDiv('focus:ring-2') cost two presses of Tab and put the ring on the node Enter could not reach. A gestureless wrapper is now a styling wrapper: not a traversal stop, and it inherits focus and disabled from the nearest anchor above it. hover stays local, because sibling divs inside one anchor highlight independently. The focus characterisation test reached for the nearest Focus ancestor, which was the decorative node, so it proved the decoration could style itself while the control the user tabs to stayed unstyled. It now focuses the node traversal would actually reach. --- lib/src/widgets/w_anchor.dart | 72 ++++- .../hover_focus_disabled_test.dart | 19 +- .../w_anchor/dpad_activation_test.dart | 297 ++++++++++++++++++ 3 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 test/widgets/w_anchor/dpad_activation_test.dart diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 8b6a5504..36ad0533 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -116,6 +116,36 @@ class _WAnchorState extends State { bool _isFocused = false; final FocusNode _focusNode = FocusNode(); + /// The keyboard and remote-control half of [WAnchor.onTap]. + /// + /// `WidgetsApp` binds `enter`, `numpadEnter`, `space`, `gameButtonA` and + /// `select` to [ActivateIntent], and `select` is the D-pad centre key on + /// Android TV, so answering the intent answers every one of those keys at + /// once. Nothing here reads a [LogicalKeyboardKey]: a `WAnchor` that matched + /// keys itself would have to be taught each new one, and would diverge from + /// whatever the platform decides activation means. + /// + /// Built once and reused, which is what [ButtonStyleButton] does through + /// `InkWell` (`material/ink_well.dart`). A map rebuilt every frame gives + /// `Actions` a new [Action] instance each time and defeats its own caching. + late final Map> _actions = >{ + ActivateIntent: CallbackAction(onInvoke: _activate), + ButtonActivateIntent: + CallbackAction(onInvoke: _activate), + }; + + /// Runs the primary action, which is [WAnchor.onTap] and only that. + /// + /// `onLongPress` and `onDoubleTap` get no binding. [ActivateIntent] means + /// "the primary action" and there is no second key for a secondary one; + /// inventing one would diverge from every button Flutter ships. + Object? _activate(Intent intent) { + if (widget.isDisabled) return null; + widget.onTap?.call(); + + return null; + } + /// Initializes the state and adds a listener to the `FocusNode` to track focus changes. @override void initState() { @@ -177,26 +207,52 @@ class _WAnchorState extends State { /// `GestureDetector` for tap events, disabling them if `widget.isDisabled` is true. @override Widget build(BuildContext context) { + final hasGestures = widget.onTap != null || + widget.onLongPress != null || + widget.onDoubleTap != null; + + // A gestureless anchor is a styling wrapper, so it inherits the interaction + // it cannot originate rather than competing for it. + // + // `WDiv` auto-wraps itself in one of these whenever its className carries + // `hover:`, `focus:` or `active:` (see `w_div.dart`'s `isInteractive` + // branch), and `WDiv` reads its state from the NEAREST provider. So before + // this inheritance, the wrapper published `isFocused: false` over a focused + // ancestor and `isDisabled: false` over a disabled one, and the element + // carrying `focus:ring-2` was the one element that could not see the focus. + // + // Hover is deliberately NOT inherited. Focus has one holder in the whole + // tree, so a descendant asking "is this focused" and a tappable ancestor + // holding focus are the same question. Hover is a pointer position, and two + // siblings inside one anchor legitimately highlight independently. + final WindAnchorState? inherited = + hasGestures ? null : WindAnchorStateProvider.of(context); + final currentState = WindAnchorState( isHovering: _isHovering, - isFocused: _isFocused, - isDisabled: widget.isDisabled, + isFocused: _isFocused || (inherited?.isFocused ?? false), + isDisabled: widget.isDisabled || (inherited?.isDisabled ?? false), customStates: widget.states, ); - final hasGestures = widget.onTap != null || - widget.onLongPress != null || - widget.onDoubleTap != null; - - // Focus is always present, needed for focus: class prefix to work + // Focus is always present, needed for focus: class prefix to work. + // + // A gestureless wrapper keeps the node but stops competing for it. It is + // not a traversal stop, because one control has to cost one press of the + // remote: before this, `WAnchor(onTap:) > WDiv('focus:ring-2')` cost two, + // and the ring was on the second one while the gesture was on the first. + // The node itself stays, because `FocusNode.hasFocus` covers descendants + // and that is what draws the ring around a `WInput` inside a styled div. Widget innerChild = Focus( focusNode: _focusNode, - canRequestFocus: !widget.isDisabled, + canRequestFocus: !widget.isDisabled && (hasGestures || inherited == null), child: widget.child, ); // Only wrap with GestureDetector if there are actual gesture callbacks if (hasGestures) { + innerChild = Actions(actions: _actions, child: innerChild); + innerChild = GestureDetector( // Translucent so the whole anchor bounds are tappable, not only the // opaque descendants. The GestureDetector defaults to diff --git a/test/interaction/hover_focus_disabled_test.dart b/test/interaction/hover_focus_disabled_test.dart index 4837361b..e02aba73 100644 --- a/test/interaction/hover_focus_disabled_test.dart +++ b/test/interaction/hover_focus_disabled_test.dart @@ -101,12 +101,21 @@ void main() { const Color(0xFFFFFFFF), ); - // Request focus on the Focus node WAnchor installs around its child. - final focusFinder = find + // Focus the node a keyboard or a remote would actually reach, which is + // the outermost one: the anchor carrying `onTap`. + // + // This used to take `.first`, the NEAREST ancestor, which is the + // gestureless anchor `WDiv` wraps itself in for its own `focus:` classes. + // Focusing that one proved the decoration could style itself while the + // control the user tabs to stayed unstyled, which is the shape this + // change removes: the styled node is no longer independently focusable, + // so `requestFocus` on it now does nothing at all. + final Iterable nodes = find .ancestor(of: find.text('Focus me'), matching: find.byType(Focus)) - .first; - final focusWidget = tester.widget(focusFinder); - focusWidget.focusNode!.requestFocus(); + .evaluate() + .map((Element e) => (e.widget as Focus).focusNode) + .whereType(); + nodes.firstWhere((FocusNode n) => n.canRequestFocus).requestFocus(); await tester.pumpAndSettle(); expect( diff --git a/test/widgets/w_anchor/dpad_activation_test.dart b/test/widgets/w_anchor/dpad_activation_test.dart new file mode 100644 index 00000000..434fb4e5 --- /dev/null +++ b/test/widgets/w_anchor/dpad_activation_test.dart @@ -0,0 +1,297 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// What a keyboard and a television remote can reach. +/// +/// Both arrive through the same door. `WidgetsApp` binds `enter`, `space`, +/// `numpadEnter`, `gameButtonA` and `select` to `ActivateIntent`, and `select` +/// is the D-pad centre key on Android TV, so an anchor that answers +/// `ActivateIntent` answers every one of them at once. +/// +/// The second group is the half that is easy to miss. Activation is worth +/// nothing if the focus ring and the gesture live on different nodes, and +/// before this change they did: `WDiv` auto-wraps itself in a gestureless +/// `WAnchor` whenever its className carries `focus:`, so the ring belonged to a +/// descendant of the tappable node and lit only when the tappable node did not. +void main() { + setUp(WindParser.clearCache); + + /// Pumps [child] under a real app, which is load-bearing here rather than + /// boilerplate. + /// + /// `WidgetsApp` is what installs `defaultShortcuts`, the table that turns a + /// key press into an [ActivateIntent]. Under a bare `Directionality` the key + /// never becomes an intent and every activation assertion below fails while + /// the implementation is correct. + Future pump(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: Center(child: child)), + ), + ), + ); + } + + /// Every focus node in the tree that traversal would stop on. + List traversalStops(WidgetTester tester) { + return tester + .widgetList(find.byType(Focus)) + .map((Focus f) => f.focusNode) + .whereType() + .where((FocusNode n) => n.canRequestFocus && !n.skipTraversal) + .toList(); + } + + group('activation', () { + for (final (String name, LogicalKeyboardKey key) + in <(String, LogicalKeyboardKey)>[ + ('the D-pad centre', LogicalKeyboardKey.select), + ('Enter', LogicalKeyboardKey.enter), + ('the numeric keypad Enter', LogicalKeyboardKey.numpadEnter), + ('Space', LogicalKeyboardKey.space), + ('the gamepad A button', LogicalKeyboardKey.gameButtonA), + ]) { + testWidgets('$name activates a focused anchor', (tester) async { + int taps = 0; + + await pump( + tester, + WAnchor(onTap: () => taps++, child: const WText('Play')), + ); + + traversalStops(tester).single.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(key); + await tester.pump(); + + expect(taps, 1); + }); + } + + testWidgets('a disabled anchor stays inert', (tester) async { + int taps = 0; + + await pump( + tester, + WAnchor( + onTap: () => taps++, + isDisabled: true, + child: const WText('Play'), + ), + ); + + // It is not a traversal stop either: `canRequestFocus` is already gated + // on `isDisabled`. Force focus onto its node anyway, so the assertion + // below is about the action map rather than about reachability. + final FocusNode node = tester + .widget( + find + .descendant( + of: find.byType(WAnchor), + matching: find.byType(Focus), + ) + .first, + ) + .focusNode!; + node.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + + expect(taps, 0); + }); + + testWidgets('an anchor with no gesture swallows nothing', (tester) async { + // The key has to keep travelling, or a styling-only wrapper around a real + // control would eat that control's activation. + int taps = 0; + + await pump( + tester, + WAnchor( + onTap: () => taps++, + child: const WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WText('Clear'), + ), + ), + ); + + traversalStops(tester).single.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + + expect(taps, 1); + }); + + testWidgets('a long press has no key, and gains none', (tester) async { + // `ActivateIntent` means "the primary action". There is no second key for + // a secondary one, and inventing a binding here would diverge from every + // Flutter button. + int longPresses = 0; + + await pump( + tester, + WAnchor( + onLongPress: () => longPresses++, + child: const WText('Options'), + ), + ); + + traversalStops(tester).single.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + + expect(longPresses, 0); + }); + }); + + group('one control, one traversal stop', () { + testWidgets('a ring-styled div inside an anchor adds no second stop', ( + tester, + ) async { + await pump( + tester, + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WText('Clear'), + ), + ), + ); + + expect(traversalStops(tester).length, 1); + }); + + testWidgets('and the ring lights on the stop that activates', ( + tester, + ) async { + await pump( + tester, + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WText('Clear'), + ), + ), + ); + + final FocusNode stop = traversalStops(tester).single; + stop.requestFocus(); + await tester.pump(); + + // Read through the state the styling layer actually consumes rather than + // through a rendered colour: `WDiv` resolves `focus:` from the nearest + // `WindAnchorStateProvider`, so that is where the answer has to be right. + final BuildContext inner = tester.element(find.byType(WText)); + expect(WindAnchorStateProvider.of(inner)?.isFocused, isTrue); + }); + + testWidgets('a bare ring-styled div is still focusable on its own', ( + tester, + ) async { + // Nothing above it to inherit from, so it keeps the node it always had. + // A div carrying `focus:` outside any anchor is how a consumer styles a + // custom control, and removing its stop would make that control + // unreachable. + await pump( + tester, + const WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WText('Standalone'), + ), + ); + + expect(traversalStops(tester).length, 1); + }); + + testWidgets('a focusable descendant still reports up through the wrapper', ( + tester, + ) async { + // The search-field shape: a ring-styled div wrapping a text input. This + // one was never broken and must stay that way. `FocusNode.hasFocus` + // covers descendants, so the wrapper reports focus while the input holds + // it, and the ring is drawn around the field the user is typing in. + await pump( + tester, + const WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WInput(placeholder: 'Search'), + ), + ); + + await tester.tap(find.byType(WInput)); + await tester.pump(); + + final BuildContext inner = tester.element(find.byType(WInput)); + expect(WindAnchorStateProvider.of(inner)?.isFocused, isTrue); + }); + + testWidgets('nested hover stays local to the div that is hovered', ( + tester, + ) async { + // Focus is inherited, hover is not, and the asymmetry is deliberate. + // Focus has one holder in the whole tree; hover is a pointer position and + // two siblings inside one anchor legitimately highlight independently. + await pump( + tester, + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'p-2 hover:bg-red-500', child: WText('Left')), + WDiv(className: 'p-2 hover:bg-blue-500', child: WText('Right')), + ], + ), + ), + ); + + final TestPointer pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding( + pointer.hover(tester.getCenter(find.text('Left'))), + ); + await tester.pump(); + + final BuildContext left = tester.element(find.text('Left')); + final BuildContext right = tester.element(find.text('Right')); + + expect(WindAnchorStateProvider.of(left)?.isHovering, isTrue); + expect(WindAnchorStateProvider.of(right)?.isHovering, isFalse); + }); + + testWidgets('a disabled anchor disables the div that styles it', ( + tester, + ) async { + // The same shadowing bug in a third state, fixed by the same inheritance. + // Before this change the gestureless wrapper published `isDisabled: + // false` over a disabled ancestor, so `disabled:` never fired on the + // element carrying it. + await pump( + tester, + WAnchor( + onTap: () {}, + isDisabled: true, + child: const WDiv( + className: 'p-2 disabled:opacity-50 focus:ring-2', + child: WText('Clear'), + ), + ), + ); + + final BuildContext inner = tester.element(find.byType(WText)); + expect(WindAnchorStateProvider.of(inner)?.isDisabled, isTrue); + }); + }); +} From 33d181a79e409a7fef26691525f12a575e087d23 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 12:35:54 +0300 Subject: [PATCH 02/10] docs(w-anchor): document keyboard activation and the one-stop rule Adds a Keyboard and Remote Control section covering which keys reach onTap, why only onTap is bound, and the two shapes the traversal change deliberately leaves alone: a standalone ring-styled WDiv keeps its node, and a focusable descendant still lights the wrapper's ring. Names the two upstream traversal limits so they are not diagnosed as Wind bugs. --- doc/widgets/w-anchor.md | 41 +++++++++++++++++++++++++++++++++++++++++ doc/widgets/w-div.md | 2 ++ 2 files changed, 43 insertions(+) diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index 7b083524..c8f31f9e 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -7,6 +7,7 @@ The foundational state wrapper that detects user gestures (Hover, Focus, Press) - [Props](#props) - [Layout Modes](#layout-modes) - [Event Handling](#event-handling) +- [Keyboard and Remote Control](#keyboard-and-remote-control) - [State Variants](#state-variants) - [Styling Examples](#styling-examples) - [All Supported Classes](#all-supported-classes) @@ -108,6 +109,45 @@ WAnchor( ) ``` +## Keyboard and Remote Control + +A focused `WAnchor` runs its `onTap` when the user presses the activation key. `WAnchor` binds no key of its own: it answers `ActivateIntent`, which `WidgetsApp` already raises for `Enter`, `Space`, the numeric keypad `Enter`, the gamepad A button and `select`. `select` is the D-pad centre on Android TV and the click on the Apple TV remote, so one binding covers a keyboard, a gamepad and a remote, and a key the platform adds later arrives for free. + +```dart +// Reachable by Tab, activated by Enter, Space or the D-pad centre. +WAnchor(onTap: play, child: const WText('Play')) +``` + +Only `onTap` is bound. `ActivateIntent` means the primary action and there is no second key for a secondary one, so `onLongPress` and `onDoubleTap` stay pointer-only, exactly as they are on Flutter's own buttons. + +### One control is one stop + +A control has to cost one press of the remote, so only an anchor that carries a gesture is a traversal stop. A gestureless `WAnchor` is a styling wrapper, and it inherits `focus` and `disabled` from the nearest anchor above it instead of publishing its own. + +This matters because `WDiv` wraps itself in a gestureless `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`. Without the inheritance, the element carrying `focus:ring-2` would be the one element that could not see the focus: + +```dart +// One stop. Tab lands on the anchor, the ring is drawn on the div, and +// Enter fires onTap. +WAnchor( + onTap: clear, + child: const WDiv( + className: 'p-2 rounded-full focus:ring-2 focus:ring-blue-500', + child: WIcon(Icons.close), + ), +) +``` + +Two shapes are unaffected. A `WDiv` carrying `focus:` with no anchor above it keeps its own focus node, because that is how a consumer styles a custom control. And a focusable descendant still lights the wrapper's ring: `FocusNode.hasFocus` covers descendants, so a `WInput` inside a ring-styled `WDiv` draws the ring around the field the user is typing in. + +`hover` is deliberately not inherited. Focus has one holder in the whole tree, so a descendant asking "is this focused" and a tappable ancestor holding focus are the same question. Hover is a pointer position, and two siblings inside one anchor legitimately highlight independently. + +### What is not here + +Wind ships no `FocusTraversalPolicy`. Directional movement is Flutter's default `DirectionalFocusTraversalPolicyMixin`, which scopes left and right to the enclosing horizontal `Scrollable` and up and down to the vertical one, so a stack of horizontal rails behaves reasonably without configuration. Focus memory per region, edge behaviour (wrap, stop or leave) and ordering beyond geometry are a consumer concern today; reach for `FocusTraversalGroup` with your own policy. + +One upstream limit is worth knowing before it is diagnosed as a Wind bug: directional traversal cannot reach a list item that has not been built, so focus stops at the edge of a lazy list's cache extent ([flutter/flutter#91741](https://github.com/flutter/flutter/issues/91741)), and it can land on a cached item that is scrolled out of sight ([flutter/flutter#91795](https://github.com/flutter/flutter/issues/91795)). + ## State Variants `WAnchor` enables several state prefixes for all Wind widgets in its subtree. This allows you to define complex interactive styles easily. @@ -160,6 +200,7 @@ While `WAnchor` does not take a `className`, it facilitates the use of these sta | Interaction | `hover:`, `focus:`, `disabled:` | | Custom States | Any value passed to the `states` prop (e.g., `active:`, `error:`) | | Gestures | Enables `onTap`, `onLongPress`, `onDoubleTap` | +| Keys | `onTap` also runs on `ActivateIntent` (`Enter`, `Space`, gamepad A, D-pad `select`) | ## Customizing Theme diff --git a/doc/widgets/w-div.md b/doc/widgets/w-div.md index c2176324..8b74e5e3 100644 --- a/doc/widgets/w-div.md +++ b/doc/widgets/w-div.md @@ -116,6 +116,8 @@ Precedence: inline `backgroundColor` wins over any `bg-*` / `dark:bg-*` resolved That wrap is styling only: the auto-wrapped `WAnchor` carries no gesture, so it publishes no `button` node to assistive technology. A `WDiv` with `hover:bg-slate-100` styles on hover and still announces as whatever its content is, rather than as a control that does nothing when activated. +It is not a keyboard or remote stop either. Inside a tappable `WAnchor` the wrapper inherits that anchor's `focus` and `disabled` rather than claiming a stop of its own, so the control is reached once and the ring is drawn on the element the user activates. Standing alone, with no anchor above it, the `WDiv` keeps its own focus node and stays reachable. `hover` is never inherited, so sibling divs inside one anchor still highlight independently. See [WAnchor](./w-anchor.md#keyboard-and-remote-control). + For direct gesture support (taps, long presses) or to create semantic buttons, use [WAnchor](w-anchor.md) or [WButton](w-button.md). From 2f957c765b7e51bf81de25c34af31e209f722194 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 12:35:54 +0300 Subject: [PATCH 03/10] docs(example): show keyboard activation on the WAnchor page A button the visitor can Tab to and activate with Enter, styled with the ring on the inner WDiv so the page demonstrates the one-stop shape rather than describing it. --- .../lib/pages/interactivity/anchor_basic.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/example/lib/pages/interactivity/anchor_basic.dart b/example/lib/pages/interactivity/anchor_basic.dart index fb57e254..b8f73e3f 100644 --- a/example/lib/pages/interactivity/anchor_basic.dart +++ b/example/lib/pages/interactivity/anchor_basic.dart @@ -105,6 +105,27 @@ class AnchorBasicExamplePage extends StatelessWidget { ), ), ), + ExampleSection( + title: 'Keyboard and Remote', + description: + 'Tab to this button and press Enter or Space. The same binding is the D-pad centre on Android TV. It is one stop, not two: the ring is styled on the inner WDiv and the gesture lives on the anchor.', + child: WAnchor( + onTap: () {}, + semanticLabel: 'Play', + child: WDiv( + className: ''' + px-4 py-2 rounded-lg duration-200 + bg-violet-600 dark:bg-violet-500 + hover:bg-violet-700 dark:hover:bg-violet-400 + focus:ring-2 focus:ring-violet-400 dark:focus:ring-violet-300 + ''', + child: const WText( + 'Play', + className: 'text-white font-medium', + ), + ), + ), + ), ], ); } From a7eafda00f09e40bbb7629a87373cf045cb8e89a Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 12:35:54 +0300 Subject: [PATCH 04/10] docs(skill): add Core Law 11 for keyboard and remote reachability Bumps the skill to 2.15.0 and records the widget tree change in the WAnchor reference: Actions is new, canRequestFocus now depends on whether the anchor carries a gesture, and a gestureless wrapper inherits focus and disabled but not hover. Two anti-pattern rows cover the mistakes the change makes possible: hand-rolling a Focus wrapper for reachability, and giving a ring-styled WDiv its own focus node. --- skills/wind-ui/SKILL.md | 6 +++++- skills/wind-ui/references/widgets.md | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index b12ab1df..332b0133 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -5,7 +5,7 @@ when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: compos version: 2.14.0 --- - + # Wind UI 1.5 @@ -63,6 +63,8 @@ These hold for every line of Wind code. Apply each as a hard constraint, not a s 10. **`active:` prefix is reserved but not wired.** `WAnchor` tracks hover and focus only; there is no onTapDown/onTapUp tracking. Don't rely on `active:bg-blue-700` for press feedback. Use a transient state in the consumer's controller and `states: {'pressed'}` if you genuinely need press feedback today. +11. **A gesture on `WAnchor` is reachable by keyboard and remote, and a gestureless one is not a stop.** `onTap` runs on `ActivateIntent`, which covers `Enter`, `Space`, the gamepad A button and `select` (the D-pad centre on Android TV, the click on the Apple TV remote). Only an anchor carrying a gesture is a traversal stop: the gestureless `WAnchor` that `WDiv` wraps itself in for `hover:` / `focus:` / `active:` inherits `focus` and `disabled` from the nearest anchor above it rather than claiming a second stop of its own, so `WAnchor(onTap:) > WDiv('focus:ring-2')` is one control the user tabs to once and the ring lands on the thing they activate. `hover` stays local, because two siblings inside one anchor legitimately highlight independently. Wind ships no traversal policy; directional movement is Flutter's default, and `FocusTraversalGroup` is the consumer's tool for region memory and edge behaviour. + ## 2. The 27 public widgets (+ WindRecipe) at a glance `fluttersdk_wind` v1 ships 27 public widgets plus the `WindRecipe` / `WindSlotRecipe` variant-composition primitives, all imported from the single barrel `package:fluttersdk_wind/fluttersdk_wind.dart`. No sub-barrels exist; do not write `import 'package:fluttersdk_wind/widgets.dart'`. @@ -405,6 +407,8 @@ Compact catalog of consistent footguns. Each entry: what's wrong, why, the corre | `WText` with `truncate` inside Row without bounded width | Overflow | wrap in `WDiv(className: 'flex-1')` | | Putting `dark:` peers at the bottom of a long className | Hard to audit; missing pairs slip through | group beside the light variant on the same line | | `active:bg-blue-700` for press feedback | Not wired (Core Law §10); WAnchor tracks hover and focus only | track press in consumer state, pass via `states: {'pressed'}` if needed | +| Adding a `Focus` or `Shortcuts` wrapper to make a `WAnchor` keyboard-reachable | Already wired (Core Law §11); `onTap` answers `ActivateIntent` | pass `onTap` and let `Enter` / `Space` / D-pad `select` reach it | +| Expecting `focus:ring-*` on a `WDiv` inside a tappable `WAnchor` to need its own focus node | The gestureless wrapper inherits focus from the anchor (Core Law §11) | style the div, put the gesture on the anchor, leave the nodes alone | | Inline `Padding(padding: EdgeInsets.all(16))` around a `WDiv` | Duplicates work | move the padding into the `WDiv` className as `p-4` | | Asserting `uppercase` output for Turkish in a bare `pumpWidget` | Casing reads the ambient locale, and with no `Localizations` ancestor it falls back to Dart's locale-independent rules, so the assertion measures the fallback | wrap the subtree in `Localizations(locale: Locale('tr'), delegates: [DefaultWidgetsLocalizations.delegate], ...)` | diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index ac883a52..a3c84826 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -252,13 +252,18 @@ Three accessibility paths, checked in this order: 2. No label and no gesture: no node of its own. 3. No label, with a gesture: `MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` → the rest below. -The rest, in all three: `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → `Focus(canRequestFocus: !isDisabled)` → optional `GestureDetector` (only if any callback is non-null) → `child`. +The rest, in all three: `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → optional `GestureDetector` and `Actions` (both only if any callback is non-null) → `Focus` → `child`. State tracking: - Hover: `MouseRegion.onEnter` / `onExit` set `_isHovering`; calls `setState` only on change. - Focus: `FocusNode` listener reads `hasFocus` and calls `setState` on change. - Press tracking does NOT exist. `active:` prefix is reserved but not wired; `WAnchor` does not detect press duration via `onTapDown` / `onTapUp` today. +Keyboard and remote activation: +- With a gesture, `Actions` maps `ActivateIntent` and `ButtonActivateIntent` to `onTap`. `WidgetsApp` raises those for `Enter`, `Space`, numpad `Enter`, gamepad A and `select` (the Android TV D-pad centre, the Apple TV remote click), so `WAnchor` binds no key itself and inherits whatever the platform adds. Only `onTap` is bound: `ActivateIntent` is the primary action and there is no second key for `onLongPress` / `onDoubleTap`. +- `canRequestFocus: !isDisabled && (hasGestures || no ancestor anchor state)`. A gestureless `WAnchor` under another anchor is a styling wrapper, not a traversal stop, so one control costs one press of the remote. Standalone (nothing to inherit from) it keeps its node, because that is how a consumer styles a custom control. +- A gestureless wrapper inherits `isFocused` and `isDisabled` from the nearest `WindAnchorStateProvider`, which is what makes `WAnchor(onTap:) > WDiv('focus:ring-2')` draw the ring on the node the user activates. `isHovering` is NOT inherited: hover is a pointer position and sibling divs inside one anchor highlight independently. + ### `WButton` Pressable surface with built-in loading state. Always wraps in `WAnchor`. From 49b681ffb0c6b4e968d21690ae0df42f0adad1f8 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 12:35:54 +0300 Subject: [PATCH 05/10] docs(changelog): record the WAnchor activation and traversal changes --- CHANGELOG.md | 11 +++++++++++ README.md | 1 + 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4efccbfc..f2351ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. --- +## [Unreleased] + +### Added + +- **A focused `WAnchor` activates on the keyboard and on a television remote.** `onTap` now answers `ActivateIntent`, which `WidgetsApp` raises for `Enter`, `Space`, numpad `Enter`, the gamepad A button and `select`, the D-pad centre on Android TV and the click on the Apple TV remote. `WAnchor` binds no key of its own, so a key the platform adds later arrives for free. Only `onTap` is bound: `ActivateIntent` means the primary action and there is no second key for `onLongPress` or `onDoubleTap`, which matches every button Flutter ships. + +### Fixed + +- **One control is one traversal stop, and the focus ring lands on the thing that activates.** `WDiv` wraps itself in a gestureless `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, and that wrapper was a full focus stop publishing its own state. So `WAnchor(onTap:) > WDiv('focus:ring-2')`, the shape of every ring-styled control, cost two presses of Tab: the first landed on the node carrying the gesture and drew no ring, the second drew the ring on a node `Enter` could not activate. A gestureless wrapper is now a styling wrapper: it is not a traversal stop, and it inherits `focus` and `disabled` from the nearest anchor above it instead of shadowing them. Two shapes are deliberately unchanged: a `WDiv` carrying `focus:` with no anchor above it keeps its own node, because that is how a consumer styles a custom control, and a focusable descendant still lights the wrapper's ring, because `FocusNode.hasFocus` covers descendants and a `WInput` inside a ring-styled `WDiv` has always drawn the ring around the field being typed in. +- **`disabled:` now fires on a `WDiv` inside a disabled `WAnchor`.** The same shadowing, in a third state: the gestureless wrapper published `isDisabled: false` over a disabled ancestor, so the element carrying `disabled:opacity-50` never saw it. Covered by the same inheritance. + ## [1.5.2] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index 98a78158..58a70d3b 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ WDiv( | 📱 | **Responsive prefixes** | `sm:`, `md:`, `lg:`, `xl:`, `2xl:` breakpoints, plus custom breakpoints via the theme. | | 🌙 | **First-class dark mode** | `dark:` prefix with runtime toggle and automatic system-brightness sync. Every color token carries its `dark:` pair in the same className. | | 🎯 | **State prefixes** | `hover:`, `focus:`, `disabled:`, `loading:`, `selected:`, and any custom state. Zero `MouseRegion`, zero `setState`, zero `_isHovered` booleans. | +| ⌨️ | **Reachable without a pointer** | A `WAnchor` with an `onTap` is one traversal stop, and it activates on `Enter`, `Space`, the gamepad A button and the D-pad centre. The same binding serves a keyboard, a gamepad and a television remote. | | 🔌 | **Platform prefixes** | `ios:`, `android:`, `web:`, `mobile:` for conditional styling without a single `if`. Works on all 6 Flutter platforms. | | 🎭 | **Customizable theme** | 24 configurable `WindThemeData` fields. Override every token scale: colors, spacing, typography, shadows, breakpoints, animations. Defaults match Tailwind v3 / v4. Define `aliases` to create bare-token className shortcuts expanded before parsing. | | 📡 | **Server-driven UI** | `WDynamic` renders widget trees from JSON. Ship UI updates without ship-blocking releases. Whitelisted 13 Wind widgets + 16 Flutter core widgets. | From afd75e0d3aa59bca7ba91022c07b3a3518af33da Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 14:19:21 +0300 Subject: [PATCH 06/10] fix(w-anchor): inherit primary focus only, and bind the key only where onTap is Two defects in the first pass, both reported by review and both reproduced before being fixed. The inheritance leaked. `WindAnchorState.isFocused` is focus-WITHIN, because it comes from FocusNode.hasFocus, which is true for an ancestor of the real holder. So in WAnchor(onTap:) > Row[WDiv('focus:ring-2'), WInput], typing in the input lit the ring on the sibling div: the card reports focus-within the whole time. WindAnchorState gains hasPrimaryFocus, defaulted to false, and a wrapper inherits only that. The container case needs no inheritance and still works: a ring-styled div wrapping the input lights through its own node. The action map swallowed keys it could not use. A CallbackAction is always enabled and ShortcutManager reports a key handled for any enabled action, so an anchor carrying only onLongPress consumed the activation key belonging to the row around it, and on web beat Space's PrioritizedIntents([ActivateIntent, ScrollIntent]) race so the page stopped scrolling. Actions is now installed only when onTap != null && !isDisabled, which is why _activate carries no disabled guard: an early return would still have reported the action enabled and stopped the key rather than letting it travel. Three tests added, two of which failed before the fix. The disabled case is asserted structurally rather than by pressing a key, because a disabled anchor is already unfocusable and a key-press version would exercise nothing. --- lib/src/state/wind_anchor_state.dart | 22 ++- lib/src/widgets/w_anchor.dart | 51 +++++-- .../w_anchor/dpad_activation_test.dart | 131 ++++++++++++++++++ 3 files changed, 192 insertions(+), 12 deletions(-) diff --git a/lib/src/state/wind_anchor_state.dart b/lib/src/state/wind_anchor_state.dart index 6bccb8e0..3118c89d 100644 --- a/lib/src/state/wind_anchor_state.dart +++ b/lib/src/state/wind_anchor_state.dart @@ -7,7 +7,9 @@ import 'package:flutter/foundation.dart'; /// /// ### Properties: /// - **isHovering:** True when mouse is over the widget. -/// - **isFocused:** True when the widget has keyboard focus. +/// - **isFocused:** True when the widget, or anything inside it, has keyboard +/// focus. +/// - **hasPrimaryFocus:** True only when the widget ITSELF is the focus. /// - **isDisabled:** True when interactions are blocked. /// - **customStates:** Set of user-defined states like `selected` or `loading`. /// @@ -17,9 +19,22 @@ class WindAnchorState { /// Whether the mouse pointer is hovering over the widget. final bool isHovering; - /// Whether the widget has keyboard focus. + /// Whether the widget, or anything inside it, has keyboard focus. + /// + /// This is focus-WITHIN, because it comes from `FocusNode.hasFocus`, which is + /// true for an ancestor of the node that actually holds focus. That is the + /// right signal for a ring drawn around a text field's container, and the + /// wrong one for asking "is this element the focus". final bool isFocused; + /// Whether this widget itself is the focus, rather than merely containing it. + /// + /// The distinction is load-bearing for a styling wrapper that inherits its + /// state: a tappable card holding a text field reports [isFocused] while the + /// user types, so a wrapper inheriting that lit up as a sibling of the field + /// the user was actually in. + final bool hasPrimaryFocus; + /// Whether the widget is disabled and ignoring interactions. final bool isDisabled; @@ -38,6 +53,7 @@ class WindAnchorState { required this.isHovering, required this.isFocused, required this.isDisabled, + this.hasPrimaryFocus = false, this.customStates, }); @@ -58,6 +74,7 @@ class WindAnchorState { return other is WindAnchorState && other.isHovering == isHovering && other.isFocused == isFocused && + other.hasPrimaryFocus == hasPrimaryFocus && other.isDisabled == isDisabled && setEquals(other.customStates, customStates); } @@ -67,6 +84,7 @@ class WindAnchorState { int get hashCode => isHovering.hashCode ^ isFocused.hashCode ^ + hasPrimaryFocus.hashCode ^ isDisabled.hashCode ^ (customStates == null ? 0 : Object.hashAllUnordered(customStates!)); } diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 36ad0533..29b65b70 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -114,6 +114,7 @@ class WAnchor extends StatefulWidget { class _WAnchorState extends State { bool _isHovering = false; bool _isFocused = false; + bool _hasPrimaryFocus = false; final FocusNode _focusNode = FocusNode(); /// The keyboard and remote-control half of [WAnchor.onTap]. @@ -139,9 +140,13 @@ class _WAnchorState extends State { /// `onLongPress` and `onDoubleTap` get no binding. [ActivateIntent] means /// "the primary action" and there is no second key for a secondary one; /// inventing one would diverge from every button Flutter ships. + /// + /// No disabled check here, and its absence is deliberate: the map is not + /// installed at all unless there is an enabled `onTap`. An early return would + /// have looked equivalent and is not, because the action would still report + /// itself enabled and the key would stop here instead of travelling on. Object? _activate(Intent intent) { - if (widget.isDisabled) return null; - widget.onTap?.call(); + widget.onTap!.call(); return null; } @@ -175,9 +180,16 @@ class _WAnchorState extends State { /// which triggers a rebuild to propagate the new state. void _onFocusChange() { if (widget.isDisabled) return; - if (_focusNode.hasFocus != _isFocused) { + + // Two signals, not one. `hasFocus` is focus-WITHIN and is what draws a ring + // around a text field's container; `hasPrimaryFocus` is this node itself + // and is the only one a styling wrapper may inherit. Conflating them lit + // the ring on a sibling of the field the user was typing in. + if (_focusNode.hasFocus != _isFocused || + _focusNode.hasPrimaryFocus != _hasPrimaryFocus) { setState(() { _isFocused = _focusNode.hasFocus; + _hasPrimaryFocus = _focusNode.hasPrimaryFocus; }); } } @@ -221,16 +233,23 @@ class _WAnchorState extends State { // ancestor and `isDisabled: false` over a disabled one, and the element // carrying `focus:ring-2` was the one element that could not see the focus. // - // Hover is deliberately NOT inherited. Focus has one holder in the whole - // tree, so a descendant asking "is this focused" and a tappable ancestor - // holding focus are the same question. Hover is a pointer position, and two - // siblings inside one anchor legitimately highlight independently. + // Only the ancestor's PRIMARY focus is inherited, never its focus-within. + // A tappable card containing a text field reports focus-within while the + // user types, so inheriting that lit the ring on every styling wrapper + // under the card, including one sitting beside the field. The wrapper's own + // `_isFocused` already covers the case that matters in the other direction: + // a ring-styled div CONTAINING the focused input lights through its own + // node, because `hasFocus` covers descendants. + // + // Hover is not inherited at all. It is a pointer position, and two siblings + // inside one anchor legitimately highlight independently. final WindAnchorState? inherited = hasGestures ? null : WindAnchorStateProvider.of(context); final currentState = WindAnchorState( isHovering: _isHovering, - isFocused: _isFocused || (inherited?.isFocused ?? false), + isFocused: _isFocused || (inherited?.hasPrimaryFocus ?? false), + hasPrimaryFocus: _hasPrimaryFocus, isDisabled: widget.isDisabled || (inherited?.isDisabled ?? false), customStates: widget.states, ); @@ -249,10 +268,22 @@ class _WAnchorState extends State { child: widget.child, ); - // Only wrap with GestureDetector if there are actual gesture callbacks - if (hasGestures) { + // The action map goes on only where there is a primary action to run, and + // that is narrower than `hasGestures` on purpose. + // + // A `CallbackAction` is always enabled, and `ShortcutManager` reports a key + // HANDLED for any enabled action whether or not the callback did anything. + // So an anchor carrying only `onLongPress`, or one that is disabled, used to + // swallow the activation key belonging to the tappable row around it. On + // web it swallowed a scroll too: `Space` maps to + // `PrioritizedIntents([ActivateIntent, ScrollIntent])`, and an + // always-enabled action wins that race. + if (widget.onTap != null && !widget.isDisabled) { innerChild = Actions(actions: _actions, child: innerChild); + } + // Only wrap with GestureDetector if there are actual gesture callbacks + if (hasGestures) { innerChild = GestureDetector( // Translucent so the whole anchor bounds are tappable, not only the // opaque descendants. The GestureDetector defaults to diff --git a/test/widgets/w_anchor/dpad_activation_test.dart b/test/widgets/w_anchor/dpad_activation_test.dart index 434fb4e5..a0e415e7 100644 --- a/test/widgets/w_anchor/dpad_activation_test.dart +++ b/test/widgets/w_anchor/dpad_activation_test.dart @@ -153,6 +153,73 @@ void main() { expect(longPresses, 0); }); + + testWidgets('an anchor with no onTap lets the key travel past it', ( + tester, + ) async { + // Not the same assertion as the case above, and the difference is the + // whole finding. An `Actions` map whose `CallbackAction` is always + // enabled reports the key HANDLED even when its callback does nothing, + // and `ShortcutManager.handleKeypress` stops there. So an anchor carrying + // only a long press used to swallow the activation key belonging to the + // tappable row around it. + // + // On web the same swallow eats a scroll: `Space` maps to + // `PrioritizedIntents([ActivateIntent, ScrollIntent])`, and an + // always-enabled action wins that race. + int outerTaps = 0; + + await pump( + tester, + WAnchor( + onTap: () => outerTaps++, + child: WAnchor( + onLongPress: () {}, + child: const WText('Options'), + ), + ), + ); + + // The inner anchor is a traversal stop of its own: it carries a gesture, + // so it is not the styling-wrapper case. + final List stops = traversalStops(tester); + expect(stops.length, 2); + stops.last.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(outerTaps, 1); + }); + + testWidgets('a disabled anchor installs no action map at all', ( + tester, + ) async { + // Asserted structurally rather than by pressing a key, and the reason is + // worth writing down: a disabled anchor is already unfocusable + // (`canRequestFocus` gates on it), so a test that focuses one and presses + // a key exercises nothing. What is checkable is that the map is absent, + // which is what keeps the guard out of `_activate`. An early return there + // would look equivalent and is not: the action would still report itself + // enabled and the key would stop rather than travel on. + await pump( + tester, + WAnchor( + onTap: () {}, + isDisabled: true, + child: const WText('Save'), + ), + ); + + expect( + find.descendant( + of: find.byType(WAnchor), + matching: find.byType(Actions), + ), + findsNothing, + ); + }); }); group('one control, one traversal stop', () { @@ -238,6 +305,70 @@ void main() { expect(WindAnchorStateProvider.of(inner)?.isFocused, isTrue); }); + testWidgets('focus does not leak to a ring-styled SIBLING of the field', ( + tester, + ) async { + // The narrow edge of the inheritance. A tappable card containing a field + // is enough to reach it: the card's own node reports focus-WITHIN, so a + // wrapper that inherited plain `isFocused` lit up while the user was + // typing somewhere else entirely. + // + // Only the ancestor's PRIMARY focus is inherited, which is the case where + // the wrapper really is that ancestor's decoration. + await pump( + tester, + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'flex flex-row', + children: [ + WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WText('Label'), + ), + // `flex-1 min-w-0`, because a `Row` hands its child unbounded + // width on the main axis and `RenderEditable` tries to fill it. + WDiv( + className: 'flex-1 min-w-0', + child: WInput(placeholder: 'Search'), + ), + ], + ), + ), + ); + + await tester.tap(find.byType(WInput)); + await tester.pump(); + + final BuildContext sibling = tester.element(find.text('Label')); + expect(WindAnchorStateProvider.of(sibling)?.isFocused, isFalse); + }); + + testWidgets('but it does reach the decoration wrapping the field', ( + tester, + ) async { + // The case the narrowing must not break: a ring-styled div that CONTAINS + // the focused input still lights, and it does so through its own node + // rather than through the inheritance, because `hasFocus` covers + // descendants. + await pump( + tester, + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WInput(placeholder: 'Search'), + ), + ), + ); + + await tester.tap(find.byType(WInput)); + await tester.pump(); + + final BuildContext inner = tester.element(find.byType(WInput)); + expect(WindAnchorStateProvider.of(inner)?.isFocused, isTrue); + }); + testWidgets('nested hover stays local to the div that is hovered', ( tester, ) async { From c28bc1c38bdfb73476f0a51623a64d99ea459bf5 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 14:19:22 +0300 Subject: [PATCH 07/10] docs(w-anchor): correct the inheritance and the binding rule Core Law 11, the widget reference and the doc page all described the wider behaviour. Also bumps the SKILL.md frontmatter to 2.15.0, which the last commit moved only in the stamp comment, leaving the file disagreeing with itself and the distributed skill advertising the old version. --- CHANGELOG.md | 4 ++++ doc/widgets/w-anchor.md | 22 ++++++++++++++++++++-- skills/wind-ui/SKILL.md | 4 ++-- skills/wind-ui/references/widgets.md | 5 +++-- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2351ac8..4bf489ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **One control is one traversal stop, and the focus ring lands on the thing that activates.** `WDiv` wraps itself in a gestureless `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, and that wrapper was a full focus stop publishing its own state. So `WAnchor(onTap:) > WDiv('focus:ring-2')`, the shape of every ring-styled control, cost two presses of Tab: the first landed on the node carrying the gesture and drew no ring, the second drew the ring on a node `Enter` could not activate. A gestureless wrapper is now a styling wrapper: it is not a traversal stop, and it inherits `focus` and `disabled` from the nearest anchor above it instead of shadowing them. Two shapes are deliberately unchanged: a `WDiv` carrying `focus:` with no anchor above it keeps its own node, because that is how a consumer styles a custom control, and a focusable descendant still lights the wrapper's ring, because `FocusNode.hasFocus` covers descendants and a `WInput` inside a ring-styled `WDiv` has always drawn the ring around the field being typed in. - **`disabled:` now fires on a `WDiv` inside a disabled `WAnchor`.** The same shadowing, in a third state: the gestureless wrapper published `isDisabled: false` over a disabled ancestor, so the element carrying `disabled:opacity-50` never saw it. Covered by the same inheritance. +### Added + +- **`WindAnchorState.hasPrimaryFocus`**, separating "this element is the focus" from "this element contains the focus". `isFocused` has always been the second of those, because it comes from `FocusNode.hasFocus`, which is true for an ancestor of the real holder. The distinction is what a styling wrapper has to inherit: a tappable card containing a text field reports focus-within while the user types, so a wrapper inheriting `isFocused` lit its ring while sitting beside the field rather than around it. Defaults to `false`, so nothing that constructs the state by hand has to change. + ## [1.5.2] - 2026-09-08 ### Fixed diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index c8f31f9e..329df637 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -120,9 +120,11 @@ WAnchor(onTap: play, child: const WText('Play')) Only `onTap` is bound. `ActivateIntent` means the primary action and there is no second key for a secondary one, so `onLongPress` and `onDoubleTap` stay pointer-only, exactly as they are on Flutter's own buttons. +An anchor with no `onTap`, or a disabled one, binds nothing and lets the key travel on to whatever is above it. That is narrower than it may look: a `CallbackAction` is always enabled and `ShortcutManager` reports a key handled for any enabled action, so binding unconditionally would have made a long-press-only anchor eat the activation key belonging to the row around it, and on web eat `Space`'s scroll with it, because `Space` maps to `PrioritizedIntents([ActivateIntent, ScrollIntent])` and an always-enabled action wins that race. + ### One control is one stop -A control has to cost one press of the remote, so only an anchor that carries a gesture is a traversal stop. A gestureless `WAnchor` is a styling wrapper, and it inherits `focus` and `disabled` from the nearest anchor above it instead of publishing its own. +A control has to cost one press of the remote, so only an anchor that carries a gesture is a traversal stop. A gestureless `WAnchor` is a styling wrapper, and it inherits from the nearest anchor above it instead of publishing its own state. This matters because `WDiv` wraps itself in a gestureless `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`. Without the inheritance, the element carrying `focus:ring-2` would be the one element that could not see the focus: @@ -140,7 +142,23 @@ WAnchor( Two shapes are unaffected. A `WDiv` carrying `focus:` with no anchor above it keeps its own focus node, because that is how a consumer styles a custom control. And a focusable descendant still lights the wrapper's ring: `FocusNode.hasFocus` covers descendants, so a `WInput` inside a ring-styled `WDiv` draws the ring around the field the user is typing in. -`hover` is deliberately not inherited. Focus has one holder in the whole tree, so a descendant asking "is this focused" and a tappable ancestor holding focus are the same question. Hover is a pointer position, and two siblings inside one anchor legitimately highlight independently. +What the wrapper inherits is deliberately narrow: the ancestor's PRIMARY focus, never its focus-within. The two are different questions and `WindAnchorState` now exposes both. A tappable card containing a text field reports focus-within the whole time the user types, so a wrapper inheriting that would light up even when it sits *beside* the field rather than around it: + +```dart +// The ring belongs to nothing here, and stays dark while the field has focus. +WAnchor( + onTap: open, + child: const WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'p-2 focus:ring-2', child: WText('Label')), + WDiv(className: 'flex-1 min-w-0', child: WInput()), + ], + ), +) +``` + +`hover` is not inherited at all. It is a pointer position, and two siblings inside one anchor legitimately highlight independently. ### What is not here diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 332b0133..21ea581e 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,7 +2,7 @@ name: wind-ui description: "fluttersdk_wind 1.5: 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.14.0 +version: 2.15.0 --- @@ -63,7 +63,7 @@ These hold for every line of Wind code. Apply each as a hard constraint, not a s 10. **`active:` prefix is reserved but not wired.** `WAnchor` tracks hover and focus only; there is no onTapDown/onTapUp tracking. Don't rely on `active:bg-blue-700` for press feedback. Use a transient state in the consumer's controller and `states: {'pressed'}` if you genuinely need press feedback today. -11. **A gesture on `WAnchor` is reachable by keyboard and remote, and a gestureless one is not a stop.** `onTap` runs on `ActivateIntent`, which covers `Enter`, `Space`, the gamepad A button and `select` (the D-pad centre on Android TV, the click on the Apple TV remote). Only an anchor carrying a gesture is a traversal stop: the gestureless `WAnchor` that `WDiv` wraps itself in for `hover:` / `focus:` / `active:` inherits `focus` and `disabled` from the nearest anchor above it rather than claiming a second stop of its own, so `WAnchor(onTap:) > WDiv('focus:ring-2')` is one control the user tabs to once and the ring lands on the thing they activate. `hover` stays local, because two siblings inside one anchor legitimately highlight independently. Wind ships no traversal policy; directional movement is Flutter's default, and `FocusTraversalGroup` is the consumer's tool for region memory and edge behaviour. +11. **An `onTap` on `WAnchor` is reachable by keyboard and remote, and a gestureless anchor is not a stop.** `onTap` runs on `ActivateIntent`, which covers `Enter`, `Space`, the gamepad A button and `select` (the D-pad centre on Android TV, the click on the Apple TV remote). Only `onTap` is bound, and the action map is installed only when an enabled `onTap` exists: a `CallbackAction` is always enabled and `ShortcutManager` reports a key handled for any enabled action, so an anchor carrying only `onLongPress` would otherwise swallow the activation key belonging to the row around it, and on web swallow `Space`'s scroll with it. Only an anchor carrying a gesture is a traversal stop: the gestureless `WAnchor` that `WDiv` wraps itself in for `hover:` / `focus:` / `active:` inherits from the nearest anchor above it rather than claiming a second stop, so `WAnchor(onTap:) > WDiv('focus:ring-2')` is one control the user tabs to once and the ring lands on the thing they activate. What it inherits is narrow: the ancestor's PRIMARY focus and its `disabled`, never the ancestor's focus-within, because a tappable card containing a text field reports focus-within while the user types and every wrapper under it would light up. `hover` is never inherited. Wind ships no traversal policy; directional movement is Flutter's default, and `FocusTraversalGroup` is the consumer's tool for region memory and edge behaviour. ## 2. The 27 public widgets (+ WindRecipe) at a glance diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index a3c84826..3d571d3c 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -260,9 +260,10 @@ State tracking: - Press tracking does NOT exist. `active:` prefix is reserved but not wired; `WAnchor` does not detect press duration via `onTapDown` / `onTapUp` today. Keyboard and remote activation: -- With a gesture, `Actions` maps `ActivateIntent` and `ButtonActivateIntent` to `onTap`. `WidgetsApp` raises those for `Enter`, `Space`, numpad `Enter`, gamepad A and `select` (the Android TV D-pad centre, the Apple TV remote click), so `WAnchor` binds no key itself and inherits whatever the platform adds. Only `onTap` is bound: `ActivateIntent` is the primary action and there is no second key for `onLongPress` / `onDoubleTap`. +- `Actions` maps `ActivateIntent` and `ButtonActivateIntent` to `onTap`, and is installed only when `onTap != null && !isDisabled`. `WidgetsApp` raises those intents for `Enter`, `Space`, numpad `Enter`, gamepad A and `select` (the Android TV D-pad centre, the Apple TV remote click), so `WAnchor` binds no key itself and inherits whatever the platform adds. Only `onTap` is bound: `ActivateIntent` is the primary action and there is no second key for `onLongPress` / `onDoubleTap`. +- The install gate is narrower than `hasGestures` on purpose. A `CallbackAction` is always enabled and `ShortcutManager` reports a key HANDLED for any enabled action, so an always-installed map made a long-press-only or disabled anchor swallow the activation key belonging to its parent, and on web beat `Space`'s `PrioritizedIntents([ActivateIntent, ScrollIntent])` race so the page stopped scrolling. An early return inside the callback does NOT fix that: the action still reports enabled. - `canRequestFocus: !isDisabled && (hasGestures || no ancestor anchor state)`. A gestureless `WAnchor` under another anchor is a styling wrapper, not a traversal stop, so one control costs one press of the remote. Standalone (nothing to inherit from) it keeps its node, because that is how a consumer styles a custom control. -- A gestureless wrapper inherits `isFocused` and `isDisabled` from the nearest `WindAnchorStateProvider`, which is what makes `WAnchor(onTap:) > WDiv('focus:ring-2')` draw the ring on the node the user activates. `isHovering` is NOT inherited: hover is a pointer position and sibling divs inside one anchor highlight independently. +- A gestureless wrapper inherits the ancestor's `hasPrimaryFocus` and `isDisabled`, NOT its `isFocused`. `WindAnchorState.isFocused` is focus-WITHIN (it comes from `FocusNode.hasFocus`, true for an ancestor of the real holder), so inheriting it lit the ring on every wrapper under a tappable card while the user typed in a field inside that card, including wrappers sitting beside the field. The container case still works without inheritance: a ring-styled `WDiv` wrapping a `WInput` lights through its own node. `isHovering` is never inherited: hover is a pointer position and sibling divs inside one anchor highlight independently. ### `WButton` From f7fc89a910fd353e1874b6d7aa974e8dba9c7663 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 14:35:31 +0300 Subject: [PATCH 08/10] test(w-anchor): cover the two lines codecov/patch flagged Both were real gaps rather than bookkeeping. WindAnchorState.hashCode had no caller at all, so nothing checked that the new field takes part in it. That matters beyond hygiene: updateShouldNotify compares two states with !=, and a field left out of == means a change to it never reaches the descendants that style on it. The second was the right-hand side of the listener's condition, which only runs when hasFocus does NOT change while hasPrimaryFocus does. That is focus moving between a card and a field inside it, and it is how the user tabs off a card. Writing it took three attempts and each failure was mine rather than the implementation's: FocusManager does mark the card dirty on that transition (focus_manager.dart:1990, nextPath.difference(previousPath) contains it), so the notification arrives. What did not work was reading a BuildContext captured before the transition, which answers from the frame it was captured in. --- test/state/wind_state_provider_test.dart | 36 ++++++++++ .../w_anchor/dpad_activation_test.dart | 66 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/test/state/wind_state_provider_test.dart b/test/state/wind_state_provider_test.dart index a1aed39c..0c367106 100644 --- a/test/state/wind_state_provider_test.dart +++ b/test/state/wind_state_provider_test.dart @@ -153,4 +153,40 @@ void main() { expect(longPressed, isFalse); expect(doubleTapped, isFalse); }); + group('WindAnchorState value semantics', () { + test('hasPrimaryFocus takes part in equality and in the hash', () { + // Both halves matter. `WindAnchorStateProvider.updateShouldNotify` + // compares two states with `!=`, so a field left out of `==` means a + // change to it never reaches the descendants that style on it. The hash + // goes with it: two states that are not equal must not collide, or a Set + // or Map keyed on them silently merges them. + const WindAnchorState within = WindAnchorState( + isHovering: false, + isFocused: true, + isDisabled: false, + ); + const WindAnchorState primary = WindAnchorState( + isHovering: false, + isFocused: true, + hasPrimaryFocus: true, + isDisabled: false, + ); + + expect(within, isNot(primary)); + expect(within.hashCode, isNot(primary.hashCode)); + expect({within, primary}.length, 2); + }); + + test('and defaults to false, so existing callers are unaffected', () { + expect(WindAnchorState.none.hasPrimaryFocus, isFalse); + expect( + const WindAnchorState( + isHovering: false, + isFocused: false, + isDisabled: false, + ).hasPrimaryFocus, + isFalse, + ); + }); + }); } diff --git a/test/widgets/w_anchor/dpad_activation_test.dart b/test/widgets/w_anchor/dpad_activation_test.dart index a0e415e7..0aa27f50 100644 --- a/test/widgets/w_anchor/dpad_activation_test.dart +++ b/test/widgets/w_anchor/dpad_activation_test.dart @@ -369,6 +369,72 @@ void main() { expect(WindAnchorStateProvider.of(inner)?.isFocused, isTrue); }); + testWidgets('focus moving off the card and into its field is seen', ( + tester, + ) async { + // The transition where `hasFocus` does not move and `hasPrimaryFocus` + // does. The anchor reports focus-within for both halves of this, so a + // listener watching only `hasFocus` sees nothing happen and the ring + // stays lit on a card the user has already tabbed out of. + await pump( + tester, + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'flex flex-col', + children: [ + WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WText('Card'), + ), + WInput(placeholder: 'Search'), + ], + ), + ), + ); + + // Targeted rather than taken from `traversalStops`: a `WInput` brings + // several `Focus` widgets of its own, so position in that list says + // nothing about which node belongs to the anchor. + final FocusNode card = tester + .widget( + find + .descendant( + of: find.byType(WAnchor).first, + matching: find.byType(Focus), + ) + .first, + ) + .focusNode!; + final FocusNode field = + tester.widget(find.byType(EditableText)).focusNode; + + // Re-resolved on each read rather than captured once. An element held + // across a rebuild answers from the frame it was captured in, which made + // the second read below report the first read's value. + bool decorationIsFocused() => WindAnchorStateProvider.of( + tester.element(find.text('Card')), + )! + .isFocused; + + card.requestFocus(); + await tester.pump(); + expect(decorationIsFocused(), isTrue); + + // Into the field. The anchor keeps focus-WITHIN and loses primary focus. + // + // Two pumps: `FocusManager` applies focus changes at the end of a frame + // and notifies then, so the `setState` that notification triggers needs + // the frame after it to reach the tree. + field.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(card.hasFocus, isTrue, reason: 'focus-within is unchanged'); + expect(card.hasPrimaryFocus, isFalse); + expect(decorationIsFocused(), isFalse); + }); + testWidgets('nested hover stays local to the div that is hovered', ( tester, ) async { From 07bdfd730e7f26c541c39c9df59a37f385e12e4e Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 17:35:28 +0300 Subject: [PATCH 09/10] fix(w-anchor): chain the inherited primary focus through nested wrappers A regression this branch introduced two commits ago, caught by review and reproduced before fixing. A gestureless wrapper published only its OWN node's primary focus, and a wrapper's node can never hold primary focus because canRequestFocus is false there. So the inherited signal died after one hop and a ring two wrappers deep stayed dark: WAnchor(onTap:) > WDiv('hover:bg-gray-100') > WDiv('focus:ring-2') Any hover: or active: class on an intermediate div creates that second wrapper, so this is an ordinary shape and it is the exact case the change set out to fix. Measured two wrappers deep with focus on the anchor: isFocused=false. Republishing hasPrimaryFocus with the inherited value ORed in restores the chain without reopening the sibling leak, and the sibling test proves it: what leaked was isFocused, and what chains is primary focus, so a wrapper only ever inherits from a wrapper that is itself decoration of the primary-focused node. Also corrects a comment of mine that justified the hash assertion with a claim this hashCode cannot make. It XORs four booleans, so any two states differing by a permutation of their true values collide. What the assertion actually pins is that hasPrimaryFocus reaches hashCode at all; == is what keeps unequal states apart. --- CHANGELOG.md | 2 +- doc/widgets/w-anchor.md | 2 +- lib/src/widgets/w_anchor.dart | 13 ++++++- skills/wind-ui/references/widgets.md | 2 +- test/state/wind_state_provider_test.dart | 17 +++++++--- .../w_anchor/dpad_activation_test.dart | 34 +++++++++++++++++++ 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf489ed..5e162b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Added -- **`WindAnchorState.hasPrimaryFocus`**, separating "this element is the focus" from "this element contains the focus". `isFocused` has always been the second of those, because it comes from `FocusNode.hasFocus`, which is true for an ancestor of the real holder. The distinction is what a styling wrapper has to inherit: a tappable card containing a text field reports focus-within while the user types, so a wrapper inheriting `isFocused` lit its ring while sitting beside the field rather than around it. Defaults to `false`, so nothing that constructs the state by hand has to change. +- **`WindAnchorState.hasPrimaryFocus`**, separating "this element is the focus" from "this element contains the focus". A gestureless wrapper republishes it with the inherited value ORed in, so the signal chains through nesting: a wrapper's own node never holds primary focus, so stopping at it left a ring two wrappers deep dark, and any `hover:` or `active:` class on an intermediate div creates that second wrapper. `isFocused` has always been the second of those, because it comes from `FocusNode.hasFocus`, which is true for an ancestor of the real holder. The distinction is what a styling wrapper has to inherit: a tappable card containing a text field reports focus-within while the user types, so a wrapper inheriting `isFocused` lit its ring while sitting beside the field rather than around it. Defaults to `false`, so nothing that constructs the state by hand has to change. ## [1.5.2] - 2026-09-08 diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index 329df637..d14588c5 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -142,7 +142,7 @@ WAnchor( Two shapes are unaffected. A `WDiv` carrying `focus:` with no anchor above it keeps its own focus node, because that is how a consumer styles a custom control. And a focusable descendant still lights the wrapper's ring: `FocusNode.hasFocus` covers descendants, so a `WInput` inside a ring-styled `WDiv` draws the ring around the field the user is typing in. -What the wrapper inherits is deliberately narrow: the ancestor's PRIMARY focus, never its focus-within. The two are different questions and `WindAnchorState` now exposes both. A tappable card containing a text field reports focus-within the whole time the user types, so a wrapper inheriting that would light up even when it sits *beside* the field rather than around it: +What the wrapper inherits is deliberately narrow: the ancestor's PRIMARY focus, never its focus-within. It passes that signal on rather than stopping at it, because any `hover:` or `active:` class on a div in between creates a second wrapper whose own node never holds focus, and a ring two wrappers deep would otherwise stay dark. The two are different questions and `WindAnchorState` now exposes both. A tappable card containing a text field reports focus-within the whole time the user types, so a wrapper inheriting that would light up even when it sits *beside* the field rather than around it: ```dart // The ring belongs to nothing here, and stays dark while the field has focus. diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 29b65b70..6bdb0905 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -246,10 +246,21 @@ class _WAnchorState extends State { final WindAnchorState? inherited = hasGestures ? null : WindAnchorStateProvider.of(context); + // `hasPrimaryFocus` is republished with the inherited value ORed in, so the + // signal passes THROUGH a wrapper rather than stopping at it. A wrapper's + // own node never holds primary focus (it cannot request focus at all), so + // publishing only `_hasPrimaryFocus` killed the chain after one hop and a + // ring two wrappers deep stayed dark. Any `hover:` or `active:` class on an + // intermediate div is enough to create that second wrapper. + // + // Chaining does not reopen the sibling leak, because what chains is the + // ancestor's PRIMARY focus: a wrapper only ever inherits from a wrapper + // that is itself decoration of the primary-focused node. final currentState = WindAnchorState( isHovering: _isHovering, isFocused: _isFocused || (inherited?.hasPrimaryFocus ?? false), - hasPrimaryFocus: _hasPrimaryFocus, + hasPrimaryFocus: + _hasPrimaryFocus || (inherited?.hasPrimaryFocus ?? false), isDisabled: widget.isDisabled || (inherited?.isDisabled ?? false), customStates: widget.states, ); diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index 3d571d3c..bf73f7a8 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -263,7 +263,7 @@ Keyboard and remote activation: - `Actions` maps `ActivateIntent` and `ButtonActivateIntent` to `onTap`, and is installed only when `onTap != null && !isDisabled`. `WidgetsApp` raises those intents for `Enter`, `Space`, numpad `Enter`, gamepad A and `select` (the Android TV D-pad centre, the Apple TV remote click), so `WAnchor` binds no key itself and inherits whatever the platform adds. Only `onTap` is bound: `ActivateIntent` is the primary action and there is no second key for `onLongPress` / `onDoubleTap`. - The install gate is narrower than `hasGestures` on purpose. A `CallbackAction` is always enabled and `ShortcutManager` reports a key HANDLED for any enabled action, so an always-installed map made a long-press-only or disabled anchor swallow the activation key belonging to its parent, and on web beat `Space`'s `PrioritizedIntents([ActivateIntent, ScrollIntent])` race so the page stopped scrolling. An early return inside the callback does NOT fix that: the action still reports enabled. - `canRequestFocus: !isDisabled && (hasGestures || no ancestor anchor state)`. A gestureless `WAnchor` under another anchor is a styling wrapper, not a traversal stop, so one control costs one press of the remote. Standalone (nothing to inherit from) it keeps its node, because that is how a consumer styles a custom control. -- A gestureless wrapper inherits the ancestor's `hasPrimaryFocus` and `isDisabled`, NOT its `isFocused`. `WindAnchorState.isFocused` is focus-WITHIN (it comes from `FocusNode.hasFocus`, true for an ancestor of the real holder), so inheriting it lit the ring on every wrapper under a tappable card while the user typed in a field inside that card, including wrappers sitting beside the field. The container case still works without inheritance: a ring-styled `WDiv` wrapping a `WInput` lights through its own node. `isHovering` is never inherited: hover is a pointer position and sibling divs inside one anchor highlight independently. +- A gestureless wrapper inherits the ancestor's `hasPrimaryFocus` and `isDisabled`, NOT its `isFocused`, and it REPUBLISHES `hasPrimaryFocus` with the inherited value ORed in so the signal chains: a wrapper's own node never holds primary focus, so stopping at it left a ring two wrappers deep dark, and any `hover:` / `active:` class on an intermediate div creates that second wrapper. `WindAnchorState.isFocused` is focus-WITHIN (it comes from `FocusNode.hasFocus`, true for an ancestor of the real holder), so inheriting it lit the ring on every wrapper under a tappable card while the user typed in a field inside that card, including wrappers sitting beside the field. The container case still works without inheritance: a ring-styled `WDiv` wrapping a `WInput` lights through its own node. `isHovering` is never inherited: hover is a pointer position and sibling divs inside one anchor highlight independently. ### `WButton` diff --git a/test/state/wind_state_provider_test.dart b/test/state/wind_state_provider_test.dart index 0c367106..1fee8f3e 100644 --- a/test/state/wind_state_provider_test.dart +++ b/test/state/wind_state_provider_test.dart @@ -155,11 +155,18 @@ void main() { }); group('WindAnchorState value semantics', () { test('hasPrimaryFocus takes part in equality and in the hash', () { - // Both halves matter. `WindAnchorStateProvider.updateShouldNotify` - // compares two states with `!=`, so a field left out of `==` means a - // change to it never reaches the descendants that style on it. The hash - // goes with it: two states that are not equal must not collide, or a Set - // or Map keyed on them silently merges them. + // The `==` half is the load-bearing one. + // `WindAnchorStateProvider.updateShouldNotify` compares two states with + // `!=`, so a field left out of `==` means a change to it never reaches + // the descendants that style on it. + // + // The hash assertion pins something narrower than it may look: that + // `hasPrimaryFocus` reaches `hashCode` at all, since omitting it would + // make exactly these two states hash equal. It does NOT say unequal + // states never collide, and this `hashCode` cannot promise that: it XORs + // four booleans, so any two states that differ by a permutation of their + // true values hash the same. `==` is what keeps them apart; the hash only + // decides how well they spread across buckets. const WindAnchorState within = WindAnchorState( isHovering: false, isFocused: true, diff --git a/test/widgets/w_anchor/dpad_activation_test.dart b/test/widgets/w_anchor/dpad_activation_test.dart index 0aa27f50..3faeda5b 100644 --- a/test/widgets/w_anchor/dpad_activation_test.dart +++ b/test/widgets/w_anchor/dpad_activation_test.dart @@ -265,6 +265,40 @@ void main() { expect(WindAnchorStateProvider.of(inner)?.isFocused, isTrue); }); + testWidgets('the ring lights through an intermediate wrapper too', ( + tester, + ) async { + // Any `hover:` or `active:` class on a div between the anchor and the + // ring-styled one creates a second gestureless wrapper, and a wrapper's + // own node never has primary focus (it cannot request focus at all). So + // the inherited signal has to pass THROUGH a wrapper, not stop at it: + // narrowing the inheritance to primary focus without chaining it left a + // div two wrappers deep dark, which is the case this whole change exists + // to fix. + await pump( + tester, + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'p-2 hover:bg-gray-100', + child: WDiv( + className: 'p-2 focus:ring-2 focus:ring-blue-500', + child: WText('Inner'), + ), + ), + ), + ); + + traversalStops(tester).single.requestFocus(); + await tester.pump(); + + expect( + WindAnchorStateProvider.of(tester.element(find.text('Inner'))) + ?.isFocused, + isTrue, + ); + }); + testWidgets('a bare ring-styled div is still focusable on its own', ( tester, ) async { From cb9377ad6625e917157bc999cae9a346ded9862d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 20:24:34 +0300 Subject: [PATCH 10/10] docs(w-anchor): correct the hasPrimaryFocus dartdoc the chain made untrue Both the class summary and the field doc still said the flag is true only when the widget ITSELF is the focus. The chaining commit made that false: a gestureless wrapper publishes true while the anchor it decorates is the focus, which is the whole point of the chain. CHANGELOG.md, doc/ and skills/ were updated then; this file was not, and it is the text that shows on pub.dev and in an IDE hover. It now says what the flag means (this element or the control it decorates) and what it deliberately does not mean (containment), because the second half is what keeps a wrapper beside a focused field from lighting up with it. --- lib/src/state/wind_anchor_state.dart | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/src/state/wind_anchor_state.dart b/lib/src/state/wind_anchor_state.dart index 3118c89d..5b41d13f 100644 --- a/lib/src/state/wind_anchor_state.dart +++ b/lib/src/state/wind_anchor_state.dart @@ -9,7 +9,8 @@ import 'package:flutter/foundation.dart'; /// - **isHovering:** True when mouse is over the widget. /// - **isFocused:** True when the widget, or anything inside it, has keyboard /// focus. -/// - **hasPrimaryFocus:** True only when the widget ITSELF is the focus. +/// - **hasPrimaryFocus:** True when the widget, or the control it decorates, +/// is the focus. Never true merely because something inside it is. /// - **isDisabled:** True when interactions are blocked. /// - **customStates:** Set of user-defined states like `selected` or `loading`. /// @@ -27,12 +28,19 @@ class WindAnchorState { /// wrong one for asking "is this element the focus". final bool isFocused; - /// Whether this widget itself is the focus, rather than merely containing it. + /// Whether this widget, or the control it decorates, is the focus. /// - /// The distinction is load-bearing for a styling wrapper that inherits its - /// state: a tappable card holding a text field reports [isFocused] while the - /// user types, so a wrapper inheriting that lit up as a sibling of the field - /// the user was actually in. + /// Not the same as "this exact node holds focus", and the difference is + /// deliberate. A gestureless [WAnchor] cannot request focus at all, so a + /// styling wrapper reports the primary focus of the anchor it decorates, + /// passing the signal on to any wrapper nested inside it. Without that a ring + /// two wrappers deep stayed dark, and one `hover:` class on a div in between + /// is enough to create the second wrapper. + /// + /// What it is never true for is containment. A tappable card holding a text + /// field reports [isFocused] the whole time the user types, because that is + /// focus-within; this stays false, which is what keeps a wrapper sitting + /// BESIDE the field from lighting up with it. final bool hasPrimaryFocus; /// Whether the widget is disabled and ignoring interactions.