mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(app): add header tooltips + shortcuts (#16)
This commit is contained in:
47
packages/app/e2e/sidebar-toggle-tooltip.spec.ts
Normal file
47
packages/app/e2e/sidebar-toggle-tooltip.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
||||
await menuButton.focus();
|
||||
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tooltip should also appear on hover.
|
||||
await menuButton.blur();
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await menuButton.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
const triggerBox = await menuButton.boundingBox();
|
||||
const tooltipBox = await tooltip.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
if (!triggerBox || !tooltipBox) return;
|
||||
|
||||
// side=right => tooltip starts to the right of the trigger.
|
||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
||||
|
||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
79
packages/app/src/components/headers/header-toggle-button.tsx
Normal file
79
packages/app/src/components/headers/header-toggle-button.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
export function HeaderToggleButton({
|
||||
onPress,
|
||||
tooltipLabel,
|
||||
tooltipKeys,
|
||||
tooltipSide,
|
||||
tooltipDelayDuration = 0,
|
||||
style,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style" | "onPress"> & {
|
||||
onPress: NonNullable<PressableProps["onPress"]>;
|
||||
tooltipLabel: string;
|
||||
tooltipKeys: ShortcutKey[];
|
||||
tooltipSide: "left" | "right" | "top" | "bottom";
|
||||
tooltipDelayDuration?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
>): ReactElement {
|
||||
const tooltipTestID =
|
||||
typeof props.testID === "string" && props.testID.length > 0
|
||||
? `${props.testID}-tooltip`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
onPress={(e) => {
|
||||
onPress(e);
|
||||
}}
|
||||
style={[styles.button, style]}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
<Shortcut keys={tooltipKeys} style={styles.shortcut} textStyle={styles.shortcutText} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
button: {
|
||||
padding: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
shortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
shortcutText: {
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}));
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Menu, PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
@@ -28,19 +29,20 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<Pressable
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
style={styles.menuButton}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={["mod", "B"]}
|
||||
tooltipSide="right"
|
||||
testID="menu-button"
|
||||
nativeID="menu-button"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityState={{ expanded: isOpen }}
|
||||
>
|
||||
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
|
||||
</Pressable>
|
||||
</HeaderToggleButton>
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
@@ -58,13 +60,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
menuButton: {
|
||||
padding: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
37
packages/app/src/components/ui/shortcut.tsx
Normal file
37
packages/app/src/components/ui/shortcut.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
export function Shortcut({
|
||||
keys,
|
||||
style,
|
||||
textStyle,
|
||||
}: {
|
||||
keys: ShortcutKey[];
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<View style={[styles.root, style]}>
|
||||
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
477
packages/app/src/components/ui/tooltip.tsx
Normal file
477
packages/app/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Modal,
|
||||
Pressable,
|
||||
StatusBar,
|
||||
View,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type TooltipContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
enabled: boolean;
|
||||
delayDuration: number;
|
||||
};
|
||||
|
||||
const TooltipContext = createContext<TooltipContextValue | null>(null);
|
||||
|
||||
function useTooltipContext(componentName: string): TooltipContextValue {
|
||||
const ctx = useContext(TooltipContext);
|
||||
if (!ctx) {
|
||||
throw new Error(`${componentName} must be used within <Tooltip />`);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
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 ? Boolean(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,
|
||||
side,
|
||||
align,
|
||||
offset,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: { width: number; height: number };
|
||||
displayArea: Rect;
|
||||
side: Side;
|
||||
align: Align;
|
||||
offset: number;
|
||||
}): { x: number; y: number; actualSide: Side } {
|
||||
const { width: contentWidth, height: contentHeight } = contentSize;
|
||||
|
||||
const spaceTop = triggerRect.y - displayArea.y;
|
||||
const spaceBottom =
|
||||
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
|
||||
const spaceLeft = triggerRect.x - displayArea.x;
|
||||
const spaceRight =
|
||||
displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
|
||||
|
||||
let actualSide = side;
|
||||
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
|
||||
actualSide = "top";
|
||||
} else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
||||
actualSide = "bottom";
|
||||
} else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
|
||||
actualSide = "right";
|
||||
} else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
|
||||
actualSide = "left";
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
if (actualSide === "bottom") {
|
||||
y = triggerRect.y + triggerRect.height + offset;
|
||||
if (align === "start") {
|
||||
x = triggerRect.x;
|
||||
} else if (align === "end") {
|
||||
x = triggerRect.x + triggerRect.width - contentWidth;
|
||||
} else {
|
||||
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
|
||||
}
|
||||
} else if (actualSide === "top") {
|
||||
y = triggerRect.y - contentHeight - offset;
|
||||
if (align === "start") {
|
||||
x = triggerRect.x;
|
||||
} else if (align === "end") {
|
||||
x = triggerRect.x + triggerRect.width - contentWidth;
|
||||
} else {
|
||||
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
|
||||
}
|
||||
} else if (actualSide === "left") {
|
||||
x = triggerRect.x - contentWidth - offset;
|
||||
if (align === "start") {
|
||||
y = triggerRect.y;
|
||||
} else if (align === "end") {
|
||||
y = triggerRect.y + triggerRect.height - contentHeight;
|
||||
} else {
|
||||
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
|
||||
}
|
||||
} else {
|
||||
x = triggerRect.x + triggerRect.width + offset;
|
||||
if (align === "start") {
|
||||
y = triggerRect.y;
|
||||
} else if (align === "end") {
|
||||
y = triggerRect.y + triggerRect.height - contentHeight;
|
||||
} else {
|
||||
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
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, actualSide };
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
delayDuration = 0,
|
||||
enabledOnDesktop = true,
|
||||
enabledOnMobile = false,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
delayDuration?: number;
|
||||
enabledOnDesktop?: boolean;
|
||||
enabledOnMobile?: boolean;
|
||||
}>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const isMobileWeb =
|
||||
isWeb &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
open: isOpen,
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, delayDuration]
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
}
|
||||
|
||||
export function TooltipTrigger({
|
||||
children,
|
||||
disabled,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPress,
|
||||
...props
|
||||
}: PropsWithChildren<PressableProps>): ReactElement {
|
||||
const ctx = useTooltipContext("TooltipTrigger");
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearOpenTimer = useCallback(() => {
|
||||
if (openTimerRef.current) {
|
||||
clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleOpen = useCallback(() => {
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
if (ctx.delayDuration <= 0) {
|
||||
ctx.setOpen(true);
|
||||
return;
|
||||
}
|
||||
openTimerRef.current = setTimeout(() => {
|
||||
ctx.setOpen(true);
|
||||
openTimerRef.current = null;
|
||||
}, ctx.delayDuration);
|
||||
}, [clearOpenTimer, ctx, disabled]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(false);
|
||||
}, [clearOpenTimer, ctx]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearOpenTimer();
|
||||
};
|
||||
}, [clearOpenTimer]);
|
||||
|
||||
const handleHoverIn = useCallback(
|
||||
(e?: any) => {
|
||||
onHoverIn?.(e);
|
||||
scheduleOpen();
|
||||
},
|
||||
[onHoverIn, scheduleOpen]
|
||||
);
|
||||
|
||||
const handleHoverOut = useCallback(
|
||||
(e?: any) => {
|
||||
onHoverOut?.(e);
|
||||
close();
|
||||
},
|
||||
[onHoverOut, close]
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
ref={ctx.triggerRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
onFocus={(e) => {
|
||||
onFocus?.(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onBlur?.(e);
|
||||
close();
|
||||
}}
|
||||
onPress={(e) => {
|
||||
onPress?.(e);
|
||||
close();
|
||||
}}
|
||||
{...(Platform.OS === "web"
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
onPointerLeave: handleHoverOut,
|
||||
onMouseEnter: handleHoverIn,
|
||||
onMouseLeave: handleHoverOut,
|
||||
} as any)
|
||||
: null)}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function TooltipContent({
|
||||
children,
|
||||
side = "top",
|
||||
align = "center",
|
||||
offset = 6,
|
||||
style,
|
||||
testID,
|
||||
maxWidth = 280,
|
||||
}: PropsWithChildren<{
|
||||
side?: Side;
|
||||
align?: Align;
|
||||
offset?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
maxWidth?: number;
|
||||
}>): ReactElement | null {
|
||||
const ctx = useTooltipContext("TooltipContent");
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBarHeight =
|
||||
Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ctx.enabled, ctx.open, ctx.triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
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,
|
||||
side,
|
||||
align,
|
||||
offset,
|
||||
});
|
||||
setPosition({ x: result.x, y: result.y });
|
||||
}, [triggerRect, contentSize, side, align, offset]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!ctx.open || !ctx.enabled) return null;
|
||||
|
||||
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||
if (Platform.OS === "web") {
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
{ maxWidth },
|
||||
style,
|
||||
{
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={ctx.open}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={() => ctx.setOpen(false)}
|
||||
>
|
||||
<View pointerEvents="box-none" style={styles.overlay}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
{ maxWidth },
|
||||
style,
|
||||
{
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: { flex: 1 },
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
zIndex: 1000,
|
||||
},
|
||||
}));
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
|
||||
@@ -569,7 +570,17 @@ function AgentScreenContent({
|
||||
title={effectiveAgent.title || "Agent"}
|
||||
rightContent={
|
||||
<View style={styles.headerRightContent}>
|
||||
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
|
||||
<HeaderToggleButton
|
||||
onPress={toggleFileExplorer}
|
||||
tooltipLabel="Toggle explorer"
|
||||
tooltipKeys={["mod", "E"]}
|
||||
tooltipSide="left"
|
||||
style={styles.menuButton}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||
accessibilityState={{ expanded: isExplorerOpen }}
|
||||
>
|
||||
{isMobile ? (
|
||||
checkout?.isGit ? (
|
||||
<GitBranch
|
||||
@@ -600,7 +611,7 @@ function AgentScreenContent({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</HeaderToggleButton>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open && agent?.cwd) {
|
||||
|
||||
16
packages/app/src/utils/format-shortcut.test.ts
Normal file
16
packages/app/src/utils/format-shortcut.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatShortcut } from "./format-shortcut";
|
||||
|
||||
describe("formatShortcut", () => {
|
||||
it("uses symbols on macOS", () => {
|
||||
expect(formatShortcut(["mod", "B"], "mac")).toBe("⌘B");
|
||||
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
|
||||
});
|
||||
|
||||
it("uses Ctrl+ on non-mac platforms", () => {
|
||||
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
|
||||
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
|
||||
});
|
||||
});
|
||||
|
||||
53
packages/app/src/utils/format-shortcut.ts
Normal file
53
packages/app/src/utils/format-shortcut.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type ShortcutKey =
|
||||
| "mod"
|
||||
| "shift"
|
||||
| "alt"
|
||||
| "ctrl"
|
||||
| "meta"
|
||||
| string;
|
||||
|
||||
export type ShortcutOs = "mac" | "non-mac";
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
if (!key) return "";
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
return key;
|
||||
}
|
||||
|
||||
export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
|
||||
const normalized = keys.map((k) => (typeof k === "string" ? k : String(k)));
|
||||
|
||||
if (os === "mac") {
|
||||
const order = ["ctrl", "alt", "shift", "mod", "meta"];
|
||||
const symbols: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
ctrl: "⌃",
|
||||
meta: "⌘",
|
||||
};
|
||||
|
||||
const modifierSet = new Set(normalized);
|
||||
const mods = order
|
||||
.filter((k) => modifierSet.has(k))
|
||||
.map((k) => symbols[k] ?? "");
|
||||
const main = normalized
|
||||
.filter((k) => !order.includes(k))
|
||||
.map(normalizeKey)
|
||||
.join("");
|
||||
return `${mods.join("")}${main}`;
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
mod: "Ctrl",
|
||||
shift: "Shift",
|
||||
alt: "Alt",
|
||||
ctrl: "Ctrl",
|
||||
meta: "Win",
|
||||
};
|
||||
return normalized
|
||||
.map((k) => labels[k] ?? normalizeKey(k))
|
||||
.filter(Boolean)
|
||||
.join("+");
|
||||
}
|
||||
|
||||
18
packages/app/src/utils/shortcut-platform.ts
Normal file
18
packages/app/src/utils/shortcut-platform.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||
|
||||
export function getShortcutOs(): ShortcutOs {
|
||||
if (Platform.OS !== "web") {
|
||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||
}
|
||||
if (getIsTauriMac()) return "mac";
|
||||
if (typeof navigator === "undefined") return "non-mac";
|
||||
const ua = navigator.userAgent ?? "";
|
||||
const platform = (navigator as any).platform ?? "";
|
||||
const isApple =
|
||||
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) ||
|
||||
/Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
return isApple ? "mac" : "non-mac";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user