Keep popovers above their trigger off it when their content grows (#9247)

* fix(app): keep popovers above their trigger off it when content grows

Tooltips and menu popovers placed above their trigger were positioned by
their top edge. When their content grew after they painted, the new height
painted first at the old top, extending down over the trigger, and the
correction waited for onLayout and a second placement commit. Native
delivers those after the frame, so the grown card covered the trigger for
about 150 ms on Android; web commits both in one task and hid it.

A surface above its trigger is now held by its bottom edge: it sits on the
bottom of a holder that ends at that edge, so layout keeps the edge in
place however the content changes.

* fix(app): let presses outside a popover reach its backdrop on web

The holder that keeps a popover's edge in place set pointerEvents
"box-none" as an inline style. react-native-web only polyfills box-none
for compiled styles, so the browser received an invalid value, fell back
to auto, and the full-width holder swallowed clicks meant for the menu
backdrop. Clicking outside an open menu no longer closed it.

Set box-none through the pointerEvents prop, as the rest of the app does.
This commit is contained in:
Mohamed Boudra
2026-11-10 11:05:35 +07:00
committed by GitHub
parent abcccce846
commit d46ee6558c
5 changed files with 192 additions and 84 deletions
+7
View File
@@ -446,6 +446,13 @@ Before you write a new one, ask:
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded**. Verify before you commit.
6. **Can the content change size after the panel paints, and does the panel
sit above its anchor?** Hold it by its bottom edge with `holdSurfaceEdge`
and `surfaceFrameStyles` from `components/ui/anchor.ts`. Native delivers
`onLayout` after the frame that shows the new size, so a panel placed by
`top` paints over its anchor until the next placement lands, about 150 ms
on Android. Web hides this because React commits the resize and the
re-placement in one task.
Then copy the closest canonical file and trim.
+48 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
holdSurfaceEdge,
surfaceFrameStyles,
type Rect,
} from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -78,6 +85,46 @@ describe("computePosition", () => {
});
});
/**
* Where a surface's edges land when its content has resized but it has not been placed again,
* the frame native paints before the next placement arrives. Resolves the frame styles the way
* layout does: a top-held surface grows down from its top, a bottom-held one up from the
* holder's bottom.
*/
function edgesBeforeReplacement(placedHeight: number, paintedHeight: number) {
const placed = position({ placement: "top", triggerRect: { ...TRIGGER, y: 400 } });
const { holder, surface } = surfaceFrameStyles(holdSurfaceEdge(placed, placedHeight));
const bottom =
typeof surface.bottom === "number"
? Number(holder.height) - surface.bottom
: Number(surface.top) + paintedHeight;
return { top: bottom - paintedHeight, bottom };
}
describe("surfaceFrameStyles", () => {
it("keeps a surface above its trigger off the trigger when its content grows", () => {
// Trigger top 400, offset 4: the surface ends at 396 however tall it becomes.
expect(edgesBeforeReplacement(CONTENT.height, CONTENT.height)).toEqual({
top: 296,
bottom: 396,
});
expect(edgesBeforeReplacement(CONTENT.height, 260)).toEqual({ top: 136, bottom: 396 });
});
it("keeps a surface above its trigger against the trigger when its content shrinks", () => {
expect(edgesBeforeReplacement(CONTENT.height, 40)).toEqual({ top: 356, bottom: 396 });
});
it("holds a surface below its trigger by its top edge", () => {
const { surface } = surfaceFrameStyles(holdSurfaceEdge(position(), CONTENT.height));
expect(surface).toMatchObject({ position: "absolute", top: 124, left: 100 });
});
it("waits off-screen until it is placed", () => {
expect(surfaceFrameStyles(null).surface).toMatchObject({ top: -9999, left: -9999 });
});
});
describe("getTransformOrigin", () => {
it.each([
["bottom", "start", [0, 0, 0]],
+52 -3
View File
@@ -1,8 +1,8 @@
import type { View } from "react-native";
import type { View, ViewStyle } from "react-native";
/**
* Where a floating surface goes relative to the thing that opened it. Menus and hover cards
* place themselves with it.
* Where a floating surface goes relative to the thing that opened it. Menus, hover cards, and
* tooltips place themselves with it.
*/
export type Placement = "top" | "bottom" | "left" | "right";
@@ -177,6 +177,55 @@ export function computePosition({
return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement };
}
/** Where a placed surface sits: its left edge, and the edge that holds it vertically. */
export type SurfacePosition = { left: number; top: number } | { left: number; bottomEdge: number };
/**
* A surface above its trigger is held by its bottom edge, the edge that faces the trigger. When
* its content grows after it paints, it extends upward, away from the trigger. Held by its top,
* it would paint over the trigger until the next placement lands, a frame later on native.
*/
export function holdSurfaceEdge(
placed: { x: number; y: number; actualPlacement: Placement },
contentHeight: number,
): SurfacePosition {
if (placed.actualPlacement === "top") {
return { left: placed.x, bottomEdge: placed.y + contentHeight };
}
return { left: placed.x, top: placed.y };
}
/**
* Styles for a surface and the holder it sits in. Both fill the display area's width, so the
* surface lays out exactly as it would without the holder. Render the holder with the
* `pointerEvents="box-none"` prop so presses outside the surface reach what is behind it;
* react-native-web drops `box-none` given as an inline style. A bottom-held surface sits on the
* bottom of a holder that ends at its bottom edge, so layout keeps that edge in place however
* the content changes. Until it is placed, the surface waits off-screen to be measured.
*/
export function surfaceFrameStyles(position: SurfacePosition | null): {
holder: ViewStyle;
surface: ViewStyle;
} {
const fill: ViewStyle = { position: "absolute", top: 0, left: 0, right: 0 };
if (!position) {
return {
holder: { ...fill, bottom: 0 },
surface: { position: "absolute", top: -9999, left: -9999 },
};
}
if ("top" in position) {
return {
holder: { ...fill, bottom: 0 },
surface: { position: "absolute", top: position.top, left: position.left },
};
}
return {
holder: { ...fill, height: position.bottomEdge },
surface: { position: "absolute", bottom: 0, left: position.left },
};
}
export function getTransformOrigin(
placement: Placement,
alignment: Alignment,
@@ -31,11 +31,14 @@ import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
holdSurfaceEdge,
measureElement,
surfaceFrameStyles,
type Alignment,
type Placement,
type Rect,
type Size,
type SurfacePosition,
} from "../anchor";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
@@ -109,7 +112,7 @@ export function useAnchoredPosition({
}) {
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [position, setPosition] = useState<SurfacePosition | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
const visibleContentSize = useMemo(() => {
@@ -174,7 +177,7 @@ export function useAnchoredPosition({
offset,
});
setPosition({ x: result.x, y: result.y });
setPosition(holdSurfaceEdge(result, visibleContentSize.height));
setActualPlacement(result.actualPlacement);
}, [triggerRect, visibleContentSize, side, align, offset]);
@@ -280,6 +283,7 @@ export function AnchoredSurface({
return () => cancelAnimationFrame(frame);
}, [open, placed, surfaceNativeID]);
const frame = useMemo(() => surfaceFrameStyles(position), [position]);
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
@@ -287,10 +291,9 @@ export function AnchoredSurface({
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
return [
resolvedWidthStyle,
frame.surface,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: fullWidth ? horizontalPadding : (position?.x ?? -9999),
...(fullWidth ? { left: horizontalPadding } : null),
transformOrigin: getTransformOrigin(actualPlacement, align),
},
];
@@ -300,8 +303,7 @@ export function AnchoredSurface({
width,
minWidth,
maxWidth,
position?.x,
position?.y,
frame.surface,
actualPlacement,
align,
]);
@@ -343,40 +345,42 @@ export function AnchoredSurface({
testID={testID ? `${testID}-backdrop` : undefined}
/>
) : null}
<FloatingSurface
key={placed ? "placed" : "measuring"}
collapsable={false}
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? nativeContentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
>
{scrollable ? (
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={SCROLL_CONTENT_STYLE}
>
{measured}
</FloatingScrollView>
) : (
measured
)}
</FloatingSurface>
<View pointerEvents="box-none" style={frame.holder}>
<FloatingSurface
key={placed ? "placed" : "measuring"}
collapsable={false}
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? nativeContentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
>
{scrollable ? (
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={SCROLL_CONTENT_STYLE}
>
{measured}
</FloatingScrollView>
) : (
measured
)}
</FloatingSurface>
</View>
</>
);
}
+40 -39
View File
@@ -30,6 +30,7 @@ import { FloatingSurface } from "@/components/ui/floating";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isNative, isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import { holdSurfaceEdge, surfaceFrameStyles, type SurfacePosition } from "./anchor";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -475,7 +476,7 @@ export function TooltipContent({
const ctx = useTooltipContext("TooltipContent");
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [position, setPosition] = useState<SurfacePosition | null>(null);
useEffect(() => {
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
@@ -509,7 +510,12 @@ export function TooltipContent({
align,
offset,
});
setPosition({ x: result.x, y: result.y });
setPosition(
holdSurfaceEdge(
{ x: result.x, y: result.y, actualPlacement: result.actualSide },
contentSize.height,
),
);
}, [triggerRect, contentSize, side, align, offset]);
const handleLayout = useCallback(
@@ -520,17 +526,8 @@ export function TooltipContent({
[],
);
const frameStyle = useMemo(
() => [
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
maxWidth,
},
],
[maxWidth, position?.x, position?.y],
);
const frame = useMemo(() => surfaceFrameStyles(position), [position]);
const frameStyle = useMemo(() => [frame.surface, { maxWidth }], [frame.surface, maxWidth]);
const contentStyle = useMemo(() => [styles.content, style], [style]);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
@@ -543,19 +540,21 @@ export function TooltipContent({
if (isWeb) {
return createPortal(
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
<View pointerEvents="box-none" style={frame.holder}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</View>,
getOverlayRoot(),
);
@@ -570,19 +569,21 @@ export function TooltipContent({
onRequestClose={handleDismiss}
>
<Pressable testID="tooltip-dismiss" style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
<View pointerEvents="box-none" style={frame.holder}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Pressable>
</Modal>
);