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,
);
}