diff --git a/packages/mix/CHANGELOG.md b/packages/mix/CHANGELOG.md index c3c269a34..b090ee88d 100644 --- a/packages/mix/CHANGELOG.md +++ b/packages/mix/CHANGELOG.md @@ -7,6 +7,25 @@ discovery instead of relying on the framework recognizing a specific variant type. Automatic self-tracking is limited to pointer-driven hover and press; other states still require an ancestor scope or external controller. +- **Typed focus-visible variants:** Added `FocusVisibleVariant`, + `ContextVariant.focusVisible()`, and `onFocusVisible(...)`, which apply while + focus is highlighted in Flutter's traditional (keyboard/directional) mode. +- **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:** While it holds primary focus and can activate, + 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 @@ -22,6 +41,18 @@ so styles depending solely on those no longer gain an opaque hit-test target that swallowed pointer events aimed at widgets beneath them, and no longer hijack the state scope of descendants that do track hover. +- **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. +- **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 3ad3b6ef3..1768bcb14 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. @@ -20,6 +21,8 @@ class MixInteractionDetector extends StatefulWidget { this.controller, this.enabled = true, this.onHoverChange, + this.onPressChange, + this.managesPressedState = true, this.onPointerPositionChange, }); @@ -40,6 +43,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 @@ -50,6 +61,18 @@ class _MixInteractionDetectorState extends State { WidgetStatesController? _internalController; late final PointerPositionNotifier _cursorPositionNotifier; + /// 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 + /// gesture give up on the same movement. + double _touchSlop = kTouchSlop; + @override void initState() { super.initState(); @@ -67,7 +90,7 @@ class _MixInteractionDetectorState extends State { _effectiveController.update(.disabled, !widget.enabled); if (!widget.enabled) { _effectiveController.update(.hovered, false); - _effectiveController.update(.pressed, false); + _clearPressedState(force: true); _cursorPositionNotifier.clearPosition(); widget.onHoverChange?.call(false); } @@ -85,10 +108,22 @@ class _MixInteractionDetectorState extends State { } } - /// Clears the pressed state and notifies listeners. - void _clearPressedState() { - if (!_effectiveController.value.contains(WidgetState.pressed)) return; - _effectiveController.update(.pressed, false); + /// 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 (widget.managesPressedState) { + _effectiveController.update(.pressed, false); + } + if (hadPointerPress) widget.onPressChange?.call(false); } /// Handles pointer entering the widget bounds. @@ -108,43 +143,60 @@ 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; + if (widget.managesPressedState) { + _effectiveController.update(.pressed, true); } - _effectiveController.update(.pressed, true); + widget.onPressChange?.call(true); } /// Handles pointer up events. void _handlePointerUp(PointerUpEvent event) { if (!mounted) return; - _effectiveController.update(.pressed, false); + _clearPressedState(pointer: event.pointer); } /// Handles pointer cancel events. void _handlePointerCancel(PointerCancelEvent event) { if (!mounted) return; - _effectiveController.update(.pressed, false); + _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 + // pointer, so bounds alone never notice. + final pressOrigin = _pressOrigin; + if (pressOrigin != null && + (event.position - pressOrigin).distance > _touchSlop) { + _clearPressedState(pointer: event.pointer); + + return; + } + final size = context.size; if (size == null) return; - final isInside = size.contains(event.localPosition); - // Clear pressed state when moving outside - if (!isInside) { - _clearPressedState(); + if (!size.contains(event.localPosition)) { + _clearPressedState(pointer: event.pointer); } } @@ -181,6 +233,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); @@ -189,6 +248,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 @@ -206,29 +268,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/providers/focus_highlight_mode_provider.dart b/packages/mix/lib/src/core/providers/focus_highlight_mode_provider.dart new file mode 100644 index 000000000..82dfea9da --- /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/core/style_builder.dart b/packages/mix/lib/src/core/style_builder.dart index 5fcc9e559..5a7d0c13b 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'; @@ -227,11 +228,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 7edce92e0..78d0bbb6a 100644 --- a/packages/mix/lib/src/specs/pressable/pressable_widget.dart +++ b/packages/mix/lib/src/specs/pressable/pressable_widget.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../../core/internal/mix_interaction_detector.dart'; @@ -5,6 +6,18 @@ 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 { + /// 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. /// /// Provides press, long press, and focus interactions. @@ -19,6 +32,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 +58,18 @@ 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; + + /// Handles key events before built-in activation while enabled. + final FocusOnKeyEventCallback? onKeyEvent; + final WidgetStatesController? controller; + final Map>? actions; final HitTestBehavior hitTestBehavior; @@ -47,12 +79,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 +116,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 +132,9 @@ class Pressable extends StatefulWidget { final MouseCursor? mouseCursor; - final String? semanticButtonLabel; + final String? semanticsLabel; + + final PressableSemanticsRole semanticsRole; final bool excludeFromSemantics; @@ -115,10 +158,7 @@ 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} + /// Handles key events before built-in activation while [enabled]. final FocusOnKeyEventCallback? onKeyEvent; /// {@macro flutter.widgets.GestureDetector.hitTestBehavior} @@ -130,38 +170,159 @@ class Pressable extends StatefulWidget { final WidgetStatesController? controller; @override - State createState() => PressableWidgetState(); + State createState() => PressableWidgetState(); } @visibleForTesting class PressableWidgetState extends State { - late final WidgetStatesController _controller; + late WidgetStatesController _controller; + late bool _ownsController; + LogicalKeyboardKey? _heldActivationKey; + bool _hovered = false; + bool _focused = false; + bool _pointerPressed = false; @override void initState() { super.initState(); - _controller = widget.controller ?? WidgetStatesController(); + _initController(); } - void _onTap() { - widget.onPress?.call(); - if (widget.enableFeedback) Feedback.forTap(context); + void _initController([Set? initialStates]) { + _ownsController = widget.controller == null; + _controller = widget.controller ?? WidgetStatesController(initialStates); } - void _onTapUp() => _controller.pressed = false; + void _onTap() { + if (!widget.enabled || widget.onPress == null) return; - void _onTapDown() => _controller.pressed = true; + widget.onPress!(); + if (widget.enableFeedback) Feedback.forTap(context); + } void _onLongPress() { - widget.onLongPress?.call(); + if (!widget.enabled || widget.onLongPress == null) return; + + widget.onLongPress!(); if (widget.enableFeedback) Feedback.forLongPress(context); } void _onFocusChange(bool hasFocus) { + if (!hasFocus) _cancelHeldActivation(); + _focused = hasFocus; _controller.focused = hasFocus; widget.onFocusChange?.call(hasFocus); } + void _onHoverChange(bool isHovered) { + _hovered = isHovered; + } + + /// Keys Pressable supports for direct keyboard/game-controller activation. + bool _isActivationKey(LogicalKeyboardKey key) { + return key == .space || + key == .enter || + key == .numpadEnter || + key == .select || + 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 + /// [MixInteractionDetector] owns. + void _cancelHeldActivation() { + if (_heldActivationKey == null) return; + + _heldActivationKey = null; + _syncPressedState(); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.enabled) { + _cancelHeldActivation(); + + return .ignored; + } + + 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; + } + + // [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.onPress == null) { + _cancelHeldActivation(); + + 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) { + // Leave modified key chords to application shortcuts. + if (_hasActivationModifier) return .ignored; + + _heldActivationKey = event.logicalKey; + _syncPressedState(); + } + + return .handled; + } + + // 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 _heldActivationKey == null ? .ignored : .handled; + } + + if (event is KeyUpEvent) { + _cancelHeldActivation(); + _onTap(); + } + + return .handled; + } + bool get hasOnPress => widget.onPress != null; MouseCursor get mouseCursor { @@ -176,63 +337,100 @@ 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 ownedOldController = _ownsController; + // 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(); + } + + if (!widget.enabled || widget.onPress == null) { + _cancelHeldActivation(); + } } @override void dispose() { - if (widget.controller == null) _controller.dispose(); + _heldActivationKey = null; + _hovered = false; + _focused = false; + _pointerPressed = false; + _syncInteractionStates(); + 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 focusable = Focus( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + 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, + ), + ); + + // 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( - 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, - child: MouseRegion( - cursor: mouseCursor, - child: Actions( - actions: actions, - child: Focus( - focusNode: widget.focusNode, - autofocus: widget.autofocus, - onFocusChange: _onFocusChange, - onKeyEvent: widget.onKeyEvent ?? widget.onKey, - canRequestFocus: widget.canRequestFocus && widget.enabled, - child: MixInteractionDetector( - controller: _controller, - enabled: widget.enabled, - child: widget.child, - ), - ), - ), - ), + excludeFromSemantics: true, + 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( - button: true, - label: widget.semanticButtonLabel, - onTap: widget.onPress, + container: hasEnabledState || widget.semanticsLabel != null, + enabled: hasEnabledState ? widget.enabled : null, + 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 511a812a8..37a12cdbc 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 a0c6ad17e..8046b6d32 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); } @@ -279,6 +284,32 @@ 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) { + // 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; + }); + + @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 36588cdf7..3c437ccb4 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,269 @@ 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); + }); + + 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(); + }); + + 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 new file mode 100644 index 000000000..bd8e5514a --- /dev/null +++ b/packages/mix/test/src/specs/pressable/pressable_keyboard_semantics_test.dart @@ -0,0 +1,784 @@ +import 'package:flutter/gestures.dart'; +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('activates on each supported auxiliary activation key', ( + 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); + var keyEvents = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Pressable( + enabled: false, + onPress: () {}, + onKeyEvent: (_, _) { + keyEvents++; + + return KeyEventResult.handled; + }, + 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); + expect(keyEvents, 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); + }); + + 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', () { + 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('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 { + 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 d4cc50b73..02b00c257 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(); @@ -287,7 +285,7 @@ void main() { MaterialApp( home: Pressable( onPress: () {}, - semanticButtonLabel: 'Test Button', + semanticsLabel: 'Test Button', child: const SizedBox(width: 100, height: 100), ), ), @@ -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, - semanticButtonLabel: 'Test Button', - child: const SizedBox(width: 100, height: 100), + semanticsLabel: 'Test Button', + 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', ( @@ -385,6 +423,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 +438,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 +457,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 +478,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 ec6a0a4e2..f6af1b6fc 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/focus_visible_variant_test.dart b/packages/mix/test/src/variants/focus_visible_variant_test.dart new file mode 100644 index 000000000..f76c6987f --- /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/packages/mix/test/src/variants/variant_mixin_test.dart b/packages/mix/test/src/variants/variant_mixin_test.dart index f36861793..4e0f4e3ee 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 fa6af73e3..6c4d1fc51 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 = [ diff --git a/skills/mix/references/fluent-api.md b/skills/mix/references/fluent-api.md index 15d116c62..95fb0753c 100644 --- a/skills/mix/references/fluent-api.md +++ b/skills/mix/references/fluent-api.md @@ -223,8 +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 | - -`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 while enabled; runs before built-in activation | +| `semanticsLabel` | Accessibility label | +| `semanticsRole` | `PressableSemanticsRole.button` (default), `link`, or `none` | +| `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 @@ -240,7 +251,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 3e47d56b4..6de3d4e11 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 |