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
+16 -8
View File
@@ -161,14 +161,18 @@ class _ClideAnchoredOverlayState extends State<ClideAnchoredOverlay> {
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<ClideAnchoredOverlay> {
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,
);
}
+35
View File
@@ -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:
///
+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));
});
});
}