-
Notifications
You must be signed in to change notification settings - Fork 1
fix(w-anchor): make a control reachable by keyboard and remote, and cost one stop #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bf77a65
33d181a
2f957c7
a7eafda
49b681f
afd75e0
c28bc1c
f7fc89a
07bdfd7
cb9377a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: <Widget>[ | ||
| 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 3769 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target documentation ---'
sed -n '135,155p' doc/widgets/w-anchor.md
printf '%s\n' '--- nearby focus-related references ---'
rg -n -C 3 'DirectionalFocusTraversalPolicyMixin|FocusTraversalPolicy|Scrollable|focus traversal' doc/widgets/w-anchor.mdRepository: fluttersdk/wind Length of output: 3006 🌐 Web query:
💡 Result: The DirectionalFocusTraversalPolicyMixin in Flutter manages focus movement within a FocusScope and utilizes a requestFocusCallback to handle the transition between focus nodes [1]. By default, this callback is designed to request focus on the target node and automatically ensure that the node is visible if it resides within a scrollable widget [1]. This mechanism ensures that as users navigate through focusable elements using a keyboard, Flutter will scroll the content as needed to keep the focused element in view [1]. Citations: Correct the directional traversal description.
🤖 Prompt for AI AgentsSource: MCP tools |
||
|
|
||
| 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 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Merge the duplicate
Addedsubsection.[Unreleased]already has an### Addedheading at Line 11. Move theWindAnchorState.hasPrimaryFocusentry under that heading to resolve the MD024 warning.🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 20-20: Multiple headings with the same content
(MD024, no-duplicate-heading)
🤖 Prompt for AI Agents
Source: Linters/SAST tools