Skip to content

feat(mix): rework Pressable input and semantics, add focus-visible variant - #1007

Draft
leoafarias wants to merge 3 commits into
fix/nested-widget-state-discoveryfrom
feat/pressable-semantics-focus-visible
Draft

feat(mix): rework Pressable input and semantics, add focus-visible variant#1007
leoafarias wants to merge 3 commits into
fix/nested-widget-state-discoveryfrom
feat/pressable-semantics-focus-visible

Conversation

@leoafarias

Copy link
Copy Markdown
Member

Summary

Pressable interaction-subsystem rework extracted from the Tailwind parity branch (leoafarias/mix-tailwind-compat). Found during parity work, but none of it is Tailwind-specific: standalone input/lifecycle/a11y fixes plus two general features. Replaces #1005 together with its base PR.

Stacked on fix/nested-widget-state-discovery: FocusVisibleVariant declares its focus dependency through the widgetStateDependencies API introduced there. This PR's diff shows only its own changes; it retargets to main automatically when the base merges.

Bug fixes

  • Duplicate pressed-state owners: both GestureDetector (tapDown/tapUp/tapCancel) and MixInteractionDetector's Listener wrote pressed to the same controller, with conflicting timing (e.g. tapCancel during a long-press cleared pressed while the pointer was still down). The Listener is now the single owner.
  • Keyboard activation previously bound ActivateIntentonPress with no enabled check and no pressed feedback. Now Space/Enter/numpad Enter model held state: pressed on key down, activate once on key up, repeats suppressed, cancellation on focus loss, disable, controller swap, and dispose. Refs PressableBox not pressed when using keyboard trigger onPress #314 — the "onPress not firing" half was fixed back then; this completes the missing pressed-state half its title describes.
  • Controller lifecycle: late final _controller ignored widget.controller swaps, and dispose() consulted the current widget to decide ownership — swapping external↔internal could dispose a controller the widget didn't own or leak the one it did. Fixed via _ownsController + didUpdateWidget.
  • Semantics correctness: the semantics node exposed onTap even when disabled, had no enabled state or longPress action, and GestureDetector's implicit semantics duplicated actions. Actions are now gated on enabled, enabled is exposed, and the GestureDetector is always excluded from semantics.
  • PressableBox.enableFeedback existed but was never forwarded to Pressable; the full surface (cursor, focus, keyboard, controller, actions, semantics) is now forwarded.

New features

  • FocusVisibleVariant / ContextVariant.focusVisible() / onFocusVisible(...): CSS :focus-visible equivalent, keyed off FocusManager.highlightMode (the same input-modality signal FocusableActionDetector uses), made reactive inside Pressable via the new internal FocusHighlightModeProvider.
  • PressableSemanticsRole (button / link / none) + semanticsLabel.

mix_protocol's schema inventory registers the new public symbols as v1 out-of-scope.

Breaking changes

  • semanticButtonLabelsemanticsLabel (no remaining usages in-repo).
  • Deprecated onKey removed; use onKeyEvent (it runs first and can override activation).
  • Pressable reserves Space/Enter/numpad Enter: custom actions no longer receive ActivateIntent for those keys. Deliberate contract so held-key state stays consistent; documented in the CHANGELOG.

Note one judgment call: activation fires on key-up for both Space and Enter (the web fires Enter on key-down). The uniform key-up model was chosen so the held/pressed lifecycle is consistent across keys.

Issue-tracker context

Searched open and closed issues (Pressable, pressed, focus, keyboard, semantics, accessibility, enableFeedback, focus-visible, …): no open issue reports these bugs or requests these features. #314 (closed, v1-era) is the nearest prior report, referenced above.

Test plan

  • Combined tree (this branch): full packages/mix suite passes (2,852 tests), including new coverage: keyboard hold/activate-once/cancel paths, controller-swap state transfer, onKeyEvent precedence, reserved-keys-vs-custom-actions, focus-visible modality switching, and a semantics role/action matrix (enabled × tap × longPress).
  • packages/mix_protocol inventory + contract tests pass; dart analyze clean.

The Tailwind parity branch consumes this PR (focus-visible: prefix → onFocusVisible, TwPressable → semantics roles) and will be rebased once it lands.

…riant

New features:
- FocusVisibleVariant, ContextVariant.focusVisible(), and onFocusVisible(),
  driven by FocusManager.highlightMode through FocusHighlightModeProvider
  (the same modality signal FocusableActionDetector uses). Builds on
  ContextVariant.widgetStateDependencies to declare its focus dependency.
- PressableSemanticsRole (button/link/none) and semanticsLabel

Fixes:
- Pointer pressed state has a single owner (MixInteractionDetector's
  Listener); the duplicate GestureDetector tapDown/tapUp/tapCancel writers
  are removed
- Keyboard activation models held state: pressed on key down, activate once
  on key up, repeats suppressed, cancellation on focus loss, disable,
  controller swap, and dispose (completes the keyboard story from #314)
- Pressable handles WidgetStatesController swaps in didUpdateWidget and no
  longer risks disposing an external controller or leaking its own
- Semantics expose enabled state, gate tap/longPress on enabled, and no
  longer duplicate actions through GestureDetector's implicit semantics
- PressableBox forwards the full Pressable surface, including the
  previously dropped enableFeedback

BREAKING CHANGE: semanticButtonLabel is renamed to semanticsLabel; the
deprecated onKey callback is removed (use onKeyEvent); Pressable reserves
Space, Enter, and numpad Enter for activation, so custom actions no longer
receive ActivateIntent for those keys. onKeyEvent runs first and can still
override activation.
Review follow-up to the Pressable rework.

- Only the Pressable holding primary focus claims activation keys, and it
  returns ignored for keys it cannot act on. FocusNode.hasFocus is also true
  while a descendant holds focus, so the previous guard swallowed Space and
  Enter before a nested TextField (or app shortcuts) ever saw them.
- Cover every key WidgetsApp maps to ActivateIntent, restoring select and
  gameButtonA activation for TV remotes and gamepads. Dropping the
  ActivateIntent binding had left those two keys bound to nothing.
- A pointer that drifts past the tap slop stops counting as a press.
  Removing GestureDetector's tapCancel took the only arena-aware canceller
  with it, and a scrolled item travels with the pointer, so list items
  stayed visually pressed for the whole scroll.
- Provide the focus-highlight scope wherever widget states are provided, so
  onFocusVisible resolves and repaints on modality changes outside a
  Pressable instead of reading a dependency-free fallback.
- FocusVisibleVariant honors WidgetStateStyleOverride, matching onFocused so
  preview tooling can force the focus-visible look.
- Only claim a semantic enabled state for something that can be disabled: a
  role, or an activation callback.

Also drops the dead _cancelHeldActivation parameter and guards the method on
the held key, so keyboard bookkeeping can no longer clear a pointer-owned
press; collapses the key-event branches; and refreshes the skill docs, which
still advertised the removed onKey and semanticButtonLabel.

Adds 10 regression tests; reverting the lib changes fails 8 of them.
@github-actions github-actions Bot added the repo label Aug 5, 2026
@leoafarias
leoafarias marked this pull request as draft August 6, 2026 14:07
Pointer and keyboard presses no longer clear each other. Pressable now owns
the published pressed state and derives it from both sources, while
MixInteractionDetector reports pointer presses through onPressChange and
stops writing WidgetState.pressed when its owner combines sources
(managesPressedState).

- Track the pointer that owns a press, so a second pointer's move, up, or
  cancel cannot end a press it did not start
- Accept a press only for kPrimaryButton across device kinds, matching
  GestureDetector's primary tap recognizer
- Leave modified key chords (alt/control/meta/shift) to application
  shortcuts, and contain a competing activation key's repeats while another
  key is held so they cannot escape to an ancestor shortcut
- A disabled Pressable ignores custom key handling and installs no custom
  actions, while keeping the subtree shape stable across enabled changes
- Move hover, focus, and pointer press onto a swapped controller instead of
  dropping them; a held key still never survives the swap
- Scope the semantics node with container and pair Focus.includeSemantics
  with excludeFromSemantics, so nested controls stay separate

Adds 11 tests covering pointer ownership, controller swaps, disposal,
auxiliary and modified activation keys, competing-key repeats, disabled
actions, cross-source press survival, and nested control semantics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant