Files
paseo/packages/app/src/components/ui/menu/menu-overlay.tsx
T
Mohamed Boudra 8b9c42c266 Keep every sidebar footer action reachable in a narrow sidebar (#8885)
* fix(app): move sidebar footer actions that don't fit behind a caret

Touch sizing makes each footer icon a 44pt target, so five of them plus
the row's padding need 236pt. A docked sidebar can be 200pt and a fold
column about as narrow, so Settings was cut off at the sidebar's edge.

The row now measures itself and keeps as many full-size buttons as fit;
the rest move behind a caret that opens a menu-engine popover. Help and
Usage leave first, Settings and Add project last, and Hosts and Help
open as pages of the overflow menu rather than second popovers.

* fix(app): keep the sidebar footer empty until it has measured

Laying the footer out before its first onLayout put all five buttons in
the row, so a narrow sidebar painted Settings clipped for a few frames
before the caret replaced it. The unmeasured row now places nothing and
holds its height, so the first painted row is already the measured one.

* fix(app): keep the footer overflow menu on a narrow, short screen

The overflow menu asked for 240pt, and edge clamping can move a surface
but not shrink it, so on a 220pt screen its right edge and submenu
arrows hung off the display. Anchored surfaces now cap their width to
the display inside the same edge padding that clamping uses.

The Hosts page lists every host, so the overflow menu is scrollable:
on a short screen the last hosts and Add host were below the fold with
no way to reach them.
2026-11-06 10:28:38 +07:00

485 lines
14 KiB
TypeScript

import { navigateMenuKeyboard } from "./keyboard-navigation";
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactElement,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Platform,
Pressable,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import type { KeyboardFocusScope } from "@/keyboard/actions";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
measureElement,
type Alignment,
type Placement,
type Rect,
type Size,
} from "../anchor";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
const CONTENT_ENTERING_DURATION_MS = 150;
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS);
/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */
const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = {
opacity: [0, 1],
transform: ["scale(0.97)", "scale(1)"],
};
/** Web plays the same keyframes itself; see `useWebContentEntering`. */
const nativeContentEntering = isWeb ? undefined : contentEntering;
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
/**
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
* up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
* moved or resized in between — content that grew once its data arrived — snapped back to the old
* rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
*/
function useWebContentEntering({
placed,
surfaceNativeID,
}: {
placed: boolean;
surfaceNativeID: string;
}): void {
useLayoutEffect(() => {
if (!isWeb || !placed || typeof document === "undefined") return undefined;
const animation = document
.getElementById(surfaceNativeID)
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
return () => animation?.cancel();
}, [placed, surfaceNativeID]);
}
/**
* Resolves where an anchored surface sits: measures the anchor, measures the content, and runs
* the two through `computePosition`. Returns nulls until both measurements have landed, which is
* why the surface renders off-screen at -9999 rather than at 0,0 on its first frame.
*/
export function useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
}: {
open: boolean;
anchorRect: Rect | null;
anchorRef: React.RefObject<View | null>;
side: Placement;
align: Alignment;
offset: number;
scrollable: boolean;
maxHeight?: number;
}) {
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 [actualPlacement, setActualPlacement] = useState<Placement>(side);
const visibleContentSize = useMemo(() => {
if (!contentSize) return null;
if (!scrollable) return contentSize;
const { height: screenHeight } = Dimensions.get("window");
const viewportMaxHeight = Math.max(screenHeight - 16, 0);
const resolvedMaxHeight =
typeof maxHeight === "number" ? Math.min(maxHeight, viewportMaxHeight) : viewportMaxHeight;
return {
width: contentSize.width,
height: Math.min(contentSize.height, resolvedMaxHeight),
};
}, [contentSize, scrollable, maxHeight]);
useEffect(() => {
if (!open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return undefined;
}
if (anchorRect) {
setTriggerRect(anchorRect);
return undefined;
}
if (!anchorRef.current) {
setTriggerRect(null);
return undefined;
}
let cancelled = false;
// The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one
// coordinate space that starts at the screen top; no status bar offset applies.
void measureElement(anchorRef.current).then((rect) => {
if (cancelled) return undefined;
setTriggerRect(rect);
return undefined;
});
return () => {
cancelled = true;
};
}, [anchorRect, anchorRef, open]);
useEffect(() => {
if (!triggerRect || !visibleContentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// measureInWindow and Modal both use full-screen coordinates, so the display area starts at 0.
const result = computePosition({
triggerRect,
contentSize: visibleContentSize,
displayArea: { x: 0, y: 0, width: screenWidth, height: screenHeight },
placement: side,
alignment: align,
offset,
});
setPosition({ x: result.x, y: result.y });
setActualPlacement(result.actualPlacement);
}, [triggerRect, visibleContentSize, side, align, offset]);
const onContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize((current) => {
if (current && current.width === width && current.height === height) return current;
return { width, height };
});
},
[],
);
return { position, actualPlacement, contentSize, visibleContentSize, onContentLayout };
}
export interface AnchoredSurfaceProps {
open: boolean;
onClose: () => void;
onExited?: () => void;
anchorRect: Rect | null;
anchorRef: React.RefObject<View | null>;
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
maxHeight?: number;
fullWidth?: boolean;
horizontalPadding?: number;
scrollable?: boolean;
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
backdrop?: boolean;
/**
* Hover tracking for the surface box, per docs/hover.md: plain View, pointer events, never
* a Pressable. Web-only by nature, which is all a flyout needs.
*/
onPointerEnter?: () => void;
onPointerLeave?: () => void;
testID?: string;
keyboardFocusScope?: KeyboardFocusScope;
children: ReactElement;
}
/**
* One floating menu surface, positioned against an anchor. The root menu renders one of these
* against its trigger; every open submenu renders another against the row that opened it.
*/
export function AnchoredSurface({
open,
onClose,
onExited,
anchorRect,
anchorRef,
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
maxHeight,
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
backdrop = true,
onPointerEnter,
onPointerLeave,
testID,
keyboardFocusScope,
children,
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
const surfaceNativeID = useId();
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
// The surface is placed once both measurements land. Until then it sits off-screen so it can
// be measured, and it must not animate: an entering animation that ran there would be over
// before anyone saw the menu. Remounting on placement starts the native animation from the
// final position; web starts its own animation on placement.
const placed = position !== null;
useWebContentEntering({ placed, surfaceNativeID });
useEffect(() => {
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
const frame = requestAnimationFrame(() => {
document
.getElementById(surfaceNativeID)
?.querySelector<HTMLElement>('[data-menu-item="true"]:not([data-menu-disabled="true"])')
?.focus();
});
return () => cancelAnimationFrame(frame);
}, [open, placed, surfaceNativeID]);
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
return [
resolvedWidthStyle,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: fullWidth ? horizontalPadding : (position?.x ?? -9999),
transformOrigin: getTransformOrigin(actualPlacement, align),
},
];
}, [
fullWidth,
horizontalPadding,
width,
minWidth,
maxWidth,
position?.x,
position?.y,
actualPlacement,
align,
]);
const scrollViewportStyle = useMemo(
() => [visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize],
);
const surfaceDataSet = useMemo(
() => ({
menuSurface: "true",
...(keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : null),
}),
[keyboardFocusScope],
);
if (!open) return null;
const measured = (
<View
collapsable={false}
onLayout={onContentLayout}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
{children}
</View>
);
return (
<>
{backdrop ? (
<Pressable
{...{ onContextMenu: onClose }}
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={onClose}
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>
</>
);
}
/**
* The full-screen layer every floating menu surface lives in: a web portal into the overlay
* root, or a transparent Modal on native. Submenus render inside their parent's layer rather
* than opening another one, so there is exactly one Modal per menu no matter how deep it goes.
*/
export function MenuOverlay({
visible,
onClose,
restoreFocusRef,
children,
}: {
visible: boolean;
onClose: () => void;
restoreFocusRef?: RefObject<View | null>;
children: ReactElement | null;
}): ReactElement | null {
const floatingLayer = useOverlayLayer("floating");
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
return true;
}
return navigateMenuKeyboard(event);
},
[onClose],
);
const setOverlayScope = useOverlayRegistration({
keys: ["Escape"],
active: visible,
layer: floatingLayer,
onKeyDown: handleOverlayKeyDown,
restoreFocusRef,
});
if (!visible) return null;
const overlay = (
<OverlayLayerProvider layer={floatingLayer}>
<View
{...{
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
}}
ref={setOverlayScope}
collapsable={false}
style={[
styles.overlay,
isWeb ? styles.overlayWeb : null,
isWeb ? { zIndex: floatingLayer } : null,
]}
>
{children}
</View>
</OverlayLayerProvider>
);
if (isWeb && typeof document !== "undefined") {
return createPortal(overlay, getOverlayRoot());
}
return (
<Modal
visible={visible}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={onClose}
>
{overlay}
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
overlayWeb: {
...StyleSheet.absoluteFill,
pointerEvents: "auto" as const,
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
content: {
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
...theme.shadow.md,
},
}));