feat(app): sidebar display preferences, a shared menu engine, and a colour pass
Sidebar polish round. Menus. `dropdown-menu` and `context-menu` were two copies of one engine that differed only in how the trigger fires — identical from `computePosition` down — and neither could nest. Both are now thin wrappers over `components/ui/menu/`, which owns anchoring, a pure page-stack navigation model, and one presentation fork: flyout on pointer, pushed pages in a sheet on compact. All 31 call sites are unchanged. Items got a rounded inset fill, a visible separator, and `selected` (a check) split from `active` (a fill). Sidebar display preferences. The flat wall of four sections becomes one row per decision with its value, and per-feature toggles over what a workspace row shows: host, pull request, checks, scripts. Each is independent — a ticked toggle that draws nothing is a lie. `display-preferences/` hides that grouping lives in zustand while title source and row items are app settings. Timestamps. "Last activity" now ticks itself. The label rounds to the minute and never shows seconds; past a week it becomes a date. The timer lives in the leaf `<Text>`, at most one per resolution tier shared by every row, and a tick only re-renders when the string actually changed — so a memoized row that legitimately never re-renders still stays current. Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as the muted tier, lightness and hue untouched. File icons are pulled toward neutral in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a tree where the icon is the only colour on the row, and ours is not. New/deleted files use the forge square-plus/square-minus glyphs at muted weight instead of tinted pills. Project icons round a little more; uploaded icons never do. The project status badge is a knockout again: it fills with the colour of the row behind it, so the ring reads as a gap in the icon rather than a white halo. File rows. The always-visible kebab is gone from the file explorer and diff tree in favour of right-click, which also reclaims the reserved action slot.
This commit is contained in:
@@ -1,26 +1,8 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, type PropsWithChildren, type ReactElement, type Ref } from "react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StatusBar,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableProps,
|
||||
@@ -28,150 +10,40 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
MenuHint,
|
||||
MenuItem,
|
||||
MenuLabel,
|
||||
MenuRoot,
|
||||
MenuSeparator,
|
||||
MenuSurface,
|
||||
useMenuContext,
|
||||
type MenuSurfaceProps,
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
type Placement = "top" | "bottom" | "left" | "right";
|
||||
type Alignment = "start" | "center" | "end";
|
||||
type MobileMenuMode = "dropdown" | "sheet";
|
||||
export { MenuRoot as ContextMenu };
|
||||
export { MenuItem as ContextMenuItem };
|
||||
export { MenuLabel as ContextMenuLabel };
|
||||
export { MenuSeparator as ContextMenuSeparator };
|
||||
export { MenuHint as ContextMenuHint };
|
||||
export type { ActionStatus } from "@/components/ui/menu";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null {
|
||||
return <MenuSurface {...props} />;
|
||||
}
|
||||
|
||||
interface ContextMenuContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
anchorRect: Rect | null;
|
||||
setAnchorRect: (rect: Rect | null) => void;
|
||||
}
|
||||
|
||||
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
|
||||
|
||||
function useContextMenuContext(componentName: string): ContextMenuContextValue {
|
||||
const ctx = useContext(ContextMenuContext);
|
||||
if (!ctx) {
|
||||
throw new Error(`${componentName} must be used within <ContextMenu />`);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useContextMenu(): ContextMenuContextValue {
|
||||
return useContextMenuContext("useContextMenu");
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
placement,
|
||||
alignment,
|
||||
offset,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: { width: number; height: number };
|
||||
displayArea: Rect;
|
||||
placement: Placement;
|
||||
alignment: Alignment;
|
||||
offset: number;
|
||||
}): { x: number; y: number; actualPlacement: Placement } {
|
||||
const { width: contentWidth, height: contentHeight } = contentSize;
|
||||
|
||||
// Calculate available space
|
||||
const spaceTop = triggerRect.y - displayArea.y;
|
||||
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
|
||||
|
||||
// Flip if needed
|
||||
let actualPlacement = placement;
|
||||
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
|
||||
actualPlacement = "top";
|
||||
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
||||
actualPlacement = "bottom";
|
||||
}
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
||||
// Position based on placement
|
||||
if (actualPlacement === "bottom") {
|
||||
y = triggerRect.y + triggerRect.height + offset;
|
||||
} else if (actualPlacement === "top") {
|
||||
y = triggerRect.y - contentHeight - offset;
|
||||
} else if (actualPlacement === "left") {
|
||||
x = triggerRect.x - contentWidth - offset;
|
||||
y = triggerRect.y;
|
||||
} else {
|
||||
x = triggerRect.x + triggerRect.width + offset;
|
||||
y = triggerRect.y;
|
||||
}
|
||||
|
||||
// Alignment
|
||||
if (actualPlacement === "top" || actualPlacement === "bottom") {
|
||||
if (alignment === "start") {
|
||||
x = triggerRect.x;
|
||||
} else if (alignment === "end") {
|
||||
x = triggerRect.x + triggerRect.width - contentWidth;
|
||||
} else {
|
||||
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Constrain to screen
|
||||
const padding = 8;
|
||||
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
|
||||
y = Math.max(
|
||||
displayArea.y + padding,
|
||||
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!),
|
||||
);
|
||||
|
||||
return { x, y, actualPlacement };
|
||||
export function useContextMenu() {
|
||||
return useMenuContext("useContextMenu");
|
||||
}
|
||||
|
||||
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
@@ -179,18 +51,18 @@ function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
}
|
||||
|
||||
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
|
||||
if (typeof event !== "object" || event === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof event !== "object" || event === null) return null;
|
||||
|
||||
const nativeEvent = Reflect.get(event, "nativeEvent");
|
||||
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
|
||||
const pageX = Reflect.get(native, "pageX");
|
||||
const pageY = Reflect.get(native, "pageY");
|
||||
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(native, "clientX");
|
||||
const clientY = Reflect.get(native, "clientY");
|
||||
|
||||
const clientX = Reflect.get(source, "clientX");
|
||||
const clientY = Reflect.get(source, "clientY");
|
||||
if (typeof clientX === "number" && typeof clientY === "number") {
|
||||
return { pageX: clientX, pageY: clientY };
|
||||
}
|
||||
@@ -198,59 +70,15 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (!ref) return;
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
Object.assign(ref, { current: value });
|
||||
}
|
||||
(ref as { current: T }).current = value;
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
});
|
||||
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setAnchorRect(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const value = useMemo<ContextMenuContextValue>(
|
||||
() => ({
|
||||
open: isOpen,
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
anchorRect,
|
||||
setAnchorRect,
|
||||
}),
|
||||
[anchorRect, isOpen, setAnchorRect, setIsOpen],
|
||||
);
|
||||
|
||||
return <ContextMenuContext.Provider value={value}>{children}</ContextMenuContext.Provider>;
|
||||
}
|
||||
|
||||
interface TriggerState {
|
||||
pressed: boolean;
|
||||
hovered: boolean;
|
||||
open: boolean;
|
||||
}
|
||||
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
|
||||
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
|
||||
|
||||
export function ContextMenuTrigger({
|
||||
children,
|
||||
@@ -272,19 +100,16 @@ export function ContextMenuTrigger({
|
||||
triggerRef?: Ref<View | null>;
|
||||
}
|
||||
>): ReactElement {
|
||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||
const ctx = useMenuContext("ContextMenuTrigger");
|
||||
|
||||
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
|
||||
|
||||
const openAtEvent = useCallback(
|
||||
(event: unknown) => {
|
||||
if (!shouldEnableOnThisPlatform || disabled) {
|
||||
return;
|
||||
}
|
||||
if (!shouldEnableOnThisPlatform || disabled) return;
|
||||
const point = coerceEventPoint(event);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
if (!point) return;
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
ctx.setAnchorRect({
|
||||
x: point.pageX,
|
||||
@@ -320,9 +145,7 @@ export function ContextMenuTrigger({
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: unknown) => {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
if (isNative) return;
|
||||
if (typeof event === "object" && event !== null) {
|
||||
const preventDefault = Reflect.get(event, "preventDefault");
|
||||
const stopPropagation = Reflect.get(event, "stopPropagation");
|
||||
@@ -360,572 +183,3 @@ export function ContextMenuTrigger({
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuContent({
|
||||
children,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
offset = 4,
|
||||
width,
|
||||
minWidth = 180,
|
||||
maxWidth,
|
||||
fullWidth = false,
|
||||
horizontalPadding = 16,
|
||||
mobileMode = "dropdown",
|
||||
testID,
|
||||
}: PropsWithChildren<{
|
||||
side?: Placement;
|
||||
align?: Alignment;
|
||||
offset?: number;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
fullWidth?: boolean;
|
||||
horizontalPadding?: number;
|
||||
mobileMode?: MobileMenuMode;
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const context = useContextMenuContext("ContextMenuContent");
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
|
||||
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 handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
isEnabled: useMobileSheet,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const renderSheetBackdrop = useCallback(
|
||||
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const sheetBackgroundStyle = useMemo(
|
||||
() => [
|
||||
styles.sheetBackground,
|
||||
{
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
],
|
||||
[theme.colors.surface0, theme.colors.border],
|
||||
);
|
||||
const sheetHandleStyle = useMemo(
|
||||
() => [styles.sheetHandle, { backgroundColor: theme.colors.surface2 }],
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
|
||||
// Measure trigger when opening (fallback) and capture point anchors.
|
||||
useEffect(() => {
|
||||
if (useMobileSheet || !open) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (anchorRect) {
|
||||
setTriggerRect(anchorRect);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!triggerRef.current) {
|
||||
setTriggerRect(null);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [anchorRect, open, triggerRef, useMobileSheet]);
|
||||
|
||||
// Calculate position when we have both measurements
|
||||
useEffect(() => {
|
||||
if (useMobileSheet) return;
|
||||
if (!triggerRect || !contentSize) return;
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
const displayArea = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
};
|
||||
|
||||
const result = computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
placement: side,
|
||||
alignment: align,
|
||||
offset,
|
||||
});
|
||||
|
||||
const x = fullWidth ? horizontalPadding : result.x;
|
||||
setPosition({ x, y: result.y });
|
||||
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding, useMobileSheet]);
|
||||
|
||||
const handleContentLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width: w, height: h } = event.nativeEvent.layout;
|
||||
setContentSize({ width: w, height: h });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const frameStyle = useMemo(() => {
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
? { width: screenWidth - horizontalPadding * 2 }
|
||||
: {
|
||||
...(typeof width === "number" ? { width } : null),
|
||||
...(typeof minWidth === "number" ? { minWidth } : null),
|
||||
...(typeof maxWidth === "number" ? { maxWidth } : null),
|
||||
};
|
||||
return [
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
];
|
||||
}, [fullWidth, horizontalPadding, width, minWidth, maxWidth, position?.y, position?.x]);
|
||||
|
||||
if (useMobileSheet) {
|
||||
return (
|
||||
<ContextMenuContext.Provider value={context}>
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
index={0}
|
||||
snapPoints={sheetSnapPoints}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderSheetBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={sheetBackgroundStyle}
|
||||
handleIndicatorStyle={sheetHandleStyle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</IsolatedBottomSheetModal>
|
||||
</ContextMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
<FloatingSurface
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleContentLayout}
|
||||
style={styles.content}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
<FloatingScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
|
||||
>
|
||||
{children}
|
||||
</FloatingScrollView>
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuLabel({
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
|
||||
const containerStyle = useMemo(() => [styles.labelContainer, style], [style]);
|
||||
return (
|
||||
<View style={containerStyle} testID={testID}>
|
||||
<Text style={styles.labelText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuSeparator({
|
||||
style,
|
||||
testID,
|
||||
}: {
|
||||
style?: ViewStyle;
|
||||
testID?: string;
|
||||
}): ReactElement {
|
||||
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
|
||||
return <View style={separatorStyle} testID={testID} />;
|
||||
}
|
||||
|
||||
export function ContextMenuHint({
|
||||
children,
|
||||
testID,
|
||||
}: PropsWithChildren<{ testID?: string }>): ReactElement {
|
||||
return (
|
||||
<View style={styles.hintContainer} testID={testID}>
|
||||
<Text style={styles.hintText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLeadingContent(input: {
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
leading: ReactElement | null | undefined;
|
||||
pendingColor: string;
|
||||
successColor: string;
|
||||
}): ReactElement | null {
|
||||
if (input.isPending) {
|
||||
return <LoadingSpinner size={16} color={input.pendingColor} />;
|
||||
}
|
||||
if (input.isSuccess) {
|
||||
return <CheckCircle size={16} color={input.successColor} />;
|
||||
}
|
||||
return input.leading ?? null;
|
||||
}
|
||||
|
||||
function resolveItemLabel(input: {
|
||||
children: ReactNode;
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
pendingLabel: string | undefined;
|
||||
successLabel: string | undefined;
|
||||
}): ReactNode {
|
||||
if (input.isPending && input.pendingLabel) return input.pendingLabel;
|
||||
if (input.isSuccess && input.successLabel) return input.successLabel;
|
||||
return input.children;
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
children,
|
||||
description,
|
||||
onSelect,
|
||||
disabled,
|
||||
destructive,
|
||||
selected,
|
||||
showSelectedCheck = false,
|
||||
selectedVariant = "default",
|
||||
leading,
|
||||
trailing,
|
||||
loading,
|
||||
status,
|
||||
pendingLabel,
|
||||
successLabel,
|
||||
closeOnSelect = true,
|
||||
testID,
|
||||
tooltip,
|
||||
}: PropsWithChildren<{
|
||||
description?: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
selected?: boolean;
|
||||
showSelectedCheck?: boolean;
|
||||
selectedVariant?: "default" | "accent";
|
||||
leading?: ReactElement | null;
|
||||
trailing?: ReactElement | null;
|
||||
/** @deprecated Use `status` instead */
|
||||
loading?: boolean;
|
||||
status?: ActionStatus;
|
||||
pendingLabel?: string;
|
||||
successLabel?: string;
|
||||
closeOnSelect?: boolean;
|
||||
testID?: string;
|
||||
tooltip?: string;
|
||||
}>): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { setOpen } = useContextMenuContext("ContextMenuItem");
|
||||
|
||||
const isPending = status === "pending" || Boolean(loading);
|
||||
const isSuccess = status === "success";
|
||||
const isDisabled = Boolean(disabled) || isPending || isSuccess;
|
||||
|
||||
const leadingContent = resolveLeadingContent({
|
||||
isPending,
|
||||
isSuccess,
|
||||
leading,
|
||||
pendingColor: theme.colors.foregroundMuted,
|
||||
successColor: theme.colors.palette.green[500],
|
||||
});
|
||||
|
||||
const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel });
|
||||
|
||||
const trailingContent =
|
||||
trailing ??
|
||||
(!showSelectedCheck && selected ? (
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
) : null);
|
||||
|
||||
const handleItemPress = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
onSelect?.();
|
||||
}, [isDisabled, closeOnSelect, setOpen, onSelect]);
|
||||
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null;
|
||||
if (!selected) selectedStyle = null;
|
||||
else if (selectedVariant === "accent") selectedStyle = styles.itemSelectedAccent;
|
||||
else selectedStyle = styles.itemSelected;
|
||||
return [
|
||||
styles.item,
|
||||
selectedStyle,
|
||||
selected && (hovered || pressed) && selectedVariant !== "accent"
|
||||
? styles.itemSelectedInteractive
|
||||
: null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
];
|
||||
},
|
||||
[selected, selectedVariant, isDisabled],
|
||||
);
|
||||
|
||||
const itemTextStyle = useMemo(
|
||||
() => [
|
||||
styles.itemText,
|
||||
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
||||
isSuccess ? styles.itemTextSuccess : null,
|
||||
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
||||
],
|
||||
[destructive, isSuccess, selected, selectedVariant],
|
||||
);
|
||||
const itemDescriptionStyle = useMemo(
|
||||
() => [
|
||||
styles.itemDescription,
|
||||
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
|
||||
],
|
||||
[selected, selectedVariant],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
disabled={isDisabled}
|
||||
onPress={handleItemPress}
|
||||
style={itemPressableStyle}
|
||||
>
|
||||
{showSelectedCheck ? (
|
||||
<View style={styles.checkSlot}>
|
||||
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
|
||||
<View style={styles.itemContent}>
|
||||
<Text numberOfLines={1} style={itemTextStyle}>
|
||||
{label}
|
||||
</Text>
|
||||
{description && !isPending && !isSuccess ? (
|
||||
<Text numberOfLines={2} style={itemDescriptionStyle}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" offset={10}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
},
|
||||
backdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
...theme.shadow.md,
|
||||
overflow: "hidden",
|
||||
},
|
||||
sheetBackground: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
sheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sheetScrollContent: {
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
labelContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
labelText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
hintContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
hintText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: "transparent",
|
||||
},
|
||||
itemHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
itemSelected: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemSelectedInteractive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemSelectedAccent: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
checkSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
leadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
trailingSlot: {
|
||||
marginLeft: "auto",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
itemContent: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
itemText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
itemTextDestructive: {
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
itemTextSuccess: {
|
||||
color: theme.colors.palette.green[500],
|
||||
},
|
||||
itemTextSelectedAccent: {
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
itemDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
itemDescriptionSelectedAccent: {
|
||||
color: theme.colors.accentForeground,
|
||||
opacity: 0.85,
|
||||
},
|
||||
}));
|
||||
|
||||
const SCROLL_CONTENT_CONTAINER_STYLE = { flexGrow: 1 };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The menu engine.
|
||||
*
|
||||
* One state model and one item vocabulary behind two presentations — an anchored popover on
|
||||
* wide screens, a bottom sheet on compact ones — and two trigger shapes, which live in the
|
||||
* `dropdown-menu` and `context-menu` wrappers.
|
||||
*
|
||||
* See docs/menus.md.
|
||||
*/
|
||||
|
||||
export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } from "./menu-root";
|
||||
export {
|
||||
MenuSurface,
|
||||
useMenuSurface,
|
||||
type MenuPageDefinition,
|
||||
type MenuSurfaceProps,
|
||||
} from "./menu-surface";
|
||||
export { MenuSubTrigger } from "./menu-sub";
|
||||
export {
|
||||
MenuHint,
|
||||
MenuItem,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
type ActionStatus,
|
||||
type MenuItemProps,
|
||||
} from "./menu-item";
|
||||
export {
|
||||
useMenuContext,
|
||||
useMenuDepth,
|
||||
type MenuCompactMode,
|
||||
type MenuContextValue,
|
||||
type MenuPresentation,
|
||||
} from "./menu-context";
|
||||
export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation";
|
||||
export type { Alignment, Placement, Rect, Size } from "./menu-anchor";
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor";
|
||||
|
||||
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
|
||||
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
|
||||
const CONTENT = { width: 200, height: 100 };
|
||||
|
||||
function position(overrides: Partial<Parameters<typeof computePosition>[0]> = {}) {
|
||||
return computePosition({
|
||||
triggerRect: TRIGGER,
|
||||
contentSize: CONTENT,
|
||||
displayArea: DISPLAY,
|
||||
placement: "bottom",
|
||||
alignment: "start",
|
||||
offset: 4,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("computePosition", () => {
|
||||
it("sits below the trigger with room to spare", () => {
|
||||
expect(position()).toEqual({ x: 100, y: 124, actualPlacement: "bottom" });
|
||||
});
|
||||
|
||||
it("aligns to the trigger's start, center, and end edges", () => {
|
||||
expect(position({ alignment: "start" }).x).toBe(100);
|
||||
expect(position({ alignment: "center" }).x).toBe(20);
|
||||
// 100 + 40 - 200 = -60, pulled back to the edge padding.
|
||||
expect(position({ alignment: "end" }).x).toBe(8);
|
||||
});
|
||||
|
||||
it("flips above the trigger when it does not fit below and there is more room above", () => {
|
||||
const result = position({
|
||||
triggerRect: { ...TRIGGER, y: 700 },
|
||||
contentSize: { width: 200, height: 200 },
|
||||
});
|
||||
expect(result.actualPlacement).toBe("top");
|
||||
expect(result.y).toBe(496);
|
||||
});
|
||||
|
||||
it("stays put when flipping would be just as cramped", () => {
|
||||
// Taller than the whole display area: neither side fits, so flipping buys nothing.
|
||||
const result = position({
|
||||
displayArea: { x: 0, y: 0, width: 1000, height: 300 },
|
||||
contentSize: { width: 200, height: 400 },
|
||||
});
|
||||
expect(result.actualPlacement).toBe("bottom");
|
||||
});
|
||||
|
||||
it("places side placements flush with the trigger top and ignores alignment", () => {
|
||||
const left = position({ placement: "left", alignment: "end" });
|
||||
expect(left).toEqual({ x: 8, y: 100, actualPlacement: "left" });
|
||||
|
||||
const right = position({ placement: "right", alignment: "end" });
|
||||
expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" });
|
||||
});
|
||||
|
||||
it("keeps the surface inside a display area that does not start at the origin", () => {
|
||||
// Regression: the horizontal clamp used to ignore displayArea.x while the vertical one
|
||||
// honoured displayArea.y, so an offset display area pushed the surface off its left edge.
|
||||
const result = position({
|
||||
displayArea: { x: 100, y: 0, width: 900, height: 800 },
|
||||
alignment: "end",
|
||||
});
|
||||
expect(result.x).toBe(108);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransformOrigin", () => {
|
||||
it.each([
|
||||
["bottom", "start", "top left"],
|
||||
["top", "end", "bottom right"],
|
||||
["right", "center", "center center"],
|
||||
] as const)("maps %s/%s to %s", (placement, alignment, expected) => {
|
||||
expect(getTransformOrigin(placement, alignment)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { View } from "react-native";
|
||||
|
||||
/**
|
||||
* Where a floating menu surface goes relative to the thing that opened it.
|
||||
*
|
||||
* This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`.
|
||||
* Submenus need a third caller, so it lives here now and the menus import it.
|
||||
*/
|
||||
|
||||
export type Placement = "top" | "bottom" | "left" | "right";
|
||||
export type Alignment = "start" | "center" | "end";
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Gap kept between the surface and the edge of the display area. */
|
||||
const EDGE_PADDING = 8;
|
||||
|
||||
export function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A surface asked to open below the trigger flips above it when it doesn't fit below *and*
|
||||
* there is more room above. Both conditions matter: flipping into a side that is equally
|
||||
* cramped just moves the clipping, so a surface taller than the whole viewport stays put.
|
||||
*
|
||||
* Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the
|
||||
* caller choosing its side up front, because flipping it mid-open would move the surface out
|
||||
* from under the pointer that is travelling toward it.
|
||||
*/
|
||||
function flipPlacement(input: {
|
||||
placement: Placement;
|
||||
triggerRect: Rect;
|
||||
contentHeight: number;
|
||||
displayArea: Rect;
|
||||
}): Placement {
|
||||
const { placement, triggerRect, contentHeight, displayArea } = input;
|
||||
const spaceTop = triggerRect.y - displayArea.y;
|
||||
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
|
||||
|
||||
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
|
||||
return "top";
|
||||
}
|
||||
if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
||||
return "bottom";
|
||||
}
|
||||
return placement;
|
||||
}
|
||||
|
||||
/** Where the surface's top-left corner lands, before it is pulled back inside the display area. */
|
||||
function anchorToPlacement(input: {
|
||||
placement: Placement;
|
||||
triggerRect: Rect;
|
||||
contentSize: Size;
|
||||
alignment: Alignment;
|
||||
offset: number;
|
||||
}): { x: number; y: number } {
|
||||
const { placement, triggerRect, contentSize, alignment, offset } = input;
|
||||
|
||||
if (placement === "left") {
|
||||
return { x: triggerRect.x - contentSize.width - offset, y: triggerRect.y };
|
||||
}
|
||||
if (placement === "right") {
|
||||
return { x: triggerRect.x + triggerRect.width + offset, y: triggerRect.y };
|
||||
}
|
||||
|
||||
const y =
|
||||
placement === "bottom"
|
||||
? triggerRect.y + triggerRect.height + offset
|
||||
: triggerRect.y - contentSize.height - offset;
|
||||
|
||||
// Vertical placements align horizontally against the trigger; side placements sit flush
|
||||
// with its top edge and ignore alignment entirely.
|
||||
if (alignment === "start") {
|
||||
return { x: triggerRect.x, y };
|
||||
}
|
||||
if (alignment === "end") {
|
||||
return { x: triggerRect.x + triggerRect.width - contentSize.width, y };
|
||||
}
|
||||
return { x: triggerRect.x + (triggerRect.width - contentSize.width) / 2, y };
|
||||
}
|
||||
|
||||
function clampToDisplayArea(input: {
|
||||
x: number;
|
||||
y: number;
|
||||
contentSize: Size;
|
||||
displayArea: Rect;
|
||||
}): { x: number; y: number } {
|
||||
const { x, y, contentSize, displayArea } = input;
|
||||
return {
|
||||
x: Math.max(
|
||||
displayArea.x + EDGE_PADDING,
|
||||
Math.min(displayArea.x + displayArea.width - contentSize.width - EDGE_PADDING, x),
|
||||
),
|
||||
y: Math.max(
|
||||
displayArea.y + EDGE_PADDING,
|
||||
Math.min(displayArea.y + displayArea.height - contentSize.height - EDGE_PADDING, y),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
placement,
|
||||
alignment,
|
||||
offset,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: Size;
|
||||
displayArea: Rect;
|
||||
placement: Placement;
|
||||
alignment: Alignment;
|
||||
offset: number;
|
||||
}): { x: number; y: number; actualPlacement: Placement } {
|
||||
const actualPlacement = flipPlacement({
|
||||
placement,
|
||||
triggerRect,
|
||||
contentHeight: contentSize.height,
|
||||
displayArea,
|
||||
});
|
||||
const anchored = anchorToPlacement({
|
||||
placement: actualPlacement,
|
||||
triggerRect,
|
||||
contentSize,
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement };
|
||||
}
|
||||
|
||||
export function getTransformOrigin(placement: Placement, alignment: Alignment): string {
|
||||
let vertical: string;
|
||||
if (placement === "bottom") vertical = "top";
|
||||
else if (placement === "top") vertical = "bottom";
|
||||
else vertical = "center";
|
||||
|
||||
let horizontal: string;
|
||||
if (alignment === "start") horizontal = "left";
|
||||
else if (alignment === "end") horizontal = "right";
|
||||
else horizontal = "center";
|
||||
|
||||
return `${vertical} ${horizontal}`;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { Platform, type View } from "react-native";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
import {
|
||||
closeSubPage,
|
||||
goBack as goBackPath,
|
||||
MENU_ROOT_PATH,
|
||||
openSubPage,
|
||||
type MenuPath,
|
||||
} from "./menu-navigation";
|
||||
import type { Rect } from "./menu-anchor";
|
||||
|
||||
/**
|
||||
* How a menu draws itself once it is open.
|
||||
*
|
||||
* `popover` anchors a floating surface to the trigger and opens submenus as flyouts beside it.
|
||||
* `sheet` presents from the bottom edge and opens submenus by replacing the page in place.
|
||||
* The choice is made from form factor, not platform, so a tablet in a narrow split view sheets
|
||||
* the same way a phone does.
|
||||
*/
|
||||
export type MenuPresentation = "popover" | "sheet";
|
||||
|
||||
/** What a menu does on a compact screen. Wide screens are always a popover. */
|
||||
export type MenuCompactMode = "popover" | "sheet";
|
||||
|
||||
export interface MenuContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/** Runs an item's action, closing the menu first when the item asks for it. */
|
||||
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
|
||||
flushPendingSelect: () => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
/** Point anchor for menus opened from a gesture rather than a trigger box. */
|
||||
anchorRect: Rect | null;
|
||||
setAnchorRect: (rect: Rect | null) => void;
|
||||
presentation: MenuPresentation;
|
||||
path: MenuPath;
|
||||
openSub: (sub: { id: string; depth: number }) => void;
|
||||
closeSub: (depth: number) => void;
|
||||
goBack: () => void;
|
||||
}
|
||||
|
||||
const MenuContext = createContext<MenuContextValue | null>(null);
|
||||
|
||||
export const MenuContextProvider = MenuContext.Provider;
|
||||
|
||||
export function useMenuContext(componentName: string): MenuContextValue {
|
||||
const ctx = useContext(MenuContext);
|
||||
if (!ctx) {
|
||||
throw new Error(`${componentName} must be used within a menu root`);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many submenu pages sit above the component reading it. A trigger on the root page is
|
||||
* depth 0. Each open submenu re-provides this one higher, which is what lets a submenu trigger
|
||||
* know whether it should nest below the current page or replace a sibling branch.
|
||||
*/
|
||||
const MenuDepthContext = createContext(0);
|
||||
|
||||
export const MenuDepthProvider = MenuDepthContext.Provider;
|
||||
|
||||
export function useMenuDepth(): number {
|
||||
return useContext(MenuDepthContext);
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one menu knows about itself. Both menu shapes build on this; they differ only in
|
||||
* what opens them, which is why the trigger is the wrapper's job and nothing here.
|
||||
*/
|
||||
export function useMenuState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
compactMode = "popover",
|
||||
}: {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
compactMode?: MenuCompactMode;
|
||||
}): MenuContextValue {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const pendingSelectRef = useRef<(() => void) | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
|
||||
const [path, setPath] = useState<MenuPath>(MENU_ROOT_PATH);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const presentation: MenuPresentation = isCompact && compactMode === "sheet" ? "sheet" : "popover";
|
||||
|
||||
const [isOpen, setIsOpenState] = useControllableOpenState({ open, defaultOpen, onOpenChange });
|
||||
useDismissKeyboardOnOpen(isOpen);
|
||||
|
||||
// A menu always reopens on its root page, against a freshly measured anchor. Keeping either
|
||||
// would reopen the surface three levels deep, or at the coordinates of the last right click.
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
setPath(MENU_ROOT_PATH);
|
||||
setAnchorRect(null);
|
||||
}
|
||||
setIsOpenState(next);
|
||||
},
|
||||
[setIsOpenState],
|
||||
);
|
||||
|
||||
const flushPendingSelect = useCallback(() => {
|
||||
const pendingSelect = pendingSelectRef.current;
|
||||
pendingSelectRef.current = null;
|
||||
if (!pendingSelect) return;
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
// Native presenters such as PHPicker can hang if launched while an RN
|
||||
// Modal is still completing dismissal on UIKit's side.
|
||||
setTimeout(pendingSelect, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSelect();
|
||||
}, []);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
|
||||
if (!closeOnSelect) {
|
||||
onSelect?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
pendingSelectRef.current = onSelect ?? null;
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
onSelect?.();
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const openSub = useCallback((sub: { id: string; depth: number }) => {
|
||||
setPath((current) => openSubPage(current, sub));
|
||||
}, []);
|
||||
|
||||
const closeSub = useCallback((depth: number) => {
|
||||
setPath((current) => closeSubPage(current, depth));
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setPath((current) => goBackPath(current));
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
open: isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
flushPendingSelect,
|
||||
triggerRef,
|
||||
anchorRect,
|
||||
setAnchorRect,
|
||||
presentation,
|
||||
path,
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
}),
|
||||
[
|
||||
isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
flushPendingSelect,
|
||||
anchorRect,
|
||||
presentation,
|
||||
path,
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useMenuContext } from "./menu-context";
|
||||
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
const ThemedCheckCircle = withUnistyles(CheckCircle);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
|
||||
/**
|
||||
* Height of the filled part of a row, which together with the uniform inset puts the row pitch
|
||||
* back at the 36pt it was before the fill was inset at all — the space is redistributed, not
|
||||
* added.
|
||||
*
|
||||
* It only holds if the label's line box is pinned: 18 line + 8 padding + 2 border is exactly
|
||||
* this. Leave the text's `lineHeight` to the platform and the content outgrows `minHeight`,
|
||||
* which then does nothing and the rows drift taller again.
|
||||
*/
|
||||
const MENU_ITEM_HEIGHT = 28;
|
||||
const MENU_ITEM_LINE_HEIGHT = 18;
|
||||
|
||||
/** Action status for menu items with loading/success feedback. */
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
|
||||
export function MenuLabel({
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
|
||||
const labelContainerStyle = useMemo(() => [styles.labelContainer, style], [style]);
|
||||
return (
|
||||
<View style={labelContainerStyle} testID={testID}>
|
||||
<Text style={styles.labelText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSeparator({
|
||||
style,
|
||||
testID,
|
||||
}: {
|
||||
style?: ViewStyle;
|
||||
testID?: string;
|
||||
}): ReactElement {
|
||||
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
|
||||
return <View style={separatorStyle} testID={testID} />;
|
||||
}
|
||||
|
||||
export function MenuHint({
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
|
||||
const hintContainerStyle = useMemo(() => [styles.hintContainer, style], [style]);
|
||||
return (
|
||||
<View style={hintContainerStyle} testID={testID}>
|
||||
<Text style={styles.hintText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLeadingContent(input: {
|
||||
isPending: boolean | undefined;
|
||||
isSuccess: boolean;
|
||||
leading: ReactElement | null;
|
||||
}): ReactElement | null {
|
||||
const { isPending, isSuccess, leading } = input;
|
||||
if (isPending) {
|
||||
return <ThemedLoadingSpinner size={16} uniProps={mutedMapping} />;
|
||||
}
|
||||
if (isSuccess) {
|
||||
return <ThemedCheckCircle size={16} uniProps={successMapping} />;
|
||||
}
|
||||
return leading;
|
||||
}
|
||||
|
||||
function resolveItemLabel(input: {
|
||||
children: ReactNode;
|
||||
isPending: boolean | undefined;
|
||||
isSuccess: boolean;
|
||||
pendingLabel?: string;
|
||||
successLabel?: string;
|
||||
}): ReactNode {
|
||||
const { children, isPending, isSuccess, pendingLabel, successLabel } = input;
|
||||
if (isPending && pendingLabel) return pendingLabel;
|
||||
if (isSuccess && successLabel) return successLabel;
|
||||
return children;
|
||||
}
|
||||
|
||||
export interface MenuItemProps {
|
||||
description?: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
muted?: boolean;
|
||||
destructive?: boolean;
|
||||
/**
|
||||
* This row's value is the chosen one. Draws a check and nothing else — a checked row that is
|
||||
* also filled in reads as two different claims about the same state.
|
||||
*/
|
||||
selected?: boolean;
|
||||
/** Reserves a leading check column so a group of items stays aligned whether ticked or not. */
|
||||
showSelectedCheck?: boolean;
|
||||
/**
|
||||
* This row's submenu is open. Distinct from `selected`: it is about where you are, not what
|
||||
* you picked, so it takes the background that `selected` gives up.
|
||||
*/
|
||||
active?: boolean;
|
||||
leading?: ReactElement | null;
|
||||
trailing?: ReactElement | null;
|
||||
/** @deprecated Use `status` instead */
|
||||
loading?: boolean;
|
||||
status?: ActionStatus;
|
||||
/** Label to show while pending (e.g. "Pushing…") */
|
||||
pendingLabel?: string;
|
||||
/** Label to show on success (e.g. "Pushed") */
|
||||
successLabel?: string;
|
||||
closeOnSelect?: boolean;
|
||||
testID?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function MenuItem({
|
||||
children,
|
||||
description,
|
||||
onSelect,
|
||||
disabled,
|
||||
muted = false,
|
||||
destructive,
|
||||
selected,
|
||||
showSelectedCheck = false,
|
||||
active = false,
|
||||
leading,
|
||||
trailing,
|
||||
loading,
|
||||
status,
|
||||
pendingLabel,
|
||||
successLabel,
|
||||
closeOnSelect = true,
|
||||
testID,
|
||||
tooltip,
|
||||
}: PropsWithChildren<MenuItemProps>): ReactElement {
|
||||
const { selectItem } = useMenuContext("MenuItem");
|
||||
|
||||
const isPending = status === "pending" || loading;
|
||||
const isSuccess = status === "success";
|
||||
const isDisabled = disabled || isPending || isSuccess;
|
||||
|
||||
const leadingContent = resolveLeadingContent({
|
||||
isPending,
|
||||
isSuccess,
|
||||
leading: leading ?? null,
|
||||
});
|
||||
|
||||
const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel });
|
||||
|
||||
const trailingContent =
|
||||
trailing ??
|
||||
(!showSelectedCheck && selected ? <ThemedCheck size={16} uniProps={mutedMapping} /> : null);
|
||||
|
||||
const handleItemPress = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
selectItem(onSelect, closeOnSelect);
|
||||
}, [isDisabled, selectItem, onSelect, closeOnSelect]);
|
||||
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.item,
|
||||
active ? styles.itemActive : null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
muted && !isDisabled ? styles.itemMuted : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
],
|
||||
[active, isDisabled, muted],
|
||||
);
|
||||
|
||||
const itemTextStyle = useMemo(
|
||||
() => [
|
||||
styles.itemText,
|
||||
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
||||
isSuccess ? styles.itemTextSuccess : null,
|
||||
muted && !isDisabled ? styles.itemTextMuted : null,
|
||||
],
|
||||
[destructive, isSuccess, muted, isDisabled],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
disabled={isDisabled}
|
||||
onPress={handleItemPress}
|
||||
style={itemPressableStyle}
|
||||
>
|
||||
{showSelectedCheck ? (
|
||||
<View style={styles.checkSlot}>
|
||||
{selected ? <ThemedCheck size={16} uniProps={foregroundMapping} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
|
||||
<View style={styles.itemContent}>
|
||||
<Text numberOfLines={1} style={itemTextStyle}>
|
||||
{label}
|
||||
</Text>
|
||||
{description && !isPending && !isSuccess ? (
|
||||
<Text numberOfLines={2} style={styles.itemDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" offset={10}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
labelContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
labelText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
// `border` sits between surface1 and surface2, which put it within a hair of the hover fill and
|
||||
// made separators vanish against a hovered row. `borderAccent` is the colour the menu surface
|
||||
// already outlines itself with, so the divider reads as part of the same frame.
|
||||
//
|
||||
// No margin of its own: the items above and below already hold themselves off it. Giving the
|
||||
// rule its own spacing on top of theirs is how you end up tuning two numbers to control one gap.
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.borderAccent,
|
||||
},
|
||||
hintContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
hintText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
// The fill is inset by the same amount on every side and rounded, so a hovered row reads as a
|
||||
// chip sitting inside the menu. That inset is the only thing that spaces this menu: rows sit
|
||||
// apart because each holds itself in, and a separator gets clearance from its neighbours for
|
||||
// free instead of carrying its own margin.
|
||||
//
|
||||
// The inset is taken out of the row, not added to it. Horizontally that means padding gives up
|
||||
// what margin takes — margin 4 + padding 8 + border 1 lands the label at the same 13pt it
|
||||
// always sat at. Vertically the fill itself is shorter by the same 8, so the pitch is unchanged
|
||||
// and the menu did not grow; it just stopped filling every row edge to edge.
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: MENU_ITEM_HEIGHT,
|
||||
gap: theme.spacing[2],
|
||||
margin: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: "transparent",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
itemHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
// The row you are inside, not the value you chose. A chosen value is marked by its check.
|
||||
itemActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
itemMuted: {
|
||||
opacity: 0.72,
|
||||
},
|
||||
itemText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: MENU_ITEM_LINE_HEIGHT,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
itemTextMuted: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
itemTextDestructive: {
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
itemTextSuccess: {
|
||||
color: theme.colors.palette.green[500],
|
||||
},
|
||||
itemDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
checkSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
leadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
trailingSlot: {
|
||||
marginLeft: "auto",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
itemContent: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeSubPage,
|
||||
currentPageId,
|
||||
goBack,
|
||||
isSubPageOpen,
|
||||
MENU_ROOT_PATH,
|
||||
openSubPage,
|
||||
} from "./menu-navigation";
|
||||
|
||||
describe("openSubPage", () => {
|
||||
it("opens a submenu from the root", () => {
|
||||
expect(openSubPage(MENU_ROOT_PATH, { id: "show", depth: 0 })).toEqual(["show"]);
|
||||
});
|
||||
|
||||
it("nests a submenu opened from inside another", () => {
|
||||
expect(openSubPage(["show"], { id: "colors", depth: 1 })).toEqual(["show", "colors"]);
|
||||
});
|
||||
|
||||
it("swaps branches instead of nesting when the trigger is a sibling", () => {
|
||||
// Sliding the pointer from one root trigger to another must not stack both flyouts.
|
||||
expect(openSubPage(["grouping"], { id: "show", depth: 0 })).toEqual(["show"]);
|
||||
});
|
||||
|
||||
it("drops deeper pages when an ancestor's sibling opens", () => {
|
||||
expect(openSubPage(["show", "colors"], { id: "grouping", depth: 0 })).toEqual(["grouping"]);
|
||||
});
|
||||
|
||||
it("returns the same path when the submenu is already open", () => {
|
||||
const path = ["show"];
|
||||
expect(openSubPage(path, { id: "show", depth: 0 })).toBe(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeSubPage", () => {
|
||||
it("closes the page at a depth and everything below it", () => {
|
||||
expect(closeSubPage(["show", "colors"], 0)).toEqual([]);
|
||||
expect(closeSubPage(["show", "colors"], 1)).toEqual(["show"]);
|
||||
});
|
||||
|
||||
it("returns the same path when nothing is open at that depth", () => {
|
||||
const path = ["show"];
|
||||
expect(closeSubPage(path, 1)).toBe(path);
|
||||
expect(closeSubPage(MENU_ROOT_PATH, 0)).toBe(MENU_ROOT_PATH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goBack", () => {
|
||||
it("pops one page", () => {
|
||||
expect(goBack(["show", "colors"])).toEqual(["show"]);
|
||||
expect(goBack(["show"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("is a no-op at the root", () => {
|
||||
expect(goBack(MENU_ROOT_PATH)).toBe(MENU_ROOT_PATH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentPageId", () => {
|
||||
it("is null at the root and the last id otherwise", () => {
|
||||
expect(currentPageId(MENU_ROOT_PATH)).toBeNull();
|
||||
expect(currentPageId(["show", "colors"])).toBe("colors");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSubPageOpen", () => {
|
||||
it("matches on id and depth together", () => {
|
||||
const path = ["show", "colors"];
|
||||
expect(isSubPageOpen(path, { id: "show", depth: 0 })).toBe(true);
|
||||
expect(isSubPageOpen(path, { id: "colors", depth: 1 })).toBe(true);
|
||||
// Right id, wrong level — a different trigger that happens to share a name.
|
||||
expect(isSubPageOpen(path, { id: "colors", depth: 0 })).toBe(false);
|
||||
expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Which submenu pages are open, as a path from the root outward.
|
||||
*
|
||||
* The desktop flyout chain and the mobile push stack are the same structure, which is why
|
||||
* there is one model and not two: a pointer surface renders *every* page in the path as its
|
||||
* own anchored surface, and a compact sheet renders only the last one. Nothing else differs.
|
||||
*
|
||||
* Pure and React-free on purpose — the ordering rules below are the part worth testing, and
|
||||
* they don't need a renderer to exercise.
|
||||
*/
|
||||
|
||||
/** Sub-page ids from the root outward. Empty means the root page is showing. */
|
||||
export type MenuPath = readonly string[];
|
||||
|
||||
export const MENU_ROOT_PATH: MenuPath = [];
|
||||
|
||||
/**
|
||||
* Opening a submenu truncates the path to the depth of the trigger that opened it, so moving
|
||||
* between two triggers on the same page swaps branches instead of nesting them. Without this,
|
||||
* sliding the pointer across a menu would stack up every flyout it passed.
|
||||
*
|
||||
* `depth` is how many sub-pages sit above the trigger's own page: a trigger on the root page
|
||||
* is depth 0, one inside a first-level submenu is depth 1.
|
||||
*/
|
||||
export function openSubPage(path: MenuPath, sub: { id: string; depth: number }): MenuPath {
|
||||
if (isSubPageOpen(path, sub)) return path;
|
||||
return [...path.slice(0, sub.depth), sub.id];
|
||||
}
|
||||
|
||||
/** Closes the page opened at `depth` and everything it opened in turn. */
|
||||
export function closeSubPage(path: MenuPath, depth: number): MenuPath {
|
||||
if (path.length <= depth) return path;
|
||||
return path.slice(0, depth);
|
||||
}
|
||||
|
||||
export function goBack(path: MenuPath): MenuPath {
|
||||
if (path.length === 0) return path;
|
||||
return path.slice(0, -1);
|
||||
}
|
||||
|
||||
/** The page a compact surface should render. Null means the root page. */
|
||||
export function currentPageId(path: MenuPath): string | null {
|
||||
return path.length === 0 ? null : (path[path.length - 1] ?? null);
|
||||
}
|
||||
|
||||
export function isSubPageOpen(path: MenuPath, sub: { id: string; depth: number }): boolean {
|
||||
return path[sub.depth] === sub.id;
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useState, type ReactElement } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StatusBar,
|
||||
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 {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import {
|
||||
computePosition,
|
||||
getTransformOrigin,
|
||||
measureElement,
|
||||
type Alignment,
|
||||
type Placement,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "./menu-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);
|
||||
|
||||
const contentExiting = new Keyframe({
|
||||
0: { opacity: 1, transform: [{ scale: 1 }] },
|
||||
100: { opacity: 0, transform: [{ scale: 0.97 }] },
|
||||
}).duration(100);
|
||||
|
||||
function releaseFixedMenuHeight(surfaceNativeID: string): void {
|
||||
if (!isWeb) return;
|
||||
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
|
||||
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
|
||||
* a submenu page taller than the page it replaced is not clipped to the old height.
|
||||
*
|
||||
* `revision` is what makes that second case work: bump it whenever the rendered content changes
|
||||
* identity (a page push, for instance) and the snapshot is released again.
|
||||
*/
|
||||
function useReleaseFixedMenuHeight({
|
||||
contentSize,
|
||||
enabled,
|
||||
surfaceNativeID,
|
||||
revision,
|
||||
}: {
|
||||
contentSize: Size | null;
|
||||
enabled: boolean;
|
||||
surfaceNativeID: string;
|
||||
revision?: string | number;
|
||||
}): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
const release = () => {
|
||||
releaseFixedMenuHeight(surfaceNativeID);
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
|
||||
}
|
||||
};
|
||||
const timers: ReturnType<typeof setTimeout>[] = [
|
||||
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
|
||||
];
|
||||
|
||||
if (contentSize) {
|
||||
timers.push(setTimeout(release, 0));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
};
|
||||
}, [contentSize, enabled, surfaceNativeID, revision]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// Capture status bar height synchronously before async measurement, so it cannot change
|
||||
// or read back null between the measure call and its resolution.
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
void measureElement(anchorRef.current).then((rect) => {
|
||||
if (cancelled) return undefined;
|
||||
// On Android with statusBarTranslucent, measureInWindow returns coordinates relative to
|
||||
// below the status bar while Modal content starts at the screen top.
|
||||
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
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;
|
||||
/** Bump when the rendered content changes identity, so the height snapshot is released. */
|
||||
revision?: string | number;
|
||||
/** 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;
|
||||
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,
|
||||
revision,
|
||||
backdrop = true,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
testID,
|
||||
children,
|
||||
}: AnchoredSurfaceProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const surfaceNativeID = useId();
|
||||
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
|
||||
useAnchoredPosition({
|
||||
open,
|
||||
anchorRect,
|
||||
anchorRef,
|
||||
side,
|
||||
align,
|
||||
offset,
|
||||
scrollable,
|
||||
maxHeight,
|
||||
});
|
||||
|
||||
useReleaseFixedMenuHeight({
|
||||
contentSize,
|
||||
enabled: open,
|
||||
surfaceNativeID,
|
||||
revision,
|
||||
});
|
||||
|
||||
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
? { width: screenWidth - horizontalPadding * 2 }
|
||||
: {
|
||||
...(typeof width === "number" ? { width } : null),
|
||||
...(typeof minWidth === "number" ? { minWidth } : null),
|
||||
...(typeof maxWidth === "number" ? { maxWidth } : null),
|
||||
};
|
||||
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],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const measured = (
|
||||
<View
|
||||
collapsable={false}
|
||||
onLayout={onContentLayout}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{backdrop ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={onClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<FloatingSurface
|
||||
collapsable={false}
|
||||
tabIndex={-1}
|
||||
nativeID={surfaceNativeID}
|
||||
testID={testID}
|
||||
style={styles.content}
|
||||
frameStyle={frameStyle}
|
||||
entering={contentEntering}
|
||||
exiting={
|
||||
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,
|
||||
onDismiss,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss?: () => void;
|
||||
children: ReactElement | null;
|
||||
}): ReactElement | null {
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
return true;
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && visible,
|
||||
layer: floatingLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const overlay = (
|
||||
<OverlayLayerProvider layer={floatingLayer}>
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
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"}
|
||||
onDismiss={onDismiss}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{overlay}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
},
|
||||
overlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, type PropsWithChildren, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import {
|
||||
MenuContextProvider,
|
||||
useMenuContext,
|
||||
useMenuState,
|
||||
type MenuCompactMode,
|
||||
} from "./menu-context";
|
||||
|
||||
/**
|
||||
* Owns one menu's state. Wrap a trigger and a `MenuSurface` in it.
|
||||
*
|
||||
* The trigger is deliberately not part of this: what opens a menu is the only thing that
|
||||
* differs between a dropdown (press) and a context menu (long press or right click), and it is
|
||||
* the whole reason those two wrappers still exist.
|
||||
*/
|
||||
export function MenuRoot({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
compactMode,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
compactMode?: MenuCompactMode;
|
||||
}>): ReactElement {
|
||||
const value = useMenuState({ open, defaultOpen, onOpenChange, compactMode });
|
||||
return <MenuContextProvider value={value}>{children}</MenuContextProvider>;
|
||||
}
|
||||
|
||||
export interface MenuTriggerState {
|
||||
pressed: boolean;
|
||||
hovered: boolean;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
|
||||
|
||||
export interface MenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
|
||||
style?: TriggerStyleProp;
|
||||
children: ReactNode | ((state: MenuTriggerState) => ReactNode);
|
||||
}
|
||||
|
||||
export function MenuTrigger({
|
||||
children,
|
||||
disabled,
|
||||
style,
|
||||
...props
|
||||
}: MenuTriggerProps): ReactElement {
|
||||
const ctx = useMenuContext("MenuTrigger");
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (disabled) return;
|
||||
ctx.setOpen(!ctx.open);
|
||||
}, [disabled, ctx]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered, open: ctx.open });
|
||||
}
|
||||
return style;
|
||||
},
|
||||
[style, ctx.open],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
const state: MenuTriggerState = { pressed, hovered, open: ctx.open };
|
||||
return typeof children === "function" ? children(state) : children;
|
||||
},
|
||||
[children, ctx.open],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
ref={ctx.triggerRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{renderChildren}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useMenuContext, useMenuDepth } from "./menu-context";
|
||||
import { isSubPageOpen } from "./menu-navigation";
|
||||
import { useMenuSurface } from "./menu-surface";
|
||||
import { MenuItem } from "./menu-item";
|
||||
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/**
|
||||
* A row that leads to a submenu page, showing the decision's current value on the way in
|
||||
* ("Grouping Project ›"). Selecting it navigates rather than acting, so it never closes the
|
||||
* menu.
|
||||
*
|
||||
* Hover lives on a plain `View` wrapping the item's `Pressable`, per docs/hover.md — a
|
||||
* `Pressable` tracking its own hover would fight the one inside it. Hover only fires on web,
|
||||
* which is exactly where flyouts exist; every other platform opens the page by pressing.
|
||||
*/
|
||||
export function MenuSubTrigger({
|
||||
id,
|
||||
value,
|
||||
indicator = false,
|
||||
disabled,
|
||||
testID,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
/** Matches the `id` of a `MenuPageDefinition` passed to the surface. */
|
||||
id: string;
|
||||
/** Current value of the decision this row leads to. */
|
||||
value?: string;
|
||||
/** Marks the branch as holding a non-default setting, e.g. an active filter. */
|
||||
indicator?: boolean;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}>): ReactElement {
|
||||
const menu = useMenuContext("MenuSubTrigger");
|
||||
const depth = useMenuDepth();
|
||||
const { registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose } =
|
||||
useMenuSurface("MenuSubTrigger");
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
useEffect(() => {
|
||||
registerSubAnchor(id, anchorRef);
|
||||
}, [id, registerSubAnchor]);
|
||||
|
||||
const isOpen = isSubPageOpen(menu.path, { id, depth });
|
||||
const isPopover = menu.presentation === "popover";
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
menu.openSub({ id, depth });
|
||||
}, [menu, id, depth]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
cancelHoverClose();
|
||||
hoverOpen({ id, depth });
|
||||
}, [cancelHoverClose, hoverOpen, id, depth]);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
hoverClose(depth);
|
||||
}, [hoverClose, depth]);
|
||||
|
||||
const trailing = useMemo(
|
||||
() => <MenuSubTrailing value={value} indicator={indicator} />,
|
||||
[value, indicator],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
onPointerEnter={isPopover && !disabled ? handlePointerEnter : undefined}
|
||||
onPointerLeave={isPopover && !disabled ? handlePointerLeave : undefined}
|
||||
>
|
||||
<MenuItem
|
||||
active={isOpen}
|
||||
closeOnSelect={false}
|
||||
disabled={disabled}
|
||||
onSelect={handleSelect}
|
||||
trailing={trailing}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</MenuItem>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSubTrailing({
|
||||
value,
|
||||
indicator,
|
||||
}: {
|
||||
value?: string;
|
||||
indicator: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<View style={styles.trailing}>
|
||||
{value ? (
|
||||
<Text style={styles.valueText} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
{indicator ? <View style={styles.indicator} testID="menu-sub-indicator" /> : null}
|
||||
<ThemedChevronRight size={14} uniProps={mutedIconMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
valueText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
indicator: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ComponentProps,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useMenuContext, MenuDepthProvider } from "./menu-context";
|
||||
import { currentPageId, isSubPageOpen } from "./menu-navigation";
|
||||
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
|
||||
import type { Alignment, Placement } from "./menu-anchor";
|
||||
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
|
||||
// track, so the sheet is wrapped rather than reading the theme through a hook.
|
||||
// See docs/unistyles.md.
|
||||
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
|
||||
backgroundStyle: {
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
/** How long the pointer must rest on a submenu row before its flyout opens. */
|
||||
const HOVER_OPEN_DELAY_MS = 90;
|
||||
/**
|
||||
* How long a flyout survives after the pointer leaves. The pointer still crosses sibling rows on
|
||||
* its way down into the flyout, so leaving the trigger cannot close it immediately.
|
||||
*/
|
||||
const HOVER_CLOSE_GRACE_MS = 260;
|
||||
/**
|
||||
* How far the flyout sits *over* its parent surface rather than beside it. The overlap is what
|
||||
* makes the hand-off reliable: with a gap there is a strip of backdrop between the two where the
|
||||
* pointer belongs to neither, and every pixel of it is a chance to dismiss the thing you are
|
||||
* reaching for. Overlapping removes the strip instead of timing around it.
|
||||
*/
|
||||
const SUBMENU_OVERLAP = 5;
|
||||
|
||||
export interface MenuPageDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface MenuSurfaceContextValue {
|
||||
registerSubAnchor: (id: string, ref: React.RefObject<View | null>) => void;
|
||||
hoverOpen: (sub: { id: string; depth: number }) => void;
|
||||
hoverClose: (depth: number) => void;
|
||||
cancelHoverClose: () => void;
|
||||
}
|
||||
|
||||
const MenuSurfaceContext = createContext<MenuSurfaceContextValue | null>(null);
|
||||
|
||||
export function useMenuSurface(componentName: string): MenuSurfaceContextValue {
|
||||
const ctx = useContext(MenuSurfaceContext);
|
||||
if (!ctx) {
|
||||
throw new Error(`${componentName} must be used within <MenuSurface />`);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export interface MenuSurfaceProps {
|
||||
/** Root page content. */
|
||||
children: ReactNode;
|
||||
/** Sub pages, reachable from a `MenuSubTrigger` on the root page. */
|
||||
pages?: readonly MenuPageDefinition[];
|
||||
/** Title shown on the sheet's root page. Sheets always have a header; popovers never do. */
|
||||
sheetTitle?: string;
|
||||
side?: Placement;
|
||||
align?: Alignment;
|
||||
offset?: number;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
fullWidth?: boolean;
|
||||
horizontalPadding?: number;
|
||||
scrollable?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open menu. Reads its presentation from the menu root and renders either an anchored
|
||||
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
|
||||
*/
|
||||
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
|
||||
const { presentation } = useMenuContext("MenuSurface");
|
||||
if (presentation === "sheet") {
|
||||
return <MenuSheetSurface {...props} />;
|
||||
}
|
||||
return <MenuPopoverSurface {...props} />;
|
||||
}
|
||||
|
||||
function useSubAnchors(): {
|
||||
value: MenuSurfaceContextValue;
|
||||
getAnchor: (id: string) => React.RefObject<View | null> | null;
|
||||
} {
|
||||
const anchors = useRef(new Map<string, React.RefObject<View | null>>());
|
||||
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { openSub, closeSub } = useMenuContext("MenuSurface");
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (openTimer.current) clearTimeout(openTimer.current);
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
openTimer.current = null;
|
||||
closeTimer.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
const registerSubAnchor = useCallback((id: string, ref: React.RefObject<View | null>) => {
|
||||
anchors.current.set(id, ref);
|
||||
}, []);
|
||||
|
||||
const hoverOpen = useCallback(
|
||||
(sub: { id: string; depth: number }) => {
|
||||
clearTimers();
|
||||
openTimer.current = setTimeout(() => openSub(sub), HOVER_OPEN_DELAY_MS);
|
||||
},
|
||||
[clearTimers, openSub],
|
||||
);
|
||||
|
||||
const hoverClose = useCallback(
|
||||
(depth: number) => {
|
||||
if (openTimer.current) clearTimeout(openTimer.current);
|
||||
openTimer.current = null;
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
closeTimer.current = setTimeout(() => closeSub(depth), HOVER_CLOSE_GRACE_MS);
|
||||
},
|
||||
[closeSub],
|
||||
);
|
||||
|
||||
const cancelHoverClose = useCallback(() => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose }),
|
||||
[registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose],
|
||||
);
|
||||
|
||||
const getAnchor = useCallback((id: string) => anchors.current.get(id) ?? null, []);
|
||||
|
||||
return { value, getAnchor };
|
||||
}
|
||||
|
||||
function MenuPopoverSurface({
|
||||
children,
|
||||
pages = [],
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
offset = 4,
|
||||
width,
|
||||
minWidth = 180,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
fullWidth = false,
|
||||
horizontalPadding = 16,
|
||||
scrollable = false,
|
||||
testID,
|
||||
}: MenuSurfaceProps): ReactElement | null {
|
||||
const menu = useMenuContext("MenuSurface");
|
||||
const { value: surfaceValue, getAnchor } = useSubAnchors();
|
||||
|
||||
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
|
||||
|
||||
// Every open page in the path gets its own flyout, so a submenu of a submenu simply stacks.
|
||||
const openPages = useMemo(
|
||||
() =>
|
||||
menu.path
|
||||
.map((id, depth) => {
|
||||
const page = pages.find((candidate) => candidate.id === id);
|
||||
return page ? { page, depth } : null;
|
||||
})
|
||||
.filter((entry): entry is { page: MenuPageDefinition; depth: number } => entry !== null),
|
||||
[menu.path, pages],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>
|
||||
<MenuOverlay visible={menu.open} onClose={handleClose} onDismiss={menu.flushPendingSelect}>
|
||||
<>
|
||||
<AnchoredSurface
|
||||
open={menu.open}
|
||||
onClose={handleClose}
|
||||
anchorRect={menu.anchorRect}
|
||||
anchorRef={menu.triggerRef}
|
||||
side={side}
|
||||
align={align}
|
||||
offset={offset}
|
||||
width={width}
|
||||
minWidth={minWidth}
|
||||
maxWidth={maxWidth}
|
||||
maxHeight={maxHeight}
|
||||
fullWidth={fullWidth}
|
||||
horizontalPadding={horizontalPadding}
|
||||
scrollable={scrollable}
|
||||
testID={testID}
|
||||
>
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
</AnchoredSurface>
|
||||
{openPages.map(({ page, depth }) => (
|
||||
<MenuFlyout
|
||||
key={page.id}
|
||||
page={page}
|
||||
depth={depth}
|
||||
anchorRef={getAnchor(page.id)}
|
||||
minWidth={minWidth}
|
||||
maxHeight={maxHeight}
|
||||
scrollable={scrollable}
|
||||
testID={testID ? `${testID}-${page.id}` : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</MenuOverlay>
|
||||
</MenuSurfaceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A submenu's own floating surface. It keeps itself alive while the pointer is inside it —
|
||||
* without that, walking from the row into the flyout would cross the gap between them and
|
||||
* trigger the close it was travelling to avoid.
|
||||
*/
|
||||
function MenuFlyout({
|
||||
page,
|
||||
depth,
|
||||
anchorRef,
|
||||
minWidth,
|
||||
maxHeight,
|
||||
scrollable,
|
||||
testID,
|
||||
}: {
|
||||
page: MenuPageDefinition;
|
||||
depth: number;
|
||||
anchorRef: React.RefObject<View | null> | null;
|
||||
minWidth?: number;
|
||||
maxHeight?: number;
|
||||
scrollable?: boolean;
|
||||
testID?: string;
|
||||
}): ReactElement | null {
|
||||
const menu = useMenuContext("MenuFlyout");
|
||||
const { hoverClose, cancelHoverClose } = useMenuSurface("MenuFlyout");
|
||||
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
|
||||
const handleHoverOut = useCallback(() => hoverClose(depth), [hoverClose, depth]);
|
||||
|
||||
if (!anchorRef) return null;
|
||||
|
||||
return (
|
||||
<AnchoredSurface
|
||||
open
|
||||
onClose={handleClose}
|
||||
anchorRect={null}
|
||||
anchorRef={anchorRef}
|
||||
side="right"
|
||||
align="start"
|
||||
offset={-SUBMENU_OVERLAP}
|
||||
minWidth={minWidth}
|
||||
maxHeight={maxHeight}
|
||||
scrollable={scrollable}
|
||||
backdrop={false}
|
||||
revision={page.id}
|
||||
onPointerEnter={cancelHoverClose}
|
||||
onPointerLeave={handleHoverOut}
|
||||
testID={testID}
|
||||
>
|
||||
<MenuDepthProvider value={depth + 1}>{page.content}</MenuDepthProvider>
|
||||
</AnchoredSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSheetSurface({
|
||||
children,
|
||||
pages = [],
|
||||
sheetTitle,
|
||||
testID,
|
||||
}: MenuSurfaceProps): ReactElement | null {
|
||||
const menu = useMenuContext("MenuSurface");
|
||||
const { value: surfaceValue } = useSubAnchors();
|
||||
|
||||
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: menu.open,
|
||||
isEnabled: true,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...backdropProps}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const openPageId = currentPageId(menu.path);
|
||||
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
||||
const depth = menu.path.length;
|
||||
|
||||
return (
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>
|
||||
<ThemedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
</MenuSurfaceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSheetHeader({
|
||||
title,
|
||||
onBack,
|
||||
}: {
|
||||
title: string;
|
||||
onBack: (() => void) | null;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.sheetHeader}>
|
||||
{onBack ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("common.back")}
|
||||
hitSlop={8}
|
||||
onPress={onBack}
|
||||
style={styles.sheetBackButton}
|
||||
testID="menu-sheet-back"
|
||||
>
|
||||
<ThemedChevronLeft size={18} uniProps={mutedIconMapping} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sheetScrollContent: {
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[3],
|
||||
},
|
||||
sheetBackButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sheetTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
export { isSubPageOpen };
|
||||
Reference in New Issue
Block a user