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:
@@ -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,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:
|
||||
///
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user