diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 4a770f897b..ed49f3106d 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -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. diff --git a/packages/app/src/components/ui/anchor.test.ts b/packages/app/src/components/ui/anchor.test.ts index 2e1cd6f68f..47ccda888c 100644 --- a/packages/app/src/components/ui/anchor.test.ts +++ b/packages/app/src/components/ui/anchor.test.ts @@ -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]], diff --git a/packages/app/src/components/ui/anchor.ts b/packages/app/src/components/ui/anchor.ts index 96a7518cba..f263df8ab7 100644 --- a/packages/app/src/components/ui/anchor.ts +++ b/packages/app/src/components/ui/anchor.ts @@ -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, diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index c48fda7156..0058cacfe5 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -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(null); const [contentSize, setContentSize] = useState(null); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const [position, setPosition] = useState(null); const [actualPlacement, setActualPlacement] = useState(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>(() => { 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} - { - "worklet"; - if (finished) { - runOnJS(onExited)(); - } - }) - } - > - {scrollable ? ( - - {measured} - - ) : ( - measured - )} - + + { + "worklet"; + if (finished) { + runOnJS(onExited)(); + } + }) + } + > + {scrollable ? ( + + {measured} + + ) : ( + measured + )} + + ); } diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 99f446001f..71ac53d8ac 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -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(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(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( - - {children} - + + + {children} + + , getOverlayRoot(), ); @@ -570,19 +569,21 @@ export function TooltipContent({ onRequestClose={handleDismiss} > - - {children} - + + + {children} + + );