Files
paseo/packages/app/src/components/ui/context-menu.tsx
T
Mohamed Boudra e522e6ea8e fix(app): make the compact Explorer tab rail usable on phones (#7589)
Popovers on Android landed one status bar below their trigger. React Native
0.86 stopped subtracting the status bar from window coordinates under
edge-to-edge, so measureInWindow, touch pageY and a full-screen Modal already
share screen-top coordinates; the old StatusBar.currentHeight compensation in
the menu engine, tooltip, context menu and sidebar long-press now
double-shifted every anchor.

Compact tabs keep their natural width and the rail scrolls as soon as they
overflow, instead of squeezing labels first and then scrolling anyway. They
drop the inline close because the long-press sheet already closes a tab and a
narrow X beside the label is easy to hit. Any scrolling rail keeps natural
widths rather than collapsing every chip to the minimum, and the rail resets
its offset when scrolling ends so tabs scrolled out of view come back.

The Explorer rail paints the sidebar surface, so its chips climb the sidebar
hover and selected tokens. Light themes paint the sidebar with surface2, which
made the surface2 active chip invisible there.

The compact Explorer closes with the same X as the compact agent list, and the
compact + menu omits Diff because compact Changes already shows diffs inline.
2026-10-21 21:44:53 +07:00

227 lines
6.2 KiB
TypeScript

import {
useCallback,
type ComponentProps,
type PropsWithChildren,
type ReactElement,
type Ref,
} from "react";
import {
View,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { isNative, isWeb } from "@/constants/platform";
import {
MenuHint,
MenuItem,
MenuLabel,
MenuRoot,
MenuSeparator,
MenuSurface,
useMenuContext,
type MenuSurfaceProps,
type MenuTriggerState,
} from "@/components/ui/menu";
import { PressHighlight } from "@/components/ui/press-highlight";
/**
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
* than to a trigger box.
*
* Everything below the trigger is the shared menu engine — see `@/components/ui/menu` and
* docs/menus.md. Only the way it opens is different from `dropdown-menu.tsx`.
*/
export { MenuItem as ContextMenuItem };
export { MenuLabel as ContextMenuLabel };
export { MenuSeparator as ContextMenuSeparator };
export { MenuHint as ContextMenuHint };
export type { ActionStatus } from "@/components/ui/menu";
/**
* Context menus use the mobile menu convention by default: long press opens a bottom sheet on a
* compact layout, while right click opens an anchored popover on a wide layout.
*/
export function ContextMenu({
compactMode = "sheet",
...props
}: ComponentProps<typeof MenuRoot>): ReactElement {
return <MenuRoot {...props} compactMode={compactMode} />;
}
export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null {
return <MenuSurface {...props} />;
}
export function useContextMenu() {
return useMenuContext("useContextMenu");
}
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
return typeof fn === "function";
}
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
if (typeof event !== "object" || event === null) return null;
const nativeEvent = Reflect.get(event, "nativeEvent");
const source = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
const pageX = Reflect.get(source, "pageX");
const pageY = Reflect.get(source, "pageY");
if (typeof pageX === "number" && typeof pageY === "number") {
return { pageX, pageY };
}
const clientX = Reflect.get(source, "clientX");
const clientY = Reflect.get(source, "clientY");
if (typeof clientX === "number" && typeof clientY === "number") {
return { pageX: clientX, pageY: clientY };
}
return null;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
(ref as { current: T }).current = value;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
export function ContextMenuTrigger({
children,
contextOnly = false,
disabled,
highlightStyle,
style,
enabled = true,
enabledOnMobile = true,
enabledOnWeb = true,
longPressDelayMs,
onContextMenu,
triggerRef,
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
highlightStyle?: StyleProp<ViewStyle>;
style?: TriggerStyleProp;
enabled?: boolean;
enabledOnMobile?: boolean;
enabledOnWeb?: boolean;
longPressDelayMs?: number;
onContextMenu?: (event: unknown) => void;
triggerRef?: Ref<View | null>;
contextOnly?: boolean;
}
>): ReactElement {
const ctx = useMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
if (!shouldEnableOnThisPlatform || disabled) return;
const point = coerceEventPoint(event);
if (!point) return;
ctx.setAnchorRect({
x: point.pageX,
y: point.pageY,
width: 0,
height: 0,
});
ctx.setOpen(true);
},
[ctx, disabled, shouldEnableOnThisPlatform],
);
const handleRef = useCallback(
(node: View | null) => {
assignRef(ctx.triggerRef, node);
assignRef(triggerRef, node);
},
[ctx.triggerRef, triggerRef],
);
const propsOnLongPress = props.onLongPress;
const handleLongPress = useCallback(
(event: GestureResponderEvent) => {
if (isWeb) {
propsOnLongPress?.(event);
return;
}
openAtEvent(event);
propsOnLongPress?.(event);
},
[propsOnLongPress, openAtEvent],
);
const handleContextMenu = useCallback(
(event: unknown) => {
if (isNative) return;
if (typeof event === "object" && event !== null) {
const preventDefault = Reflect.get(event, "preventDefault");
const stopPropagation = Reflect.get(event, "stopPropagation");
if (isCallable(preventDefault)) preventDefault.call(event);
if (isCallable(stopPropagation)) stopPropagation.call(event);
}
onContextMenu?.(event);
openAtEvent(event);
},
[onContextMenu, openAtEvent],
);
const resolveDynamicStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered, open: ctx.open });
}
return style;
},
[style, ctx.open],
);
if (contextOnly) {
const contextOnlyStyle =
typeof style === "function"
? style({ pressed: false, hovered: false, open: ctx.open })
: style;
return (
<View
{...(props as ViewProps)}
ref={handleRef}
collapsable={false}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={handleContextMenu}
style={contextOnlyStyle}
>
{children}
</View>
);
}
return (
<PressHighlight
{...props}
ref={handleRef}
collapsable={false}
disabled={disabled}
delayLongPress={longPressDelayMs}
onLongPress={handleLongPress}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={handleContextMenu}
style={typeof style === "function" ? resolveDynamicStyle : style}
highlightStyle={highlightStyle}
>
{children}
</PressHighlight>
);
}