diff --git a/CHANGELOG.md b/CHANGELOG.md index 4efccbfc..5e162b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ 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. + +### Added + +- **`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 ### 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. | diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index 7b083524..d14588c5 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,63 @@ 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. + +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 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: + +```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. + +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. +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 + +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 +218,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). 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', + ), + ), + ), + ), ], ); } diff --git a/lib/src/state/wind_anchor_state.dart b/lib/src/state/wind_anchor_state.dart index 6bccb8e0..5b41d13f 100644 --- a/lib/src/state/wind_anchor_state.dart +++ b/lib/src/state/wind_anchor_state.dart @@ -7,7 +7,10 @@ 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 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`. /// @@ -17,9 +20,29 @@ 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, or the control it decorates, is the focus. + /// + /// 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. final bool isDisabled; @@ -38,6 +61,7 @@ class WindAnchorState { required this.isHovering, required this.isFocused, required this.isDisabled, + this.hasPrimaryFocus = false, this.customStates, }); @@ -58,6 +82,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 +92,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 8b6a5504..6bdb0905 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -114,8 +114,43 @@ 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]. + /// + /// `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. + /// + /// 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) { + widget.onTap!.call(); + + return null; + } + /// Initializes the state and adds a listener to the `FocusNode` to track focus changes. @override void initState() { @@ -145,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; }); } } @@ -177,24 +219,80 @@ 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. + // + // 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); + + // `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, - isDisabled: widget.isDisabled, + isFocused: _isFocused || (inherited?.hasPrimaryFocus ?? false), + hasPrimaryFocus: + _hasPrimaryFocus || (inherited?.hasPrimaryFocus ?? 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, ); + // 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( diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index b12ab1df..21ea581e 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ 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 --- - + # 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. **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 `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..bf73f7a8 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -252,13 +252,19 @@ 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: +- `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`, 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` Pressable surface with built-in loading state. Always wraps in `WAnchor`. 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/state/wind_state_provider_test.dart b/test/state/wind_state_provider_test.dart index a1aed39c..1fee8f3e 100644 --- a/test/state/wind_state_provider_test.dart +++ b/test/state/wind_state_provider_test.dart @@ -153,4 +153,47 @@ void main() { expect(longPressed, isFalse); expect(doubleTapped, isFalse); }); + group('WindAnchorState value semantics', () { + test('hasPrimaryFocus takes part in equality and in the hash', () { + // 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, + 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 new file mode 100644 index 00000000..3faeda5b --- /dev/null +++ b/test/widgets/w_anchor/dpad_activation_test.dart @@ -0,0 +1,528 @@ +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); + }); + + 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', () { + 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('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 { + // 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('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('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 { + // 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); + }); + }); +}