From a8d2d265ecae1d9c632ad12eb5cba04ac9016d14 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 4 Aug 2026 18:40:30 -0400 Subject: [PATCH] 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) - ContextVariant.widgetStateDependencies so any context variant can declare the widget states it needs tracked - PressableSemanticsRole (button/link/none) and semanticsLabel Fixes: - Style.widgetStates now discovers state requirements recursively through nested and negated variants (previously a style whose only widget-state variants were nested under e.g. onDark/onBreakpoint, or wrapped in NotVariant, never activated because StyleBuilder attached no MixInteractionDetector) - 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 - 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 | 30 ++ .../focus_highlight_mode_provider.dart | 61 +++ packages/mix/lib/src/core/style.dart | 26 +- .../src/specs/pressable/pressable_widget.dart | 177 +++++-- .../mixins/widget_state_variant_mixin.dart | 5 + packages/mix/lib/src/variants/variant.dart | 32 ++ .../src/core/style_builder_hover_test.dart | 138 ++++++ .../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 + .../variants/widget_state_variant_test.dart | 20 + .../inventory/schema_inventory_manifest.dart | 12 + 14 files changed, 1000 insertions(+), 61 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 82b5ea4bd3..d0b52c7d77 100644 --- a/packages/mix/CHANGELOG.md +++ b/packages/mix/CHANGELOG.md @@ -1,3 +1,33 @@ +## 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 ### New features 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/core/style.dart b/packages/mix/lib/src/core/style.dart index fd619c1a67..8e0e64251a 100644 --- a/packages/mix/lib/src/core/style.dart +++ b/packages/mix/lib/src/core/style.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -66,10 +68,26 @@ abstract class Style> extends Mix> @internal Set get widgetStates { - return ($variants ?? []) - .where((v) => v.variant is WidgetStateVariant) - .map((v) => (v.variant as WidgetStateVariant).state) - .toSet(); + final states = {}; + final visited = HashSet>.identity(); + + void collectDependencies(Style style) { + if (!visited.add(style)) return; + + final variants = style.$variants; + if (variants == null) return; + + for (final variantStyle in variants) { + if (variantStyle.variant case final ContextVariant variant) { + states.addAll(variant.widgetStateDependencies); + } + collectDependencies(variantStyle.value); + } + } + + collectDependencies(this); + + return states; } /// Merges all active variants with their nested variants recursively. 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 67150f42b9..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); } @@ -108,6 +113,9 @@ class ContextVariant extends Variant { return ContextVariant.breakpoint(BreakpointToken.desktop()); } + /// Widget states that must be tracked for this variant to be evaluated. + Set get widgetStateDependencies => const {}; + /// Check if this variant should be active for the given context bool when(BuildContext context) { return shouldApply(context); @@ -185,6 +193,9 @@ final class NotVariant extends ContextVariant { bool operator ==(Object other) => identical(this, other) || other is NotVariant && other.inner == inner; + @override + Set get widgetStateDependencies => inner.widgetStateDependencies; + @override int get hashCode => inner.hashCode; } @@ -254,10 +265,31 @@ final class WidgetStateVariant extends ContextVariant { identical(this, other) || other is WidgetStateVariant && other.state == state; + @override + Set get widgetStateDependencies => {state}; + @override 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/core/style_builder_hover_test.dart b/packages/mix/test/src/core/style_builder_hover_test.dart index 983c9cd11f..09addfca20 100644 --- a/packages/mix/test/src/core/style_builder_hover_test.dart +++ b/packages/mix/test/src/core/style_builder_hover_test.dart @@ -5,6 +5,54 @@ import 'package:mix/mix.dart'; void main() { group('StyleBuilder hover functionality', () { + Future expectHoverColors( + WidgetTester tester, { + required BoxStyler style, + required Color initial, + required Color hovered, + Brightness platformBrightness = Brightness.light, + }) async { + Color? currentColor; + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData( + size: const Size(800, 600), + platformBrightness: platformBrightness, + ), + child: Center( + child: StyleBuilder( + style: style, + builder: (context, spec) { + currentColor = (spec.decoration as BoxDecoration?)?.color; + + return Container( + key: const Key('nested-hover-target'), + constraints: spec.constraints, + decoration: spec.decoration, + ); + }, + ), + ), + ), + ), + ); + + expect(currentColor, initial); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + await gesture.moveTo( + tester.getCenter(find.byKey(const Key('nested-hover-target'))), + ); + await tester.pump(); + + expect(currentColor, hovered); + + await gesture.removePointer(); + } + testWidgets('hover variant changes style when mouse enters and exits', ( tester, ) async { @@ -178,5 +226,95 @@ void main() { await gesture.removePointer(); }, ); + + group('nested widget-state dependency discovery', () { + testWidgets('tracks a negated hover variant', (tester) async { + final style = BoxStyler() + .size(100, 100) + .color(Colors.blue) + .onNot( + ContextVariant.widgetState(WidgetState.hovered), + BoxStyler().color(Colors.red), + ); + + await expectHoverColors( + tester, + style: style, + initial: Colors.red, + hovered: Colors.blue, + ); + }); + + testWidgets('tracks hover nested under a breakpoint', (tester) async { + final style = BoxStyler() + .size(100, 100) + .color(Colors.blue) + .onBreakpoint( + const Breakpoint.minWidth(0), + BoxStyler().onHovered(BoxStyler().color(Colors.red)), + ); + + await expectHoverColors( + tester, + style: style, + initial: Colors.blue, + hovered: Colors.red, + ); + }); + + testWidgets('tracks hover nested under dark mode', (tester) async { + final style = BoxStyler() + .size(100, 100) + .color(Colors.blue) + .onDark(BoxStyler().onHovered(BoxStyler().color(Colors.red))); + + await expectHoverColors( + tester, + style: style, + initial: Colors.blue, + hovered: Colors.red, + platformBrightness: Brightness.dark, + ); + }); + + testWidgets('tracks hover when breakpoint nesting is reversed', ( + tester, + ) async { + final style = BoxStyler() + .size(100, 100) + .color(Colors.blue) + .onHovered( + BoxStyler().onBreakpoint( + const Breakpoint.minWidth(0), + BoxStyler().color(Colors.red), + ), + ); + + await expectHoverColors( + tester, + style: style, + initial: Colors.blue, + hovered: Colors.red, + ); + }); + + test('handles cyclic nested variant styles by identity', () { + final variants = >[]; + final cyclicStyle = BoxStyler(variants: variants); + variants.add(VariantStyle(const NamedVariant('cycle'), cyclicStyle)); + variants.add( + VariantStyle( + ContextVariant.widgetState(WidgetState.hovered), + BoxStyler(), + ), + ); + final rootStyle = BoxStyler().variant( + const NamedVariant('root'), + cyclicStyle, + ); + + expect(rootStyle.widgetStates, {WidgetState.hovered}); + }); + }); }); } 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/test/src/variants/widget_state_variant_test.dart b/packages/mix/test/src/variants/widget_state_variant_test.dart index fc87a0c5dd..3cfbef9953 100644 --- a/packages/mix/test/src/variants/widget_state_variant_test.dart +++ b/packages/mix/test/src/variants/widget_state_variant_test.dart @@ -89,6 +89,26 @@ void main() { final keys = variants.map((v) => v.key).toSet(); expect(keys.length, WidgetState.values.length); }); + + test('declares its widget state dependency', () { + final variant = ContextVariant.widgetState(WidgetState.hovered); + + expect(variant.widgetStateDependencies, {WidgetState.hovered}); + }); + + test('negated variants delegate widget state dependencies', () { + final variant = ContextVariant.not( + ContextVariant.widgetState(WidgetState.disabled), + ); + + expect(variant.widgetStateDependencies, {WidgetState.disabled}); + }); + + test('non-widget-state variants have no widget state dependencies', () { + final variant = ContextVariant.brightness(Brightness.dark); + + expect(variant.widgetStateDependencies, isEmpty); + }); }); group('Equality and hashCode', () { 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 = [