Interaction Architecture¶
Pointer events, focus and keyboard input are handled by nodes hosted on a
widget. A host is an InteractionRegion or any widget with
InteractionHostMixin; the nodes on one host share one InteractionState.
flowchart LR
H["host: InteractionRegion or InteractionHostMixin widget"] --> P[PointerInputNode]
H --> F[FocusNode]
H --> S[FocusScope]
H --> D[DraggableNode]
P --> ST["InteractionState: hovered, pressed, focused, disabled"]
F --> ST
PointerInputNodeturns hover, press and release into state flags and anon_clickon a valid press-and-release, and requests focus for its host on press when aFocusNodeis present.FocusNodeholds focus, takes part in traversal and receives key and text events.FocusScopemarks a subtree as one traversal group.DraggableNodehandles drag start, deltas and end, with long-press initiation.
A host is composed in one of two ways. A modifier (focusable(),
clickable()) wraps an arbitrary widget in an InteractionRegion that hosts
the nodes. A standard control (Button, Checkbox, TextField) inherits
InteractionHostMixin and adds its nodes in __init__, so no wrapper deepens
the tree. Either way the widget reads visual state (hovered, pressed, focused,
disabled) from the one shared InteractionState.
A new capability is a new node on the same host; the drop target, scrolling, semantics and context-menu concerns are shaped to fit that slot rather than a new hosting mechanism.
Focus¶
Focusable Is Not Traversable¶
Holding focus and being a stop in the Tab sequence are two properties:
| Property | Carrier | Meaning |
|---|---|---|
| Focusable | owning a FocusNode |
Can hold focus, receive keys and paint a focus ring. |
| Traversable | FocusNode.traversable (default True) |
The global Tab sequence stops on it. |
_collect_focus_nodes collects only traversable nodes. A node with
traversable=False is still focusable: it can be focused programmatically,
typically by the FocusScope that owns it, it receives keys and its keys
bubble; Tab merely never lands on it. Clickable and InteractiveWidget
take traversable as a constructor argument, and Clickable.set_traversable()
flips it afterwards, because a widget does not always know at construction
that it belongs to a group: a RadioButton meets its RadioGroup when it is
mounted.
This split is what makes a group (a menu, a multi-handle slider) expressible. Without it, every focusable part of a widget is unavoidably its own Tab stop.
Traversal¶
Tab moves forward and Shift+Tab backward through the traversable nodes in
depth-first tree order. On Tab, the FocusScope enclosing the focused node is
consulted before the global sequence. FocusNode.focused syncs to
InteractionState.focused on the host, which drives the focus ring.
What Tab Can Reach¶
Being mounted is not the same as being on screen. A Collapsible closes, a
Deck switches page, a route is pushed over another, a modal opens: the
content stays in the tree, keeping its state, while the user can no longer see
or click it. Tab stops at the same boundary the eye does, or the focus ring
vanishes and keystrokes go to an invisible widget. Two hooks express that at
two granularities:
| Hook | Granularity | Used by |
|---|---|---|
FocusTraversalBlocker.blocks_focus_traversal |
Hides the widget's whole subtree | disabled Clickable, closed Collapsible, visible(False) |
Widget.focus_traversal_children() |
Narrows to some of the children | Deck (the selected page), Navigator (the top route) |
The blocker cannot express "keep one of N children reachable", which is why
the second hook exists. focus_traversal_children() defaults to
children_snapshot(), so a container opts in only by narrowing it, and it
resolves the narrowing on every call: a Deck's index addresses the
post-expansion child list, and a ForEach can change its item count at any
time.
A blocking overlay entry is handled one level up, in
Window._focus_traversal_root: while a modal is open the whole sequence starts
at that entry's content, so Tab is trapped inside the dialog instead of
walking out into the background. Window._sync_overlay_focus_trap moves focus
into the entry on open and gives it back to the invoker on close; no traversal
rule can do that half, since by then the dialog is already detached.
Everything follows from one walk. Window._iter_focus_traversal yields the
reachable widgets; _collect_focus_nodes filters it to traversable nodes, and
_release_focus_if_blocked searches it for the focused widget and drops focus
that is no longer there. is_foreground in
KEYBOARD_SHORTCUTS.md asks the same hooks from the
other direction, so a FOREGROUND shortcut and a Tab stop buried in the same
hidden content agree about being out of reach. Off-screen children of a
Scrollable are reachable, which matches browser behaviour.
Focus Source¶
Focus carries a FocusSource (KEYBOARD / POINTER), and MD3 suppresses
the focus ring when focus is pointer-driven
(InteractiveWidget.should_show_focus_ring).
The source can change without focus moving: dragging a slider that Tab
focused makes it pointer-driven, and Tab-ing between its handles makes it
keyboard-driven again. Since request_focus and FocusNode._set_focused both
short-circuit when nothing changes, FocusNode.notify_focus_source(source)
re-announces the source in that case; without it the ring state sticks at
whatever the last focus change said.
A widget that takes focus on its own has no source to report; a Menu
focusing its first item when it opens is the case. It inherits
Window._last_input_source, whether the last input was a key press or a
pointer press, so a mouse-opened menu does not come up wearing a keyboard focus
ring. The item is still focused, because the arrow keys need somewhere to
start; it does not look keyboard-driven until the keyboard drives it.
Key Events¶
A key event goes to the focused FocusNode's on_key. A handler that returns
False lets it bubble to the nearest ancestor FocusNode, and an event that
reaches the root falls to the window's default actions, Tab traversal among
them.
A FocusNode also receives the text-level events a text field needs, each
dispatched by the window to the focused node: on_text for a committed
character, on_text_motion for arrows, Home, End, Backspace and Delete, and
on_ime_composition(text, start, length) for a composition update, where
start and length mark the range the IME highlights inside text.
Focus Traversal Groups¶
Some widgets own keyboard navigation inside themselves: RangeSlider between
its handles, Menu between its items. Both are one problem, a widget that
traverses its own parts and decides when Tab escapes, and both use one
primitive. A FocusScope marks a subtree as one group, the unit Tab enters and
leaves, and delegates to a FocusTraversalPolicy that enumerates the members
and tracks the current one.
The policy is small, so that real-node owners and virtual-stop owners
implement the same thing: members() in traversal order, current_index(),
set_current(index), entry_index(backwards) for the member Tab enters at,
and on_boundary(direction) for a step past the last or before the first
member. Two member shapes are built in: FocusNodePolicy, whose members are
real child FocusNodes marked traversable=False so only the policy moves
focus between them (a menu's items), and VirtualStopPolicy, whose members
are stops the owner keeps for itself while focus stays on its own FocusNode
(a slider's handle indices).
Tab enters at the first member and Shift+Tab at the last by default, so
Shift+Tab into a RangeSlider lands on the far handle; a RadioGroup enters
at the selected radio. FocusScope.move(step, wrap=) roves to the adjacent
member; whether Tab roves is the scope's tab_roves: a slider roves on Tab, a
menu roves on the arrow keys and makes every Tab a boundary. At the boundary
the policy returns False to let Tab escape to the next outside stop or
True to consume it (a popup menu dismisses).
Window._dispatch_key_press consults the scope enclosing the focused node
before the global traversal, because the focused node may not be a Tab stop at
all (a menu item), and the "focused node is not in the list, restart from the
first stop" fallback would otherwise fire first. Scopes resolve innermost
first, so an open submenu answers for the focus inside it. When a scope lets
Tab escape and the focused node is not itself a stop, traversal resumes from
the scope owner's own stop (Window._scope_owner_node).
A group has one external Tab stop, except a popup menu, which has none: it is
entered by opening it, and its boundary dismisses it. An inline menu keeps
one stop, the surface, because WAI-ARIA makes a permanently visible menu a
single stop. A single-handle Slider is a one-member scope: Tab enters,
finds no second member and hands the key back to the global sequence.
Submenus are nested scopes. A disabled member has no FocusNode at all, so
the policies enumerate exactly the members the keyboard should reach.
Wrap versus stop-at-edge, and what roving moves, are per-widget decisions.
A RadioGroup follows the WAI-ARIA radio group: the arrows wrap and moving
focus moves the selection, which is why Tab enters at the selected radio; both
axes rove because a radio group is laid out as a Row or a Column. A button
group follows the WAI-ARIA toolbar: Left/Right stop at the ends, and roving
moves focus only, since its items are actions or independent toggles. A
NavigationRail mixes the two: it enters at the selected item and wraps like
a radio group, but roving moves focus only, because selecting a destination
navigates, too heavy to fire on every arrow press; only Up/Down rove, since a
rail is always a column. The rail's expand/collapse button is not a member but
an ordinary stop above the group, so the group's FocusNode and FocusScope
live on a dedicated item-group widget below that button; on the rail root they
would order the stops wrong and capture the button's focus.
A per-widget Tab interception (RangeSlider intercepting Tab through a
callback on its FocusNode) was rejected in favour of the scope: it was a
hand-rolled focus scope that served only virtual stops and could not express a
menu, whose members are real focus nodes that must not be Tab stops.
Menu Keyboard Model¶
MD3 does not specify a menu's keyboard behaviour, and real applications disagree: the WAI-ARIA APG example focuses the first item however the menu was opened and closes on Tab, while desktop menus (Chrome, macOS, Windows) highlight nothing until the user reaches for the keyboard. The model below is a reading of the desktop convention and may change.
| Trigger | Behaviour |
|---|---|
| Opened with the pointer | Focus enters the menu but lands on the surface: no item is current, nothing is highlighted. Arrows, Tab, Escape and Enter reach the menu from there; Enter, with no current item, does nothing. |
| Opened from the keyboard | The first enabled item becomes current, with its ring; Window._last_input_source decides which case applies. |
| Up / Down | Rove the enabled items, wrapping. From "no item current" they enter at the first (Down) or last (Up). |
| Tab / Shift+Tab | Rove without wrapping; stepping past the end is the boundary. The first Tab in a pointer-opened menu lands on an item rather than closing the menu, because Tab is the key users press to be handed the focus. |
| Right / Left | Walk into and out of a submenu. |
| Escape | Dismiss. |
Whether Tab and the arrows should both rove, and whether "nothing is current" is worth its complexity, is revisited when accessibility lands, since a screen reader's expectations pull the other way.