harden ClideAnchoredOverlay positioning + add an anchored test harness (T-288)

Root-cause of the theme-picker friction: the primitive's focus model is fine
(keyboard nav reaches a ClideMenu through the overlay), but the shared harness()
uses Overlay(canSizeOverlay) + a zero-size MediaQuery, which mispositions an
anchored follower off-screen and defeats autoFlip.

- autoFlip now reads the real view size (View.physicalSize) instead of
  MediaQuery.size, so it flips correctly even when MediaQuery is overridden.
- Drop the inner Align in the follower — it pegged the panel to a corner of the
  full-screen follower box and broke hit-testing for non-top-left anchors, so
  end-aligned menu items weren't mouse-tappable.
- Add anchoredHarness() — a properly-sized Overlay tree for testing popover
  content (the remaining migrations will use it).

Tests: keyboard nav through the overlay, an end-aligned item is mouse-tappable,
and autoFlip flips below->above near the bottom edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 22:17:58 +02:00
co-authored by Claude Opus 4.8
parent 020b29f9b7
commit 318e09e748
3 changed files with 124 additions and 8 deletions
+73
View File
@@ -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));
});
});
}