From 6dda5e6621cc1c4834b135058489a0be67caf22c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 4 Aug 2026 19:42:27 -0400 Subject: [PATCH 1/3] feat(mix): rework Pressable input and semantics, add focus-visible variant 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. --- packages/mix/CHANGELOG.md | 22 + .../focus_highlight_mode_provider.dart | 61 +++ .../src/specs/pressable/pressable_widget.dart | 177 +++++-- .../mixins/widget_state_variant_mixin.dart | 5 + packages/mix/lib/src/variants/variant.dart | 23 + .../pressable/pressable_hover_press_test.dart | 78 +++- .../pressable_keyboard_semantics_test.dart | 433 ++++++++++++++++++ .../pressable/pressable_widget_test.dart | 28 +- .../src/variants/context_variant_test.dart | 11 + .../test/src/variants/variant_mixin_test.dart | 10 + .../inventory/schema_inventory_manifest.dart | 12 + 11 files changed, 803 insertions(+), 57 deletions(-) create mode 100644 packages/mix/lib/src/core/providers/focus_highlight_mode_provider.dart create mode 100644 packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart diff --git a/packages/mix/CHANGELOG.md b/packages/mix/CHANGELOG.md index 1ec1175cce..d0b52c7d77 100644 --- a/packages/mix/CHANGELOG.md +++ b/packages/mix/CHANGELOG.md @@ -1,10 +1,32 @@ ## Unreleased +### New features + +- **Typed focus-visible variants:** Added `FocusVisibleVariant`, + `ContextVariant.focusVisible()`, and `onFocusVisible(...)`. Context variants + can now declare their required states through + `ContextVariant.widgetStateDependencies`. +- **Pressable semantics roles:** Added `PressableSemanticsRole` with button, + link, and neutral roles. `PressableBox` now forwards the full Pressable + focus, keyboard, controller, feedback, cursor, action, and semantics surface. + +### Breaking changes + +- **Pressable input and semantics:** Replaced `semanticButtonLabel` with + `semanticsLabel`, added `semanticsRole`, and removed the deprecated `onKey` + callback. Use `onKeyEvent` for custom keyboard handling. +- **Reserved activation keys:** Pressable owns Space, Enter, and numpad Enter + so it can model held-key state consistently. Custom activation behavior must + use `onKeyEvent`; custom `actions` remain supported for other intents. + ### Fixes - **Nested widget-state discovery:** `StyleBuilder` now discovers state requirements recursively through nested and negated variants with identity-based cycle protection. +- **Pressable lifecycle:** Pointer state has one owner, keyboard activation + fires once on key-up, cancellation clears held state, focus-visible follows + Flutter input modality, and disabled semantics expose no actions. ## 2.2.0-beta.1 diff --git a/packages/mix/lib/src/core/providers/focus_highlight_mode_provider.dart b/packages/mix/lib/src/core/providers/focus_highlight_mode_provider.dart new file mode 100644 index 0000000000..82dfea9da8 --- /dev/null +++ b/packages/mix/lib/src/core/providers/focus_highlight_mode_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Provides the current Flutter focus-highlight mode to descendants. +@internal +class FocusHighlightModeProvider extends StatefulWidget { + const FocusHighlightModeProvider({super.key, required this.child}); + + static FocusHighlightMode of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_FocusHighlightModeScope>() + ?.mode ?? + FocusManager.instance.highlightMode; + } + + final Widget child; + + @override + State createState() => + _FocusHighlightModeProviderState(); +} + +class _FocusHighlightModeProviderState + extends State { + late FocusHighlightMode _mode; + + @override + void initState() { + super.initState(); + _mode = FocusManager.instance.highlightMode; + FocusManager.instance.addHighlightModeListener(_handleModeChange); + } + + void _handleModeChange(FocusHighlightMode mode) { + if (!mounted || mode == _mode) return; + + setState(() => _mode = mode); + } + + @override + void dispose() { + FocusManager.instance.removeHighlightModeListener(_handleModeChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _FocusHighlightModeScope(mode: _mode, child: widget.child); + } +} + +class _FocusHighlightModeScope extends InheritedWidget { + const _FocusHighlightModeScope({required this.mode, required super.child}); + + final FocusHighlightMode mode; + + @override + bool updateShouldNotify(_FocusHighlightModeScope oldWidget) { + return mode != oldWidget.mode; + } +} diff --git a/packages/mix/lib/src/specs/pressable/pressable_widget.dart b/packages/mix/lib/src/specs/pressable/pressable_widget.dart index 7edce92e04..dc5dc4864f 100644 --- a/packages/mix/lib/src/specs/pressable/pressable_widget.dart +++ b/packages/mix/lib/src/specs/pressable/pressable_widget.dart @@ -1,10 +1,15 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; import '../../core/internal/mix_interaction_detector.dart'; +import '../../core/providers/focus_highlight_mode_provider.dart'; import '../../core/providers/widget_state_provider.dart'; import '../box/box_spec.dart'; import '../box/box_widget.dart'; +/// The accessibility role exposed by a [Pressable]. +enum PressableSemanticsRole { button, link, none } + /// Combines [Box] styling with gesture handling. /// /// Provides press, long press, and focus interactions. @@ -19,6 +24,14 @@ class PressableBox extends StatelessWidget { this.enableFeedback = false, this.onFocusChange, this.onPress, + this.mouseCursor, + this.canRequestFocus = true, + this.excludeFromSemantics = false, + this.semanticsLabel, + this.semanticsRole = PressableSemanticsRole.button, + this.onKeyEvent, + this.controller, + this.actions, this.hitTestBehavior = HitTestBehavior.opaque, this.enabled = true, }); @@ -37,7 +50,16 @@ class PressableBox extends StatelessWidget { final bool enabled; final FocusNode? focusNode; final bool autofocus; - final Function(bool focus)? onFocusChange; + final ValueChanged? onFocusChange; + + final MouseCursor? mouseCursor; + final bool canRequestFocus; + final bool excludeFromSemantics; + final String? semanticsLabel; + final PressableSemanticsRole semanticsRole; + final FocusOnKeyEventCallback? onKeyEvent; + final WidgetStatesController? controller; + final Map>? actions; final HitTestBehavior hitTestBehavior; @@ -47,12 +69,21 @@ class PressableBox extends StatelessWidget { return Pressable( enabled: enabled, + enableFeedback: enableFeedback, onPress: onPress, hitTestBehavior: hitTestBehavior, onLongPress: onLongPress, onFocusChange: onFocusChange, autofocus: autofocus, focusNode: focusNode, + mouseCursor: mouseCursor, + canRequestFocus: canRequestFocus, + excludeFromSemantics: excludeFromSemantics, + semanticsLabel: semanticsLabel, + semanticsRole: semanticsRole, + onKeyEvent: onKeyEvent, + controller: controller, + actions: actions, child: style == null ? Box(child: child) : Box(style: style, child: child), @@ -75,10 +106,10 @@ class Pressable extends StatefulWidget { this.autofocus = false, this.focusNode, this.mouseCursor, - this.onKey, this.canRequestFocus = true, this.excludeFromSemantics = false, - this.semanticButtonLabel, + this.semanticsLabel, + this.semanticsRole = PressableSemanticsRole.button, this.onKeyEvent, this.controller, this.actions, @@ -91,7 +122,9 @@ class Pressable extends StatefulWidget { final MouseCursor? mouseCursor; - final String? semanticButtonLabel; + final String? semanticsLabel; + + final PressableSemanticsRole semanticsRole; final bool excludeFromSemantics; @@ -115,9 +148,6 @@ class Pressable extends StatefulWidget { /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; - /// {@macro flutter.widgets.Focus.onKey} - final FocusOnKeyEventCallback? onKey; - /// {@macro flutter.widgets.Focus.onKeyEvent} final FocusOnKeyEventCallback? onKeyEvent; @@ -135,33 +165,100 @@ class Pressable extends StatefulWidget { @visibleForTesting class PressableWidgetState extends State { - late final WidgetStatesController _controller; + late WidgetStatesController _controller; + late bool _ownsController; + LogicalKeyboardKey? _heldActivationKey; @override void initState() { super.initState(); - _controller = widget.controller ?? WidgetStatesController(); + _initController(); + } + + void _initController([Set? initialStates]) { + _ownsController = widget.controller == null; + _controller = + widget.controller ?? WidgetStatesController(initialStates ?? {}); } void _onTap() { + if (!widget.enabled || widget.onPress == null) return; + widget.onPress?.call(); if (widget.enableFeedback) Feedback.forTap(context); } - void _onTapUp() => _controller.pressed = false; - - void _onTapDown() => _controller.pressed = true; - void _onLongPress() { + if (!widget.enabled || widget.onLongPress == null) return; + widget.onLongPress?.call(); if (widget.enableFeedback) Feedback.forLongPress(context); } void _onFocusChange(bool hasFocus) { + if (!hasFocus && _heldActivationKey != null) _cancelHeldActivation(); _controller.focused = hasFocus; widget.onFocusChange?.call(hasFocus); } + bool _isActivationKey(LogicalKeyboardKey key) { + return key == .space || key == .enter || key == .numpadEnter; + } + + void _cancelHeldActivation([WidgetStatesController? controller]) { + _heldActivationKey = null; + (controller ?? _controller).pressed = false; + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + final customResult = widget.onKeyEvent?.call(node, event) ?? .ignored; + + if (customResult != .ignored) { + if (event is KeyUpEvent && event.logicalKey == _heldActivationKey) { + _cancelHeldActivation(); + } + + return customResult; + } + + if (!_isActivationKey(event.logicalKey)) { + return .ignored; + } + + if (!widget.enabled || widget.onPress == null || !node.hasFocus) { + if (event.logicalKey == _heldActivationKey) { + _cancelHeldActivation(); + } + + return .handled; + } + + if (event is KeyDownEvent) { + if (_heldActivationKey == null) { + _heldActivationKey = event.logicalKey; + _controller.pressed = true; + } + + return .handled; + } + + if (event is KeyRepeatEvent) { + return .handled; + } + + if (event is KeyUpEvent) { + final shouldActivate = event.logicalKey == _heldActivationKey; + if (shouldActivate) { + _cancelHeldActivation(); + _onTap(); + } + + return .handled; + } + + return .ignored; + } + bool get hasOnPress => widget.onPress != null; MouseCursor get mouseCursor { @@ -176,52 +273,55 @@ class PressableWidgetState extends State { return hasOnPress ? SystemMouseCursors.click : MouseCursor.defer; } - /// Binds [ActivateIntent] for keyboard activation (SPACE/ENTER). - Map> get actions { - return { - ActivateIntent: CallbackAction( - onInvoke: (_) => widget.onPress?.call(), - ), - ...?widget.actions, - }; + @override + void didUpdateWidget(Pressable oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.controller != widget.controller) { + final oldController = _controller; + final oldStates = oldController.value; + final ownedOldController = _ownsController; + _cancelHeldActivation(oldController); + _initController(widget.controller == null ? oldStates : null); + if (ownedOldController) oldController.dispose(); + } + + if ((oldWidget.enabled && !widget.enabled) || + (oldWidget.onPress != null && widget.onPress == null)) { + _cancelHeldActivation(); + } } @override void dispose() { - if (widget.controller == null) _controller.dispose(); + _cancelHeldActivation(); + if (_ownsController) _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - // Only track pressed state if there's a tap or long press handler - final hasGestureHandler = - widget.onPress != null || widget.onLongPress != null; - Widget current = GestureDetector( - onTapDown: hasGestureHandler ? (_) => _onTapDown() : null, - onTapUp: hasGestureHandler ? (_) => _onTapUp() : null, onTap: widget.enabled && widget.onPress != null ? _onTap : null, - onTapCancel: hasGestureHandler ? () => _onTapUp() : null, onLongPress: widget.enabled && widget.onLongPress != null ? _onLongPress : null, behavior: widget.hitTestBehavior, - excludeFromSemantics: widget.excludeFromSemantics, + excludeFromSemantics: true, child: MouseRegion( cursor: mouseCursor, child: Actions( - actions: actions, + actions: widget.actions ?? const {}, child: Focus( focusNode: widget.focusNode, autofocus: widget.autofocus, onFocusChange: _onFocusChange, - onKeyEvent: widget.onKeyEvent ?? widget.onKey, + onKeyEvent: _onKeyEvent, canRequestFocus: widget.canRequestFocus && widget.enabled, child: MixInteractionDetector( controller: _controller, enabled: widget.enabled, - child: widget.child, + child: FocusHighlightModeProvider(child: widget.child), ), ), ), @@ -230,9 +330,14 @@ class PressableWidgetState extends State { if (!widget.excludeFromSemantics) { current = Semantics( - button: true, - label: widget.semanticButtonLabel, - onTap: widget.onPress, + enabled: widget.enabled, + button: widget.semanticsRole == .button ? true : null, + link: widget.semanticsRole == .link ? true : null, + label: widget.semanticsLabel, + onTap: widget.enabled && widget.onPress != null ? _onTap : null, + onLongPress: widget.enabled && widget.onLongPress != null + ? _onLongPress + : null, child: current, ); } diff --git a/packages/mix/lib/src/style/mixins/widget_state_variant_mixin.dart b/packages/mix/lib/src/style/mixins/widget_state_variant_mixin.dart index 511a812a82..37a12cdbc6 100644 --- a/packages/mix/lib/src/style/mixins/widget_state_variant_mixin.dart +++ b/packages/mix/lib/src/style/mixins/widget_state_variant_mixin.dart @@ -47,6 +47,11 @@ mixin WidgetStateVariantMixin, S extends Spec> return variant(ContextVariant.widgetState(.focused), style); } + /// Creates a variant for focus shown in Flutter's traditional highlight mode. + T onFocusVisible(T style) { + return variant(ContextVariant.focusVisible(), style); + } + /// Creates a variant for disabled state T onDisabled(T style) { return variant(ContextVariant.widgetState(.disabled), style); diff --git a/packages/mix/lib/src/variants/variant.dart b/packages/mix/lib/src/variants/variant.dart index ed1e58b31d..2234385655 100644 --- a/packages/mix/lib/src/variants/variant.dart +++ b/packages/mix/lib/src/variants/variant.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../core/breakpoint.dart'; +import '../core/providers/focus_highlight_mode_provider.dart'; import '../core/providers/widget_state_provider.dart'; import '../core/providers/widget_state_style_override.dart'; import '../core/spec.dart'; @@ -57,6 +58,10 @@ class ContextVariant extends Variant { return WidgetStateVariant(state); } + static FocusVisibleVariant focusVisible() { + return FocusVisibleVariant(); + } + static OrientationVariant orientation(Orientation orientation) { return OrientationVariant(orientation); } @@ -267,6 +272,24 @@ final class WidgetStateVariant extends ContextVariant { int get hashCode => state.hashCode; } +/// Context variant that applies to traditionally highlighted keyboard focus. +final class FocusVisibleVariant extends ContextVariant { + FocusVisibleVariant() + : super('focus_visible', (context) { + return WidgetStateProvider.hasStateOf(context, .focused) && + FocusHighlightModeProvider.of(context) == .traditional; + }); + + @override + bool operator ==(Object other) => other is FocusVisibleVariant; + + @override + Set get widgetStateDependencies => const {.focused}; + + @override + int get hashCode => key.hashCode; +} + String _breakpointKey(Breakpoint breakpoint) { if (breakpoint case final BreakpointRef ref) { return 'breakpoint_${ref.token.name}'; diff --git a/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart b/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart index 36588cdf71..1a0c57d8df 100644 --- a/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart +++ b/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart @@ -6,19 +6,21 @@ import 'package:mix/mix.dart'; void main() { group('Pressable hover and press interaction', () { testWidgets( - 'press state should remain true until tapUp even when hover ends', + 'press state clears when the pointer leaves the detector bounds', (tester) async { final controller = WidgetStatesController(); await tester.pumpWidget( MaterialApp( - home: Pressable( - controller: controller, - onPress: () {}, - child: const SizedBox( - width: 100, - height: 100, - child: Text('Pressable'), + home: Center( + child: Pressable( + controller: controller, + onPress: () {}, + child: const SizedBox( + width: 100, + height: 100, + child: Text('Pressable'), + ), ), ), ), @@ -48,7 +50,7 @@ void main() { expect(controller.has(WidgetState.hovered), isTrue); // Move mouse away while still pressed - await gesture.moveTo(const Offset(200, 200)); // Move outside widget + await gesture.moveTo(Offset.zero); await tester.pumpAndSettle(); // When moving out while pressed, the gesture is cancelled @@ -77,13 +79,15 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Pressable( - controller: controller, - onPress: () => onPressCalled = true, - child: const SizedBox( - width: 100, - height: 100, - child: Text('Pressable'), + home: Center( + child: Pressable( + controller: controller, + onPress: () => onPressCalled = true, + child: const SizedBox( + width: 100, + height: 100, + child: Text('Pressable'), + ), ), ), ), @@ -104,7 +108,7 @@ void main() { expect(controller.has(WidgetState.pressed), isTrue); // Move out - this triggers tap cancel - await gesture.moveTo(const Offset(200, 200)); // Move out + await gesture.moveTo(Offset.zero); await tester.pumpAndSettle(); // Press state should be cleared on cancel @@ -117,5 +121,45 @@ void main() { // onPress should not have been called since gesture was cancelled expect(onPressCalled, isFalse); }); + + testWidgets('focus loss does not clear a pointer-owned press', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter(find.byType(Pressable)), + ); + await gesture.down(tester.getCenter(find.byType(Pressable))); + await tester.pump(); + expect(controller.pressed, isTrue); + + focusNode.unfocus(); + await tester.pump(); + expect(controller.pressed, isTrue); + + await gesture.up(); + await tester.pump(); + expect(controller.pressed, isFalse); + }); }); } diff --git a/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart b/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart new file mode 100644 index 0000000000..db5f9515d6 --- /dev/null +++ b/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart @@ -0,0 +1,433 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +class _ProbeIntent extends Intent { + const _ProbeIntent(); +} + +void main() { + group('Pressable keyboard lifecycle', () { + testWidgets('Space and Enter hold pressed and activate once on key up', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var presses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + for (final key in [LogicalKeyboardKey.space, LogicalKeyboardKey.enter]) { + await tester.sendKeyDownEvent(key); + await tester.pump(); + expect(controller.pressed, isTrue); + expect(presses, 0); + + await tester.sendKeyRepeatEvent(key); + await tester.sendKeyRepeatEvent(key); + await tester.pump(); + expect(controller.pressed, isTrue); + expect(presses, 0); + + await tester.sendKeyUpEvent(key); + await tester.pump(); + expect(controller.pressed, isFalse); + expect(presses, 1); + + presses = 0; + } + }); + + testWidgets('focus loss cancels held keyboard activation', (tester) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var presses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(controller.pressed, isTrue); + + focusNode.unfocus(); + await tester.pump(); + expect(controller.pressed, isFalse); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(presses, 0); + }); + + testWidgets('disabling while held cancels activation', (tester) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var enabled = true; + var presses = 0; + late StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, stateSetter) { + setState = stateSetter; + + return Pressable( + enabled: enabled, + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + child: const SizedBox(width: 100, height: 100), + ); + }, + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(controller.pressed, isTrue); + + setState(() => enabled = false); + await tester.pump(); + expect(controller.pressed, isFalse); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(presses, 0); + }); + + testWidgets('disposal clears a held state without activation', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var presses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(controller.pressed, isTrue); + + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + expect(controller.pressed, isFalse); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + expect(presses, 0); + }); + + testWidgets('controller swap does not carry a held keyboard press', ( + tester, + ) async { + final focusNode = FocusNode(); + final externalController = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(externalController.dispose); + var useExternalController = true; + var presses = 0; + late StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, stateSetter) { + setState = stateSetter; + + return Pressable( + focusNode: focusNode, + controller: useExternalController ? externalController : null, + onPress: () => presses++, + child: Box( + key: const Key('controller-swap-box'), + style: BoxStyler() + .size(100, 100) + .color(Colors.blue) + .onPressed(BoxStyler().color(Colors.red)), + ), + ); + }, + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(externalController.pressed, isTrue); + + setState(() => useExternalController = false); + await tester.pump(); + + final container = tester.widget( + find.descendant( + of: find.byKey(const Key('controller-swap-box')), + matching: find.byType(Container), + ), + ); + expect(externalController.pressed, isFalse); + expect((container.decoration! as BoxDecoration).color, Colors.blue); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(presses, 0); + }); + + testWidgets('onKeyEvent runs first and can suppress key-up activation', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + final pressedSeenByHandler = []; + var presses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + onKeyEvent: (_, event) { + pressedSeenByHandler.add(controller.pressed); + + return event is KeyUpEvent + ? KeyEventResult.handled + : KeyEventResult.ignored; + }, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(controller.pressed, isTrue); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(pressedSeenByHandler, [false, true]); + expect(controller.pressed, isFalse); + expect(presses, 0); + }); + + testWidgets('keeps custom actions but reserves Space and Enter', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + BuildContext? actionContext; + var presses = 0; + var probes = 0; + var customActivations = 0; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + onPress: () => presses++, + actions: >{ + _ProbeIntent: CallbackAction<_ProbeIntent>( + onInvoke: (_) => probes++, + ), + ActivateIntent: CallbackAction( + onInvoke: (_) => customActivations++, + ), + }, + child: Builder( + builder: (context) { + actionContext = context; + return const SizedBox(width: 100, height: 100); + }, + ), + ), + ), + ); + + Actions.invoke(actionContext!, const _ProbeIntent()); + expect(probes, 1); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(presses, 1); + expect(customActivations, 0); + }); + }); + + group('Pressable focus visibility', () { + testWidgets('requires focus and traditional focus-highlight mode', ( + tester, + ) async { + final focusManager = FocusManager.instance; + final previousStrategy = focusManager.highlightStrategy; + addTearDown(() => focusManager.highlightStrategy = previousStrategy); + focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + child: Box( + key: const Key('focus-visible-box'), + style: BoxStyler() + .size(100, 100) + .color(Colors.blue) + .onFocusVisible(BoxStyler().color(Colors.red)), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + BoxDecoration decoration() { + final container = tester.widget( + find.descendant( + of: find.byKey(const Key('focus-visible-box')), + matching: find.byType(Container), + ), + ); + + return container.decoration! as BoxDecoration; + } + + expect(focusNode.hasFocus, isTrue); + expect(decoration().color, Colors.blue); + + focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + await tester.pump(); + expect(decoration().color, Colors.red); + + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + expect(decoration().color, Colors.blue); + }); + }); + + group('Pressable semantics contract', () { + testWidgets('maps button, link, and none roles exactly', (tester) async { + final handle = tester.ensureSemantics(); + + for (final role in PressableSemanticsRole.values) { + await tester.pumpWidget( + MaterialApp( + home: Pressable( + key: ValueKey(role), + semanticsLabel: role.name, + semanticsRole: role, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + + final flags = tester + .getSemantics(find.byKey(ValueKey(role))) + .flagsCollection; + expect(flags.isButton, role == PressableSemanticsRole.button); + expect(flags.isLink, role == PressableSemanticsRole.link); + } + + handle.dispose(); + }); + + testWidgets('exposes only enabled callbacks as semantic actions', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + for (final enabled in [true, false]) { + for (final hasTap in [true, false]) { + for (final hasLongPress in [true, false]) { + final key = ValueKey((enabled, hasTap, hasLongPress)); + await tester.pumpWidget( + MaterialApp( + home: Pressable( + key: key, + enabled: enabled, + semanticsLabel: 'Action', + onPress: hasTap ? () {} : null, + onLongPress: hasLongPress ? () {} : null, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + + final semantics = tester.getSemantics(find.byKey(key)); + expect( + semantics, + isSemantics( + label: 'Action', + isButton: true, + hasEnabledState: true, + isEnabled: enabled, + hasTapAction: enabled && hasTap, + hasLongPressAction: enabled && hasLongPress, + ), + ); + final data = semantics.getSemanticsData(); + expect(data.hasAction(SemanticsAction.tap), enabled && hasTap); + expect( + data.hasAction(SemanticsAction.longPress), + enabled && hasLongPress, + ); + } + } + } + + handle.dispose(); + }); + }); +} diff --git a/packages/mix/test/src/specs/pressable/pressable_widget_test.dart b/packages/mix/test/src/specs/pressable/pressable_widget_test.dart index d4cc50b732..809347fc7b 100644 --- a/packages/mix/test/src/specs/pressable/pressable_widget_test.dart +++ b/packages/mix/test/src/specs/pressable/pressable_widget_test.dart @@ -287,7 +287,7 @@ void main() { MaterialApp( home: Pressable( onPress: () {}, - semanticButtonLabel: 'Test Button', + semanticsLabel: 'Test Button', child: const SizedBox(width: 100, height: 100), ), ), @@ -304,7 +304,7 @@ void main() { home: Pressable( onPress: () {}, excludeFromSemantics: true, - semanticButtonLabel: 'Test Button', + semanticsLabel: 'Test Button', child: const SizedBox(width: 100, height: 100), ), ), @@ -385,6 +385,10 @@ void main() { // ignore: unused_local_variable bool? focusChanged; final focusNode = FocusNode(); + final controller = WidgetStatesController(); + final actions = >{}; + KeyEventResult onKeyEvent(FocusNode _, KeyEvent _) => + KeyEventResult.ignored; await tester.pumpWidget( MaterialApp( @@ -396,7 +400,14 @@ void main() { autofocus: true, enabled: true, enableFeedback: true, - + mouseCursor: SystemMouseCursors.help, + canRequestFocus: false, + excludeFromSemantics: true, + semanticsLabel: 'Forwarded label', + semanticsRole: PressableSemanticsRole.link, + onKeyEvent: onKeyEvent, + controller: controller, + actions: actions, hitTestBehavior: HitTestBehavior.deferToChild, child: const SizedBox(width: 100, height: 100), ), @@ -408,7 +419,15 @@ void main() { expect(pressable.enabled, isTrue); expect(pressable.autofocus, isTrue); expect(pressable.focusNode, same(focusNode)); - + expect(pressable.enableFeedback, isTrue); + expect(pressable.mouseCursor, SystemMouseCursors.help); + expect(pressable.canRequestFocus, isFalse); + expect(pressable.excludeFromSemantics, isTrue); + expect(pressable.semanticsLabel, 'Forwarded label'); + expect(pressable.semanticsRole, PressableSemanticsRole.link); + expect(pressable.onKeyEvent, same(onKeyEvent)); + expect(pressable.controller, same(controller)); + expect(pressable.actions, same(actions)); expect(pressable.hitTestBehavior, HitTestBehavior.deferToChild); // Test callbacks work @@ -421,6 +440,7 @@ void main() { expect(wasLongPressed, isTrue); focusNode.dispose(); + controller.dispose(); }); }); } diff --git a/packages/mix/test/src/variants/context_variant_test.dart b/packages/mix/test/src/variants/context_variant_test.dart index ec6a0a4e20..f6af1b6fc1 100644 --- a/packages/mix/test/src/variants/context_variant_test.dart +++ b/packages/mix/test/src/variants/context_variant_test.dart @@ -55,6 +55,17 @@ void main() { expect(enabled, anotherEnabled); expect(enabled.hashCode, anotherEnabled.hashCode); }); + + test('focusVisible factory returns a typed focused-state variant', () { + final focusVisible = ContextVariant.focusVisible(); + final anotherFocusVisible = ContextVariant.focusVisible(); + + expect(focusVisible, isA()); + expect(focusVisible.key, 'focus_visible'); + expect(focusVisible.widgetStateDependencies, {WidgetState.focused}); + expect(focusVisible, anotherFocusVisible); + expect(focusVisible.hashCode, anotherFocusVisible.hashCode); + }); }); group('responsive breakpoint shorthand factories', () { diff --git a/packages/mix/test/src/variants/variant_mixin_test.dart b/packages/mix/test/src/variants/variant_mixin_test.dart index f36861793e..4e0f4e3ee4 100644 --- a/packages/mix/test/src/variants/variant_mixin_test.dart +++ b/packages/mix/test/src/variants/variant_mixin_test.dart @@ -106,6 +106,16 @@ void main() { expect(result.$variants!.first.variant, isA()); }); + test('onFocusVisible creates correct typed variant', () { + const attribute = TestVariantAttribute(); + const style = TestVariantAttribute(); + final result = attribute.onFocusVisible(style); + + expect(result.$variants, isNotNull); + expect(result.$variants, hasLength(1)); + expect(result.$variants!.first.variant, isA()); + }); + test('onMobile creates correct variant', () { const attribute = TestVariantAttribute(); const style = TestVariantAttribute(); diff --git a/packages/mix_protocol/lib/src/inventory/schema_inventory_manifest.dart b/packages/mix_protocol/lib/src/inventory/schema_inventory_manifest.dart index fa6af73e3d..6c4d1fc51d 100644 --- a/packages/mix_protocol/lib/src/inventory/schema_inventory_manifest.dart +++ b/packages/mix_protocol/lib/src/inventory/schema_inventory_manifest.dart @@ -431,6 +431,10 @@ const _supportedInventory = [ const _v1UnsupportedInventory = [ SchemaInventoryEntry.knownUnsupported('enum:ElevationShadow', _v1OutOfScope), + SchemaInventoryEntry.knownUnsupported( + 'enum:PressableSemanticsRole', + _v1OutOfScope, + ), SchemaInventoryEntry.knownUnsupported( r'mix:BeveledRectangleBorderMix.$borderRadius', _v1OutOfScope, @@ -579,6 +583,14 @@ const _v1UnsupportedInventory = [ r'mix:StarBorderMix.$valleyRounding', _v1OutOfScope, ), + SchemaInventoryEntry.knownUnsupported( + 'variant:FocusVisibleVariant', + _v1OutOfScope, + ), + SchemaInventoryEntry.knownUnsupported( + 'variant_factory:ContextVariant.focusVisible', + _v1OutOfScope, + ), ]; const _neverUnsupportedInventory = [ From 21700c8ad7ec553cf4f88aa16a1a71dae7ae4a5d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 5 Aug 2026 15:08:05 -0400 Subject: [PATCH 2/3] fix(mix): scope Pressable key claims and end press with the gesture 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. --- packages/mix/CHANGELOG.md | 16 +- .../internal/mix_interaction_detector.dart | 95 +++++++---- packages/mix/lib/src/core/style_builder.dart | 15 +- .../src/specs/pressable/pressable_widget.dart | 122 ++++++++------ packages/mix/lib/src/variants/variant.dart | 8 + .../pressable/pressable_hover_press_test.dart | 76 +++++++++ .../pressable_keyboard_semantics_test.dart | 156 ++++++++++++++++++ .../variants/focus_visible_variant_test.dart | 114 +++++++++++++ skills/mix/references/fluent-api.md | 15 +- skills/mix/references/variants.md | 1 + 10 files changed, 528 insertions(+), 90 deletions(-) create mode 100644 packages/mix/test/src/variants/focus_visible_variant_test.dart diff --git a/packages/mix/CHANGELOG.md b/packages/mix/CHANGELOG.md index d0b52c7d77..a48d01a8ae 100644 --- a/packages/mix/CHANGELOG.md +++ b/packages/mix/CHANGELOG.md @@ -15,9 +15,12 @@ - **Pressable input and semantics:** Replaced `semanticButtonLabel` with `semanticsLabel`, added `semanticsRole`, and removed the deprecated `onKey` callback. Use `onKeyEvent` for custom keyboard handling. -- **Reserved activation keys:** Pressable owns Space, Enter, and numpad Enter - so it can model held-key state consistently. Custom activation behavior must - use `onKeyEvent`; custom `actions` remain supported for other intents. +- **Reserved activation keys:** While it holds primary focus and can activate, + Pressable owns the keys Flutter maps to `ActivateIntent` — Space, Enter, + numpad Enter, select, and game button A — so it can model held-key state + consistently. Custom activation behavior must use `onKeyEvent`; custom + `actions` remain supported for other intents. Those keys are left untouched + when a descendant holds focus, so nested text fields keep working. ### Fixes @@ -27,6 +30,13 @@ - **Pressable lifecycle:** Pointer state has one owner, keyboard activation fires once on key-up, cancellation clears held state, focus-visible follows Flutter input modality, and disabled semantics expose no actions. +- **Press state ends with the gesture:** A pointer that drifts past the tap slop + stops counting as a press, so items no longer stay visually pressed while a + list scrolls under the finger. +- **Focus-visible scope:** The focus-highlight scope is now provided wherever + widget states are, so `onFocusVisible` also resolves — and repaints on input + modality changes — outside a `Pressable`. A `WidgetStateStyleOverride` forcing + `focused` now applies it too, matching `onFocused`. ## 2.2.0-beta.1 diff --git a/packages/mix/lib/src/core/internal/mix_interaction_detector.dart b/packages/mix/lib/src/core/internal/mix_interaction_detector.dart index 712fe81fdc..d57b8f2e9e 100644 --- a/packages/mix/lib/src/core/internal/mix_interaction_detector.dart +++ b/packages/mix/lib/src/core/internal/mix_interaction_detector.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../pointer_position.dart'; +import '../providers/focus_highlight_mode_provider.dart'; import '../providers/widget_state_provider.dart'; /// A widget that detects user interactions and provides state tracking with automatic mouse position tracking. @@ -37,6 +38,15 @@ class _MixInteractionDetectorState extends State { WidgetStatesController? _internalController; late final PointerPositionNotifier _cursorPositionNotifier; + /// Global position of the pointer that owns the current pressed state. + Offset? _pressOrigin; + + /// Distance a pointer may drift before it stops counting as a press. + /// + /// Mirrors what [TapGestureRecognizer] uses, so the pressed state and the tap + /// gesture give up on the same movement. + double _touchSlop = kTouchSlop; + @override void initState() { super.initState(); @@ -54,7 +64,7 @@ class _MixInteractionDetectorState extends State { _effectiveController.update(.disabled, !widget.enabled); if (!widget.enabled) { _effectiveController.update(.hovered, false); - _effectiveController.update(.pressed, false); + _clearPressedState(); _cursorPositionNotifier.clearPosition(); widget.onHoverChange?.call(false); } @@ -72,8 +82,9 @@ class _MixInteractionDetectorState extends State { } } - /// Clears the pressed state and notifies listeners. + /// Clears the pressed state and the pointer that owned it. void _clearPressedState() { + _pressOrigin = null; if (!_effectiveController.value.contains(WidgetState.pressed)) return; _effectiveController.update(.pressed, false); } @@ -105,32 +116,43 @@ class _MixInteractionDetectorState extends State { if (event.kind == .mouse && (event.buttons & kPrimaryMouseButton) == 0) { return; } + _pressOrigin = event.position; _effectiveController.update(.pressed, true); } /// Handles pointer up events. void _handlePointerUp(PointerUpEvent event) { if (!mounted) return; - _effectiveController.update(.pressed, false); + _clearPressedState(); } /// Handles pointer cancel events. void _handlePointerCancel(PointerCancelEvent event) { if (!mounted) return; - _effectiveController.update(.pressed, false); + _clearPressedState(); } /// Handles pointer move events to track boundary crossings. void _handlePointerMove(PointerMoveEvent event) { if (!mounted) return; + // A pointer that drifts past the tap slop has become a drag or a scroll, + // so it no longer owns a press. This [Listener] sees raw pointer events + // rather than arena outcomes, and a scrolled widget travels with the + // pointer, so bounds alone never notice. + final pressOrigin = _pressOrigin; + if (pressOrigin != null && + (event.position - pressOrigin).distance > _touchSlop) { + _clearPressedState(); + + return; + } + final size = context.size; if (size == null) return; - final isInside = size.contains(event.localPosition); - // Clear pressed state when moving outside - if (!isInside) { + if (!size.contains(event.localPosition)) { _clearPressedState(); } } @@ -168,6 +190,13 @@ class _MixInteractionDetectorState extends State { widget.controller ?? (_internalController ??= _createInternalController()); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _touchSlop = + MediaQuery.maybeGestureSettingsOf(context)?.touchSlop ?? kTouchSlop; + } + @override void didUpdateWidget(MixInteractionDetector oldWidget) { super.didUpdateWidget(oldWidget); @@ -193,29 +222,35 @@ class _MixInteractionDetectorState extends State { @override Widget build(BuildContext context) { - // Build order: IgnorePointer -> MouseRegion -> Listener -> PointerPositionProvider -> ListenableBuilder -> WidgetStateProvider - return IgnorePointer( - ignoring: !widget.enabled, - child: MouseRegion( - onEnter: _handlePointerEnter, - onExit: _handlePointerExit, - onHover: _handleOnPointerHover, - child: Listener( - onPointerDown: _handlePointerDown, - onPointerMove: _handlePointerMove, - onPointerUp: _handlePointerUp, - onPointerCancel: _handlePointerCancel, - behavior: .opaque, - child: PointerPositionProvider( - notifier: _cursorPositionNotifier, - child: ListenableBuilder( - listenable: _effectiveController, - builder: (context, _) { - return WidgetStateProvider( - states: _effectiveController.value, - child: widget.child, - ); - }, + // Build order: FocusHighlightModeProvider -> IgnorePointer -> MouseRegion -> Listener -> PointerPositionProvider -> ListenableBuilder -> WidgetStateProvider + // + // The focus-highlight scope is paired with the widget-state scope: any + // subtree that can resolve widget-state variants can also resolve the + // focus-visible variant, which needs both signals. + return FocusHighlightModeProvider( + child: IgnorePointer( + ignoring: !widget.enabled, + child: MouseRegion( + onEnter: _handlePointerEnter, + onExit: _handlePointerExit, + onHover: _handleOnPointerHover, + child: Listener( + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + behavior: .opaque, + child: PointerPositionProvider( + notifier: _cursorPositionNotifier, + child: ListenableBuilder( + listenable: _effectiveController, + builder: (context, _) { + return WidgetStateProvider( + states: _effectiveController.value, + child: widget.child, + ); + }, + ), ), ), ), diff --git a/packages/mix/lib/src/core/style_builder.dart b/packages/mix/lib/src/core/style_builder.dart index ca5fab0eed..512b4ccd79 100644 --- a/packages/mix/lib/src/core/style_builder.dart +++ b/packages/mix/lib/src/core/style_builder.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import '../animation/style_animation_builder.dart'; import '../modifiers/internal/render_modifier.dart'; import 'internal/mix_interaction_detector.dart'; +import 'providers/focus_highlight_mode_provider.dart'; import 'providers/style_provider.dart'; import 'providers/style_spec_provider.dart'; import 'providers/widget_state_provider.dart'; @@ -220,11 +221,15 @@ class _ExternalControllerProvider extends StatelessWidget { @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: controller, - builder: (_, _) { - return WidgetStateProvider(states: controller.value, child: child); - }, + // Paired with the widget-state scope so the focus-visible variant can read + // the input modality wherever focused state is published. + return FocusHighlightModeProvider( + child: ListenableBuilder( + listenable: controller, + builder: (_, _) { + return WidgetStateProvider(states: controller.value, child: child); + }, + ), ); } } diff --git a/packages/mix/lib/src/specs/pressable/pressable_widget.dart b/packages/mix/lib/src/specs/pressable/pressable_widget.dart index dc5dc4864f..a058a33c70 100644 --- a/packages/mix/lib/src/specs/pressable/pressable_widget.dart +++ b/packages/mix/lib/src/specs/pressable/pressable_widget.dart @@ -1,8 +1,7 @@ -import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import '../../core/internal/mix_interaction_detector.dart'; -import '../../core/providers/focus_highlight_mode_provider.dart'; import '../../core/providers/widget_state_provider.dart'; import '../box/box_spec.dart'; import '../box/box_widget.dart'; @@ -177,37 +176,50 @@ class PressableWidgetState extends State { void _initController([Set? initialStates]) { _ownsController = widget.controller == null; - _controller = - widget.controller ?? WidgetStatesController(initialStates ?? {}); + _controller = widget.controller ?? WidgetStatesController(initialStates); } void _onTap() { if (!widget.enabled || widget.onPress == null) return; - widget.onPress?.call(); + widget.onPress!(); if (widget.enableFeedback) Feedback.forTap(context); } void _onLongPress() { if (!widget.enabled || widget.onLongPress == null) return; - widget.onLongPress?.call(); + widget.onLongPress!(); if (widget.enableFeedback) Feedback.forLongPress(context); } void _onFocusChange(bool hasFocus) { - if (!hasFocus && _heldActivationKey != null) _cancelHeldActivation(); + if (!hasFocus) _cancelHeldActivation(); _controller.focused = hasFocus; widget.onFocusChange?.call(hasFocus); } + /// Keys Flutter maps to [ActivateIntent] in `WidgetsApp.defaultShortcuts`. + /// + /// Pressable models activation itself instead of binding [ActivateIntent], + /// so it has to cover the same key set or those keys would activate nothing. bool _isActivationKey(LogicalKeyboardKey key) { - return key == .space || key == .enter || key == .numpadEnter; + return key == .space || + key == .enter || + key == .numpadEnter || + key == .select || + key == .gameButtonA; } - void _cancelHeldActivation([WidgetStatesController? controller]) { + /// Releases a held keyboard activation. + /// + /// Guarded so keyboard bookkeeping never clears a pointer-owned press, which + /// [MixInteractionDetector] owns. + void _cancelHeldActivation() { + if (_heldActivationKey == null) return; + _heldActivationKey = null; - (controller ?? _controller).pressed = false; + _controller.pressed = false; } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { @@ -225,14 +237,19 @@ class PressableWidgetState extends State { return .ignored; } - if (!widget.enabled || widget.onPress == null || !node.hasFocus) { - if (event.logicalKey == _heldActivationKey) { - _cancelHeldActivation(); - } + // [FocusNode.hasFocus] is also true while a descendant holds primary focus, + // so only the focused Pressable itself may claim activation keys. Claiming + // them any wider would swallow Space and Enter before a nested text field + // (or app shortcuts) ever sees them. + if (!node.hasPrimaryFocus || !widget.enabled || widget.onPress == null) { + _cancelHeldActivation(); - return .handled; + return .ignored; } + // A focused Pressable owns activation keys while it can activate, so a + // second activation key pressed during a hold is absorbed rather than + // starting a competing activation. if (event is KeyDownEvent) { if (_heldActivationKey == null) { _heldActivationKey = event.logicalKey; @@ -242,21 +259,17 @@ class PressableWidgetState extends State { return .handled; } - if (event is KeyRepeatEvent) { - return .handled; + // Repeats and key ups only concern the key currently being held. + if (event.logicalKey != _heldActivationKey) { + return .ignored; } if (event is KeyUpEvent) { - final shouldActivate = event.logicalKey == _heldActivationKey; - if (shouldActivate) { - _cancelHeldActivation(); - _onTap(); - } - - return .handled; + _cancelHeldActivation(); + _onTap(); } - return .ignored; + return .handled; } bool get hasOnPress => widget.onPress != null; @@ -279,15 +292,17 @@ class PressableWidgetState extends State { if (oldWidget.controller != widget.controller) { final oldController = _controller; - final oldStates = oldController.value; final ownedOldController = _ownsController; - _cancelHeldActivation(oldController); - _initController(widget.controller == null ? oldStates : null); + // Release the held key on the outgoing controller before its states are + // copied, so a keyboard press never survives the swap. + _cancelHeldActivation(); + _initController( + widget.controller == null ? {...oldController.value} : null, + ); if (ownedOldController) oldController.dispose(); } - if ((oldWidget.enabled && !widget.enabled) || - (oldWidget.onPress != null && widget.onPress == null)) { + if (!widget.enabled || widget.onPress == null) { _cancelHeldActivation(); } } @@ -301,6 +316,24 @@ class PressableWidgetState extends State { @override Widget build(BuildContext context) { + Widget focusable = Focus( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onFocusChange: _onFocusChange, + onKeyEvent: _onKeyEvent, + canRequestFocus: widget.canRequestFocus && widget.enabled, + child: MixInteractionDetector( + controller: _controller, + enabled: widget.enabled, + child: widget.child, + ), + ); + + final actions = widget.actions; + if (actions != null) { + focusable = Actions(actions: actions, child: focusable); + } + Widget current = GestureDetector( onTap: widget.enabled && widget.onPress != null ? _onTap : null, onLongPress: widget.enabled && widget.onLongPress != null @@ -308,29 +341,20 @@ class PressableWidgetState extends State { : null, behavior: widget.hitTestBehavior, excludeFromSemantics: true, - child: MouseRegion( - cursor: mouseCursor, - child: Actions( - actions: widget.actions ?? const {}, - child: Focus( - focusNode: widget.focusNode, - autofocus: widget.autofocus, - onFocusChange: _onFocusChange, - onKeyEvent: _onKeyEvent, - canRequestFocus: widget.canRequestFocus && widget.enabled, - child: MixInteractionDetector( - controller: _controller, - enabled: widget.enabled, - child: FocusHighlightModeProvider(child: widget.child), - ), - ), - ), - ), + child: MouseRegion(cursor: mouseCursor, child: focusable), ); if (!widget.excludeFromSemantics) { + // Only claim an enabled/disabled state for something that can be + // disabled: a role, or an activation callback. A bare `none` wrapper is + // not a control, so it should not be announced as one. + final hasEnabledState = + widget.semanticsRole != .none || + widget.onPress != null || + widget.onLongPress != null; + current = Semantics( - enabled: widget.enabled, + enabled: hasEnabledState ? widget.enabled : null, button: widget.semanticsRole == .button ? true : null, link: widget.semanticsRole == .link ? true : null, label: widget.semanticsLabel, diff --git a/packages/mix/lib/src/variants/variant.dart b/packages/mix/lib/src/variants/variant.dart index 2234385655..3834d28fe1 100644 --- a/packages/mix/lib/src/variants/variant.dart +++ b/packages/mix/lib/src/variants/variant.dart @@ -276,6 +276,14 @@ final class WidgetStateVariant extends ContextVariant { final class FocusVisibleVariant extends ContextVariant { FocusVisibleVariant() : super('focus_visible', (context) { + // A forced state override is authoritative and skips the modality + // check: preview tooling asks for the focus-visible look directly and + // has no real input modality to read. + final override = WidgetStateStyleOverride.maybeOf(context); + if (override != null) { + return override.states.contains(WidgetState.focused); + } + return WidgetStateProvider.hasStateOf(context, .focused) && FocusHighlightModeProvider.of(context) == .traditional; }); diff --git a/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart b/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart index 1a0c57d8df..42125480a9 100644 --- a/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart +++ b/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart @@ -161,5 +161,81 @@ void main() { await tester.pump(); expect(controller.pressed, isFalse); }); + + testWidgets( + 'press state clears when the pointer drifts past the tap slop', + (tester) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + controller: controller, + onPress: () {}, + // Large enough that the pointer never leaves the bounds. + child: const SizedBox.expand(), + ), + ), + ); + + final gesture = await tester.startGesture(const Offset(200, 200)); + await tester.pump(const Duration(milliseconds: 120)); + expect(controller.pressed, isTrue); + + await gesture.moveBy(const Offset(0, kTouchSlop / 2)); + await tester.pump(); + expect( + controller.pressed, + isTrue, + reason: 'movement within the slop is still a press', + ); + + await gesture.moveBy(const Offset(0, kTouchSlop)); + await tester.pump(); + expect(controller.pressed, isFalse); + + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('press state clears while scrolling a list of pressables', ( + tester, + ) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: ListView.builder( + itemCount: 30, + itemExtent: 200, + itemBuilder: (context, index) => Pressable( + controller: index == 0 ? controller : null, + onPress: () {}, + child: SizedBox(height: 200, child: Text('item $index')), + ), + ), + ), + ); + + final gesture = await tester.startGesture(const Offset(200, 100)); + await tester.pump(const Duration(milliseconds: 120)); + expect(controller.pressed, isTrue); + + // A scrolled item travels with the pointer, so it never leaves the item + // bounds: only the slop rule can end the press here. + for (var i = 0; i < 5; i++) { + await gesture.moveBy(const Offset(0, -8)); + await tester.pump(const Duration(milliseconds: 16)); + } + + expect(controller.pressed, isFalse); + expect(tester.getTopLeft(find.text('item 0')).dy, lessThan(0)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); }); } diff --git a/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart b/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart index db5f9515d6..600a86269a 100644 --- a/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart +++ b/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart @@ -53,6 +53,114 @@ void main() { } }); + testWidgets('activates on every key Flutter maps to ActivateIntent', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var presses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + for (final key in [ + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.select, + LogicalKeyboardKey.gameButtonA, + ]) { + await tester.sendKeyDownEvent(key); + await tester.pump(); + expect(controller.pressed, isTrue, reason: '${key.debugName} down'); + + await tester.sendKeyUpEvent(key); + await tester.pump(); + expect(controller.pressed, isFalse, reason: '${key.debugName} up'); + expect(presses, 1, reason: '${key.debugName} activation'); + + presses = 0; + } + }); + + testWidgets('leaves activation keys to a focused descendant', ( + tester, + ) async { + final fieldFocus = FocusNode(); + addTearDown(fieldFocus.dispose); + var presses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Pressable( + onPress: () => presses++, + child: TextField(focusNode: fieldFocus), + ), + ), + ), + ); + fieldFocus.requestFocus(); + await tester.pump(); + expect(fieldFocus.hasPrimaryFocus, isTrue); + + for (final key in [LogicalKeyboardKey.space, LogicalKeyboardKey.enter]) { + final handled = await tester.sendKeyDownEvent(key); + await tester.pump(); + await tester.sendKeyUpEvent(key); + await tester.pump(); + + expect( + handled, + isFalse, + reason: '${key.debugName} must reach the field', + ); + expect( + presses, + 0, + reason: '${key.debugName} must not press the parent', + ); + } + }); + + testWidgets('a disabled pressable does not swallow activation keys', ( + tester, + ) async { + final fieldFocus = FocusNode(); + addTearDown(fieldFocus.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Pressable( + enabled: false, + onPress: () {}, + child: TextField(focusNode: fieldFocus), + ), + ), + ), + ); + fieldFocus.requestFocus(); + await tester.pump(); + + final handled = await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(handled, isFalse); + }); + testWidgets('focus loss cancels held keyboard activation', (tester) async { final focusNode = FocusNode(); final controller = WidgetStatesController(); @@ -383,6 +491,54 @@ void main() { handle.dispose(); }); + testWidgets('a roleless wrapper without callbacks has no enabled state', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + const MaterialApp( + home: Pressable( + key: Key('wrapper'), + semanticsRole: PressableSemanticsRole.none, + child: SizedBox(width: 100, height: 100), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(const Key('wrapper'))), + isSemantics(hasEnabledState: false, isButton: false, isLink: false), + ); + + handle.dispose(); + }); + + testWidgets('a roleless control still reports its enabled state', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + key: const Key('control'), + enabled: false, + semanticsRole: PressableSemanticsRole.none, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(const Key('control'))), + isSemantics(hasEnabledState: true, isEnabled: false, isButton: false), + ); + + handle.dispose(); + }); + testWidgets('exposes only enabled callbacks as semantic actions', ( tester, ) async { diff --git a/packages/mix/test/src/variants/focus_visible_variant_test.dart b/packages/mix/test/src/variants/focus_visible_variant_test.dart new file mode 100644 index 0000000000..f76c6987fe --- /dev/null +++ b/packages/mix/test/src/variants/focus_visible_variant_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +void main() { + group('FocusVisibleVariant', () { + late FocusHighlightStrategy previousStrategy; + + setUp(() { + previousStrategy = FocusManager.instance.highlightStrategy; + }); + + tearDown(() { + FocusManager.instance.highlightStrategy = previousStrategy; + }); + + Color? colorOf(WidgetTester tester) { + final container = tester.widget( + find.byKey(const Key('target')), + ); + + return (container.decoration as BoxDecoration?)?.color; + } + + Widget buildWithController(WidgetStatesController controller) { + return MaterialApp( + home: StyleBuilder( + controller: controller, + style: BoxStyler() + .size(50, 50) + .color(Colors.blue) + .onFocusVisible(BoxStyler().color(Colors.red)), + builder: (context, spec) => + Container(key: const Key('target'), decoration: spec.decoration), + ), + ); + } + + testWidgets('tracks highlight mode without a Pressable ancestor', ( + tester, + ) async { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTouch; + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + controller.focused = true; + + await tester.pumpWidget(buildWithController(controller)); + expect(colorOf(tester), Colors.blue); + + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + await tester.pump(); + expect(colorOf(tester), Colors.red); + + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTouch; + await tester.pump(); + expect(colorOf(tester), Colors.blue); + }); + + testWidgets('needs focused state, not just traditional highlighting', ( + tester, + ) async { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(buildWithController(controller)); + expect(colorOf(tester), Colors.blue); + + controller.focused = true; + await tester.pump(); + expect(colorOf(tester), Colors.red); + }); + + testWidgets('a forced state override wins over the input modality', ( + tester, + ) async { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTouch; + + await tester.pumpWidget( + MaterialApp( + home: WidgetStateStyleOverride( + states: const {WidgetState.focused}, + child: Box( + key: const Key('target'), + style: BoxStyler() + .size(50, 50) + .color(Colors.blue) + .onFocusVisible(BoxStyler().color(Colors.red)), + ), + ), + ), + ); + + expect( + (tester + .widget( + find.descendant( + of: find.byKey(const Key('target')), + matching: find.byType(Container), + ), + ) + .decoration + as BoxDecoration?) + ?.color, + Colors.red, + ); + }); + }); +} diff --git a/skills/mix/references/fluent-api.md b/skills/mix/references/fluent-api.md index 15d116c62c..add2114826 100644 --- a/skills/mix/references/fluent-api.md +++ b/skills/mix/references/fluent-api.md @@ -223,8 +223,17 @@ Use `Pressable` for interaction state around any child, and `PressableBox` when | `canRequestFocus` | Whether focus can be requested; defaults to `true` | | `controller` | Optional `WidgetStatesController` | | `actions` | Additional focus actions | - -`Pressable` also exposes keyboard and semantics parameters such as `onKey`, `onKeyEvent`, `excludeFromSemantics`, and `semanticButtonLabel`; check `pressable_widget.dart` for the full constructor. +| `onKeyEvent` | Custom key handling; runs before built-in activation | +| `semanticsLabel` | Accessibility label | +| `semanticsRole` | `PressableSemanticsRole.button` (default), `link`, or `none` | +| `excludeFromSemantics` | Emits no semantics node; defaults to `false` | + +While focused, `Pressable` handles the activation keys itself (Space, Enter, +numpad Enter, select, game button A): pressed on key down, activated once on key +up. It leaves those keys alone when a descendant holds focus, so a nested +`TextField` still receives them. Custom `actions` therefore never see +`ActivateIntent` for a focused Pressable; use `onKeyEvent` to override +activation. ### PressableBox @@ -240,7 +249,7 @@ Use `Pressable` for interaction state around any child, and `PressableBox` when | `enableFeedback` | Enables haptic/audio feedback; defaults to `false` | | `hitTestBehavior` | Gesture hit-test behavior; defaults to `HitTestBehavior.opaque` | -`PressableBox` forwards interaction handling to `Pressable` and renders the child through `Box(style: style, child: child)`. +`PressableBox` forwards the full `Pressable` surface — including `mouseCursor`, `canRequestFocus`, `onKeyEvent`, `controller`, `actions`, `semanticsLabel`, `semanticsRole`, and `excludeFromSemantics` — and renders the child through `Box(style: style, child: child)`. ## Sizing Decision Tree diff --git a/skills/mix/references/variants.md b/skills/mix/references/variants.md index 3e47d56b4d..6de3d4e11d 100644 --- a/skills/mix/references/variants.md +++ b/skills/mix/references/variants.md @@ -64,6 +64,7 @@ Available on all Stylers via `WidgetStateVariantMixin`: | `onHovered(style)` | `WidgetState.hovered` | | `onPressed(style)` | `WidgetState.pressed` | | `onFocused(style)` | `WidgetState.focused` | +| `onFocusVisible(style)` | `WidgetState.focused` while Flutter's focus highlight mode is `traditional` (keyboard/directional input) — use it for focus rings that should not appear on touch | | `onDisabled(style)` | `WidgetState.disabled` | | `onEnabled(style)` | Not disabled | From a104484e77a680836dacf81e71195ba06f3b41ea Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 6 Aug 2026 10:09:47 -0400 Subject: [PATCH 3/3] fix(mix): combine pointer and keyboard press sources in Pressable 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. --- packages/mix/CHANGELOG.md | 19 +- .../internal/mix_interaction_detector.dart | 63 ++++-- .../src/specs/pressable/pressable_widget.dart | 109 ++++++++-- .../pressable/pressable_hover_press_test.dart | 148 +++++++++++++ .../pressable_keyboard_semantics_test.dart | 197 +++++++++++++++++- .../pressable/pressable_widget_test.dart | 46 +++- skills/mix/references/fluent-api.md | 20 +- 7 files changed, 545 insertions(+), 57 deletions(-) diff --git a/packages/mix/CHANGELOG.md b/packages/mix/CHANGELOG.md index a48d01a8ae..d769cdf9a0 100644 --- a/packages/mix/CHANGELOG.md +++ b/packages/mix/CHANGELOG.md @@ -16,20 +16,23 @@ `semanticsLabel`, added `semanticsRole`, and removed the deprecated `onKey` callback. Use `onKeyEvent` for custom keyboard handling. - **Reserved activation keys:** While it holds primary focus and can activate, - Pressable owns the keys Flutter maps to `ActivateIntent` — Space, Enter, - numpad Enter, select, and game button A — so it can model held-key state - consistently. Custom activation behavior must use `onKeyEvent`; custom - `actions` remain supported for other intents. Those keys are left untouched - when a descendant holds focus, so nested text fields keep working. + Pressable owns unmodified Space, Enter, numpad Enter, select, and game button + A so it can model held-key state consistently. Override those direct key + bindings with `onKeyEvent`; custom `actions` remain available to other + shortcuts and programmatic intents. Those keys are left untouched when a + descendant holds focus, and modified chords are left to application + shortcuts. ### Fixes - **Nested widget-state discovery:** `StyleBuilder` now discovers state requirements recursively through nested and negated variants with identity-based cycle protection. -- **Pressable lifecycle:** Pointer state has one owner, keyboard activation - fires once on key-up, cancellation clears held state, focus-visible follows - Flutter input modality, and disabled semantics expose no actions. +- **Pressable lifecycle:** Pointer and keyboard press sources are combined + without clearing each other, keyboard activation fires once on key-up, + cancellation clears held state, focus-visible follows Flutter input modality, + and disabled controls ignore custom key handling and expose neither semantic + nor custom actions. - **Press state ends with the gesture:** A pointer that drifts past the tap slop stops counting as a press, so items no longer stay visually pressed while a list scrolls under the finger. diff --git a/packages/mix/lib/src/core/internal/mix_interaction_detector.dart b/packages/mix/lib/src/core/internal/mix_interaction_detector.dart index d57b8f2e9e..7cee8bbaa5 100644 --- a/packages/mix/lib/src/core/internal/mix_interaction_detector.dart +++ b/packages/mix/lib/src/core/internal/mix_interaction_detector.dart @@ -21,6 +21,8 @@ class MixInteractionDetector extends StatefulWidget { this.controller, this.enabled = true, this.onHoverChange, + this.onPressChange, + this.managesPressedState = true, this.onPointerPositionChange, }); @@ -28,6 +30,14 @@ class MixInteractionDetector extends StatefulWidget { final WidgetStatesController? controller; final bool enabled; final ValueChanged? onHoverChange; + final ValueChanged? onPressChange; + + /// Whether pointer input is written directly to [controller]. + /// + /// Set this to false when the owner combines pointer presses with another + /// input source before publishing [WidgetState.pressed]. + final bool managesPressedState; + final ValueChanged? onPointerPositionChange; @override @@ -41,6 +51,9 @@ class _MixInteractionDetectorState extends State { /// Global position of the pointer that owns the current pressed state. Offset? _pressOrigin; + /// Pointer that owns the current pressed state. + int? _pressPointer; + /// Distance a pointer may drift before it stops counting as a press. /// /// Mirrors what [TapGestureRecognizer] uses, so the pressed state and the tap @@ -64,7 +77,7 @@ class _MixInteractionDetectorState extends State { _effectiveController.update(.disabled, !widget.enabled); if (!widget.enabled) { _effectiveController.update(.hovered, false); - _clearPressedState(); + _clearPressedState(force: true); _cursorPositionNotifier.clearPosition(); widget.onHoverChange?.call(false); } @@ -82,11 +95,22 @@ class _MixInteractionDetectorState extends State { } } - /// Clears the pressed state and the pointer that owned it. - void _clearPressedState() { + /// Clears the pressed state if [pointer] owns it. + void _clearPressedState({int? pointer, bool force = false}) { + final pressPointer = _pressPointer; + if (!force && + (pressPointer == null || + (pointer != null && pointer != pressPointer))) { + return; + } + + final hadPointerPress = pressPointer != null; + _pressPointer = null; _pressOrigin = null; - if (!_effectiveController.value.contains(WidgetState.pressed)) return; - _effectiveController.update(.pressed, false); + if (widget.managesPressedState) { + _effectiveController.update(.pressed, false); + } + if (hadPointerPress) widget.onPressChange?.call(false); } /// Handles pointer entering the widget bounds. @@ -106,36 +130,42 @@ class _MixInteractionDetectorState extends State { widget.onHoverChange?.call(false); // Clear pressed state if active (edge case handling) - _clearPressedState(); + _clearPressedState(pointer: event.pointer); } /// Handles pointer down events for all pointer types. void _handlePointerDown(PointerDownEvent event) { if (!mounted) return; - // Only treat primary mouse button as "pressed" for mouse; all other kinds count. - if (event.kind == .mouse && (event.buttons & kPrimaryMouseButton) == 0) { - return; - } + + // Match GestureDetector's primary tap recognizer across device kinds. + if (_pressPointer != null || event.buttons != kPrimaryButton) return; + + _pressPointer = event.pointer; _pressOrigin = event.position; - _effectiveController.update(.pressed, true); + if (widget.managesPressedState) { + _effectiveController.update(.pressed, true); + } + widget.onPressChange?.call(true); } /// Handles pointer up events. void _handlePointerUp(PointerUpEvent event) { if (!mounted) return; - _clearPressedState(); + _clearPressedState(pointer: event.pointer); } /// Handles pointer cancel events. void _handlePointerCancel(PointerCancelEvent event) { if (!mounted) return; - _clearPressedState(); + _clearPressedState(pointer: event.pointer); } /// Handles pointer move events to track boundary crossings. void _handlePointerMove(PointerMoveEvent event) { if (!mounted) return; + if (event.pointer != _pressPointer) return; + // A pointer that drifts past the tap slop has become a drag or a scroll, // so it no longer owns a press. This [Listener] sees raw pointer events // rather than arena outcomes, and a scrolled widget travels with the @@ -143,7 +173,7 @@ class _MixInteractionDetectorState extends State { final pressOrigin = _pressOrigin; if (pressOrigin != null && (event.position - pressOrigin).distance > _touchSlop) { - _clearPressedState(); + _clearPressedState(pointer: event.pointer); return; } @@ -153,7 +183,7 @@ class _MixInteractionDetectorState extends State { // Clear pressed state when moving outside if (!size.contains(event.localPosition)) { - _clearPressedState(); + _clearPressedState(pointer: event.pointer); } } @@ -205,6 +235,9 @@ class _MixInteractionDetectorState extends State { if (oldWidget.controller != widget.controller) { _handleControllerChange(oldWidget); _syncDisabledState(); + if (widget.managesPressedState && _pressPointer != null) { + _effectiveController.update(.pressed, true); + } } // Handle enabled state changes diff --git a/packages/mix/lib/src/specs/pressable/pressable_widget.dart b/packages/mix/lib/src/specs/pressable/pressable_widget.dart index a058a33c70..78d0bbb6a6 100644 --- a/packages/mix/lib/src/specs/pressable/pressable_widget.dart +++ b/packages/mix/lib/src/specs/pressable/pressable_widget.dart @@ -7,7 +7,16 @@ import '../box/box_spec.dart'; import '../box/box_widget.dart'; /// The accessibility role exposed by a [Pressable]. -enum PressableSemanticsRole { button, link, none } +enum PressableSemanticsRole { + /// Exposes the control as a button. + button, + + /// Exposes the control as a link. + link, + + /// Adds no button or link role while preserving other semantics. + none, +} /// Combines [Box] styling with gesture handling. /// @@ -56,6 +65,8 @@ class PressableBox extends StatelessWidget { final bool excludeFromSemantics; final String? semanticsLabel; final PressableSemanticsRole semanticsRole; + + /// Handles key events before built-in activation while enabled. final FocusOnKeyEventCallback? onKeyEvent; final WidgetStatesController? controller; final Map>? actions; @@ -147,7 +158,7 @@ class Pressable extends StatefulWidget { /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; - /// {@macro flutter.widgets.Focus.onKeyEvent} + /// Handles key events before built-in activation while [enabled]. final FocusOnKeyEventCallback? onKeyEvent; /// {@macro flutter.widgets.GestureDetector.hitTestBehavior} @@ -159,7 +170,7 @@ class Pressable extends StatefulWidget { final WidgetStatesController? controller; @override - State createState() => PressableWidgetState(); + State createState() => PressableWidgetState(); } @visibleForTesting @@ -167,6 +178,9 @@ class PressableWidgetState extends State { late WidgetStatesController _controller; late bool _ownsController; LogicalKeyboardKey? _heldActivationKey; + bool _hovered = false; + bool _focused = false; + bool _pointerPressed = false; @override void initState() { @@ -195,14 +209,16 @@ class PressableWidgetState extends State { void _onFocusChange(bool hasFocus) { if (!hasFocus) _cancelHeldActivation(); + _focused = hasFocus; _controller.focused = hasFocus; widget.onFocusChange?.call(hasFocus); } - /// Keys Flutter maps to [ActivateIntent] in `WidgetsApp.defaultShortcuts`. - /// - /// Pressable models activation itself instead of binding [ActivateIntent], - /// so it has to cover the same key set or those keys would activate nothing. + void _onHoverChange(bool isHovered) { + _hovered = isHovered; + } + + /// Keys Pressable supports for direct keyboard/game-controller activation. bool _isActivationKey(LogicalKeyboardKey key) { return key == .space || key == .enter || @@ -211,6 +227,30 @@ class PressableWidgetState extends State { key == .gameButtonA; } + bool get _hasActivationModifier { + final keyboard = HardwareKeyboard.instance; + + return keyboard.isAltPressed || + keyboard.isControlPressed || + keyboard.isMetaPressed || + keyboard.isShiftPressed; + } + + void _syncPressedState() { + _controller.pressed = _pointerPressed || _heldActivationKey != null; + } + + void _syncInteractionStates() { + _controller.hovered = _hovered; + _controller.focused = _focused; + _syncPressedState(); + } + + void _onPointerPressChange(bool isPressed) { + _pointerPressed = isPressed; + _syncPressedState(); + } + /// Releases a held keyboard activation. /// /// Guarded so keyboard bookkeeping never clears a pointer-owned press, which @@ -219,10 +259,16 @@ class PressableWidgetState extends State { if (_heldActivationKey == null) return; _heldActivationKey = null; - _controller.pressed = false; + _syncPressedState(); } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.enabled) { + _cancelHeldActivation(); + + return .ignored; + } + final customResult = widget.onKeyEvent?.call(node, event) ?? .ignored; if (customResult != .ignored) { @@ -241,7 +287,7 @@ class PressableWidgetState extends State { // so only the focused Pressable itself may claim activation keys. Claiming // them any wider would swallow Space and Enter before a nested text field // (or app shortcuts) ever sees them. - if (!node.hasPrimaryFocus || !widget.enabled || widget.onPress == null) { + if (!node.hasPrimaryFocus || widget.onPress == null) { _cancelHeldActivation(); return .ignored; @@ -252,16 +298,21 @@ class PressableWidgetState extends State { // starting a competing activation. if (event is KeyDownEvent) { if (_heldActivationKey == null) { + // Leave modified key chords to application shortcuts. + if (_hasActivationModifier) return .ignored; + _heldActivationKey = event.logicalKey; - _controller.pressed = true; + _syncPressedState(); } return .handled; } - // Repeats and key ups only concern the key currently being held. + // Keep all events from a competing activation key contained while the + // original key is held. Otherwise its repeat can escape to an ancestor + // shortcut even though its key-down was handled here. if (event.logicalKey != _heldActivationKey) { - return .ignored; + return _heldActivationKey == null ? .ignored : .handled; } if (event is KeyUpEvent) { @@ -293,12 +344,19 @@ class PressableWidgetState extends State { if (oldWidget.controller != widget.controller) { final oldController = _controller; final ownedOldController = _ownsController; - // Release the held key on the outgoing controller before its states are - // copied, so a keyboard press never survives the swap. - _cancelHeldActivation(); + // A held key belongs to the outgoing controller and never survives a + // controller swap. + _heldActivationKey = null; + // Live pointer, hover, and focus sources do survive. Remove their values + // from the outgoing controller before publishing them to the new one. + oldController + ..hovered = false + ..focused = false + ..pressed = false; _initController( widget.controller == null ? {...oldController.value} : null, ); + _syncInteractionStates(); if (ownedOldController) oldController.dispose(); } @@ -309,7 +367,11 @@ class PressableWidgetState extends State { @override void dispose() { - _cancelHeldActivation(); + _heldActivationKey = null; + _hovered = false; + _focused = false; + _pointerPressed = false; + _syncInteractionStates(); if (_ownsController) _controller.dispose(); super.dispose(); } @@ -322,17 +384,23 @@ class PressableWidgetState extends State { onFocusChange: _onFocusChange, onKeyEvent: _onKeyEvent, canRequestFocus: widget.canRequestFocus && widget.enabled, + includeSemantics: !widget.excludeFromSemantics, child: MixInteractionDetector( controller: _controller, enabled: widget.enabled, + onHoverChange: _onHoverChange, + onPressChange: _onPointerPressChange, + managesPressedState: false, child: widget.child, ), ); - final actions = widget.actions; - if (actions != null) { - focusable = Actions(actions: actions, child: focusable); - } + // Keep the subtree shape stable when enabled changes while withholding + // custom actions from disabled controls. + focusable = Actions( + actions: widget.enabled ? (widget.actions ?? const {}) : const {}, + child: focusable, + ); Widget current = GestureDetector( onTap: widget.enabled && widget.onPress != null ? _onTap : null, @@ -354,6 +422,7 @@ class PressableWidgetState extends State { widget.onLongPress != null; current = Semantics( + container: hasEnabledState || widget.semanticsLabel != null, enabled: hasEnabledState ? widget.enabled : null, button: widget.semanticsRole == .button ? true : null, link: widget.semanticsRole == .link ? true : null, diff --git a/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart b/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart index 42125480a9..3c437ccb42 100644 --- a/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart +++ b/packages/mix/test/src/specs/pressable/pressable_hover_press_test.dart @@ -237,5 +237,153 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); }); + + testWidgets('only the pointer that started a press can end it', ( + tester, + ) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Pressable( + controller: controller, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ); + + final center = tester.getCenter(find.byType(Pressable)); + final owner = await tester.startGesture(center, pointer: 1); + final other = await tester.startGesture( + center + const Offset(1, 1), + pointer: 2, + ); + await tester.pump(); + expect(controller.pressed, isTrue); + + await other.up(); + await tester.pump(); + expect(controller.pressed, isTrue, reason: 'the owner is still down'); + + await owner.up(); + await tester.pump(); + expect(controller.pressed, isFalse); + }); + + testWidgets('controller swap moves active interaction states', ( + tester, + ) async { + final firstController = WidgetStatesController(); + final secondController = WidgetStatesController(); + final focusNode = FocusNode(); + addTearDown(firstController.dispose); + addTearDown(secondController.dispose); + addTearDown(focusNode.dispose); + var useFirstController = true; + late StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, stateSetter) { + setState = stateSetter; + + return Center( + child: Pressable( + focusNode: focusNode, + controller: useFirstController + ? firstController + : secondController, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ), + ); + }, + ), + ), + ); + + focusNode.requestFocus(); + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + final center = tester.getCenter(find.byType(Pressable)); + await gesture.addPointer(location: center); + await gesture.down(center); + await tester.pump(); + expect(firstController.pressed, isTrue); + expect(firstController.hovered, isTrue); + expect(firstController.focused, isTrue); + + setState(() => useFirstController = false); + await tester.pump(); + expect(firstController.pressed, isFalse); + expect(firstController.hovered, isFalse); + expect(firstController.focused, isFalse); + expect(secondController.pressed, isTrue); + expect(secondController.hovered, isTrue); + expect(secondController.focused, isTrue); + + await gesture.up(); + await tester.pump(); + expect(secondController.pressed, isFalse); + expect(secondController.hovered, isTrue); + expect(secondController.focused, isTrue); + + await gesture.removePointer(); + }); + + testWidgets('disposal clears transient states on an external controller', ( + tester, + ) async { + final controller = WidgetStatesController(); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + var showPressable = true; + late StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, stateSetter) { + setState = stateSetter; + + return Center( + child: showPressable + ? Pressable( + focusNode: focusNode, + controller: controller, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ) + : const SizedBox(width: 100, height: 100), + ); + }, + ), + ), + ); + + focusNode.requestFocus(); + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + final center = tester.getCenter(find.byType(Pressable)); + await gesture.addPointer(location: center); + await gesture.down(center); + await tester.pump(); + expect(controller.pressed, isTrue); + expect(controller.hovered, isTrue); + expect(controller.focused, isTrue); + + setState(() => showPressable = false); + await tester.pump(); + expect(controller.pressed, isFalse); + expect(controller.hovered, isFalse); + expect(controller.focused, isFalse); + + await gesture.up(); + await gesture.removePointer(); + }); }); } diff --git a/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart b/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart index 600a86269a..bd8e5514a2 100644 --- a/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart +++ b/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; @@ -53,7 +54,7 @@ void main() { } }); - testWidgets('activates on every key Flutter maps to ActivateIntent', ( + testWidgets('activates on each supported auxiliary activation key', ( tester, ) async { final focusNode = FocusNode(); @@ -138,6 +139,7 @@ void main() { ) async { final fieldFocus = FocusNode(); addTearDown(fieldFocus.dispose); + var keyEvents = 0; await tester.pumpWidget( MaterialApp( @@ -145,6 +147,11 @@ void main() { body: Pressable( enabled: false, onPress: () {}, + onKeyEvent: (_, _) { + keyEvents++; + + return KeyEventResult.handled; + }, child: TextField(focusNode: fieldFocus), ), ), @@ -159,6 +166,7 @@ void main() { await tester.pump(); expect(handled, isFalse); + expect(keyEvents, 0); }); testWidgets('focus loss cancels held keyboard activation', (tester) async { @@ -408,6 +416,193 @@ void main() { expect(presses, 1); expect(customActivations, 0); }); + + testWidgets('leaves modified activation keys to application shortcuts', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var presses = 0; + var shortcuts = 0; + + await tester.pumpWidget( + MaterialApp( + home: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter, control: true): + _ProbeIntent(), + }, + child: Actions( + actions: { + _ProbeIntent: CallbackAction<_ProbeIntent>( + onInvoke: (_) => shortcuts++, + ), + }, + child: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(shortcuts, 1); + expect(presses, 0); + expect(controller.pressed, isFalse); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + }); + + testWidgets('absorbs competing activation-key repeats during a hold', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var presses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () => presses++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + expect(await tester.sendKeyDownEvent(LogicalKeyboardKey.space), isTrue); + expect(await tester.sendKeyDownEvent(LogicalKeyboardKey.enter), isTrue); + expect(await tester.sendKeyRepeatEvent(LogicalKeyboardKey.enter), isTrue); + expect(await tester.sendKeyUpEvent(LogicalKeyboardKey.enter), isTrue); + expect(controller.pressed, isTrue); + expect(presses, 0); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(controller.pressed, isFalse); + expect(presses, 1); + }); + + testWidgets('a disabled Pressable does not install custom actions', ( + tester, + ) async { + BuildContext? childContext; + + await tester.pumpWidget( + MaterialApp( + home: Pressable( + enabled: false, + actions: { + _ProbeIntent: CallbackAction<_ProbeIntent>(onInvoke: (_) => null), + }, + child: Builder( + builder: (context) { + childContext = context; + + return const SizedBox(width: 100, height: 100); + }, + ), + ), + ), + ); + + expect(Actions.maybeFind<_ProbeIntent>(childContext!), isNull); + }); + + testWidgets('pointer press survives keyboard activation release', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); + final center = tester.getCenter(find.byType(Pressable)); + await pointer.addPointer(location: center); + await pointer.down(center); + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(controller.pressed, isTrue); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(controller.pressed, isTrue, reason: 'pointer is still down'); + + await pointer.up(); + await tester.pump(); + expect(controller.pressed, isFalse); + }); + + testWidgets('keyboard press survives pointer release', (tester) async { + final focusNode = FocusNode(); + final controller = WidgetStatesController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Pressable( + focusNode: focusNode, + controller: controller, + onPress: () {}, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); + final center = tester.getCenter(find.byType(Pressable)); + await pointer.addPointer(location: center); + await pointer.down(center); + await tester.pump(); + expect(controller.pressed, isTrue); + + await pointer.up(); + await tester.pump(); + expect(controller.pressed, isTrue, reason: 'keyboard key is still down'); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(controller.pressed, isFalse); + }); }); group('Pressable focus visibility', () { diff --git a/packages/mix/test/src/specs/pressable/pressable_widget_test.dart b/packages/mix/test/src/specs/pressable/pressable_widget_test.dart index 809347fc7b..02b00c2576 100644 --- a/packages/mix/test/src/specs/pressable/pressable_widget_test.dart +++ b/packages/mix/test/src/specs/pressable/pressable_widget_test.dart @@ -172,9 +172,7 @@ void main() { focusNode.dispose(); }); - testWidgets('handles keyboard activation with ActivateIntent', ( - tester, - ) async { + testWidgets('handles Enter and Space keyboard activation', (tester) async { bool wasPressed = false; final focusNode = FocusNode(); @@ -299,18 +297,58 @@ void main() { }); testWidgets('excludes semantics when requested', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( MaterialApp( home: Pressable( onPress: () {}, excludeFromSemantics: true, semanticsLabel: 'Test Button', - child: const SizedBox(width: 100, height: 100), + child: const SizedBox( + width: 100, + height: 100, + child: Text('Visible child'), + ), ), ), ); expect(find.bySemanticsLabel('Test Button'), findsNothing); + expect( + tester.getSemantics(find.text('Visible child')), + isSemantics( + label: 'Visible child', + isFocusable: false, + hasFocusAction: false, + hasTapAction: false, + ), + ); + + handle.dispose(); + }); + + testWidgets('keeps nested control semantics separate', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Pressable( + onPress: () {}, + child: const TextField(key: Key('field')), + ), + ), + ), + ); + + final field = tester + .getSemantics(find.byType(EditableText)) + .flagsCollection; + expect(field.isTextField, isTrue); + expect(field.isButton, isFalse); + + handle.dispose(); }); testWidgets('properly disposes controller when not provided', ( diff --git a/skills/mix/references/fluent-api.md b/skills/mix/references/fluent-api.md index add2114826..95fb0753c5 100644 --- a/skills/mix/references/fluent-api.md +++ b/skills/mix/references/fluent-api.md @@ -223,17 +223,19 @@ Use `Pressable` for interaction state around any child, and `PressableBox` when | `canRequestFocus` | Whether focus can be requested; defaults to `true` | | `controller` | Optional `WidgetStatesController` | | `actions` | Additional focus actions | -| `onKeyEvent` | Custom key handling; runs before built-in activation | +| `onKeyEvent` | Custom key handling while enabled; runs before built-in activation | | `semanticsLabel` | Accessibility label | | `semanticsRole` | `PressableSemanticsRole.button` (default), `link`, or `none` | -| `excludeFromSemantics` | Emits no semantics node; defaults to `false` | - -While focused, `Pressable` handles the activation keys itself (Space, Enter, -numpad Enter, select, game button A): pressed on key down, activated once on key -up. It leaves those keys alone when a descendant holds focus, so a nested -`TextField` still receives them. Custom `actions` therefore never see -`ActivateIntent` for a focused Pressable; use `onKeyEvent` to override -activation. +| `excludeFromSemantics` | Suppresses Pressable's semantic annotations while preserving descendant semantics; defaults to `false` | + +While focused, `Pressable` handles unmodified Space, Enter, numpad Enter, +select, and game button A itself: pressed on key down, activated once on key up. +It leaves those keys alone when a descendant holds focus, so a nested +`TextField` still receives them, and leaves modified chords to application +shortcuts. Those direct key bindings are handled before Flutter can dispatch an +`ActivateIntent`; use `onKeyEvent` to override them. Custom actions remain +available to other shortcuts and programmatic intents, but are not installed +while the Pressable is disabled. ### PressableBox