diff --git a/lib/widgets/src/clide_anchored.dart b/lib/widgets/src/clide_anchored.dart index a1e39434..baf5865e 100644 --- a/lib/widgets/src/clide_anchored.dart +++ b/lib/widgets/src/clide_anchored.dart @@ -161,14 +161,18 @@ class _ClideAnchoredOverlayState extends State { var side = widget.side; if (!widget.autoFlip || widget.centered) return side; final box = context.findRenderObject(); - final media = MediaQuery.maybeOf(context); - if (box is RenderBox && box.hasSize && media != null) { + // Use the real view size, not MediaQuery — the latter can be overridden to + // zero (e.g. the shared test harness), which would defeat the flip. + final view = View.maybeOf(context); + if (box is RenderBox && box.hasSize && view != null) { final rect = box.localToGlobal(Offset.zero) & box.size; - final h = media.size.height; - if (side == ClideAnchorSide.below && rect.bottom > h * 0.6) { - side = ClideAnchorSide.above; - } else if (side == ClideAnchorSide.above && rect.top < h * 0.4) { - side = ClideAnchorSide.below; + final h = view.physicalSize.height / view.devicePixelRatio; + if (h > 0) { + if (side == ClideAnchorSide.below && rect.bottom > h * 0.6) { + side = ClideAnchorSide.above; + } else if (side == ClideAnchorSide.above && rect.top < h * 0.4) { + side = ClideAnchorSide.below; + } } } return side; @@ -264,13 +268,17 @@ class _ClideAnchoredOverlayState extends State { final flipped = _resolvedSide != widget.side; final off = flipped ? Offset(widget.offset.dx, -widget.offset.dy) : widget.offset; final (target, follower) = _alignments(_resolvedSide); + // The Stack lays the follower out loosely, so the (shrink-wrapping) panel + // sizes to its content; the follower layer's transform alone positions it. + // An Align here would peg the panel to a corner of the full-screen follower + // box and break hit-testing for non-top-left anchors. positioned = CompositedTransformFollower( link: _link, showWhenUnlinked: false, targetAnchor: target, followerAnchor: follower, offset: off, - child: Align(alignment: follower, child: content), + child: content, ); } diff --git a/test/helpers/widget_harness.dart b/test/helpers/widget_harness.dart index bdd09e98..616838b8 100644 --- a/test/helpers/widget_harness.dart +++ b/test/helpers/widget_harness.dart @@ -35,6 +35,41 @@ Widget harness(KernelFixture fixture, Widget child) { ); } +/// Harness for **anchored-overlay content** (ClideAnchoredOverlay popovers). +/// +/// The shared [harness] wraps its child in `Overlay(canSizeOverlay)` + a +/// zero-size `MediaQuery` — fine for plain widgets, but it mispositions an +/// anchored follower off-screen and defeats `autoFlip`, so popover items aren't +/// reliably hit-testable. This builds a properly-sized Overlay tree instead: +/// `Directionality → ClideKernel → ClideTheme → MediaQuery(size) → Overlay`, +/// with [child] (the trigger) placed at [alignment]. Set +/// `tester.view.physicalSize = size` to match (the default 800×600 matches the +/// default test view, so no setup is needed unless you change [size]). +Widget anchoredHarness( + KernelFixture fixture, + Widget child, { + Size size = const Size(800, 600), + Alignment alignment = Alignment.topLeft, +}) { + return Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: fixture.services, + child: ClideTheme( + controller: fixture.services.theme, + child: MediaQuery( + data: MediaQueryData(size: size), + child: Overlay( + initialEntries: [ + OverlayEntry(builder: (_) => Align(alignment: alignment, child: child)), + ], + ), + ), + ), + ), + ); +} + /// Settle async-driven UI in a widget test WITHOUT the two patterns that have /// repeatedly wedged this suite: /// diff --git a/test/widgets/src/clide_anchored_test.dart b/test/widgets/src/clide_anchored_test.dart index 80a604a5..39e29717 100644 --- a/test/widgets/src/clide_anchored_test.dart +++ b/test/widgets/src/clide_anchored_test.dart @@ -97,5 +97,78 @@ void main() { expect(find.text('panel'), findsNothing); expect(tester.takeException(), isNull); }); + + // A ClideAnchoredOverlay hosting a ClideMenu of A/B/C, for the focus + + // positioning tests below. + Widget anchoredMenu( + ClideOverlayController c, + void Function(String) onPick, { + ClideAnchorSide side = ClideAnchorSide.below, + ClideAnchorAlign align = ClideAnchorAlign.start, + bool autoFlip = false, + }) { + return ClideAnchoredOverlay( + controller: c, + side: side, + align: align, + autoFlip: autoFlip, + anchor: const SizedBox(width: 80, height: 24, child: ClideText('trigger')), + overlayBuilder: (ctx, ctrl) => ClideMenu( + onClose: ctrl.close, + entries: [ + ClideMenuItem(label: 'A', onSelect: () => onPick('A')), + ClideMenuItem(label: 'B', onSelect: () => onPick('B')), + ClideMenuItem(label: 'C', onSelect: () => onPick('C')), + ], + ), + ); + } + + testWidgets('keyboard nav reaches a ClideMenu through the overlay', (tester) async { + final c = ClideOverlayController(); + addTearDown(c.dispose); + var picked = ''; + await tester.pumpWidget(anchoredHarness(f, anchoredMenu(c, (v) => picked = v))); + c.open(); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // A + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // B + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(picked, 'B'); + }); + + testWidgets('an end-aligned menu item is mouse-tappable (no Align hit-offset)', (tester) async { + final c = ClideOverlayController(); + addTearDown(c.dispose); + var picked = ''; + // Right-edge trigger + end alignment — the menu extends left, on-screen. + await tester.pumpWidget(anchoredHarness( + f, + anchoredMenu(c, (v) => picked = v, align: ClideAnchorAlign.end), + alignment: Alignment.topRight, + )); + c.open(); + await tester.pumpAndSettle(); + await tester.tap(find.text('C')); + await tester.pumpAndSettle(); + expect(picked, 'C'); + }); + + testWidgets('autoFlip flips below to above when the anchor is near the bottom', (tester) async { + final c = ClideOverlayController(); + addTearDown(c.dispose); + await tester.pumpWidget(anchoredHarness( + f, + anchoredMenu(c, (_) {}, side: ClideAnchorSide.below, autoFlip: true), + alignment: Alignment.bottomLeft, + )); + c.open(); + await tester.pumpAndSettle(); + // The panel (its first item) sits ABOVE the trigger, not below. + final triggerTop = tester.getRect(find.text('trigger')).top; + final panelBottom = tester.getRect(find.text('A')).bottom; + expect(panelBottom, lessThanOrEqualTo(triggerTop)); + }); }); }