feat: sortable workspace tabs, tab context menus, and terminal auto-create removal
- Add drag-and-drop tab reordering with persistent tab order - Add right-click context menu with copy agent ID, copy resume command, close to right - Switch workspace route encoding from percent-encoding to base64url - Remove terminal auto-creation on directory listing (explicit create only) - Add swipe gestures on terminal emulator for mobile sidebar navigation - Highlight active workspace row in sidebar
This commit is contained in:
@@ -9,14 +9,19 @@ import {
|
||||
type ReactElement,
|
||||
type MutableRefObject,
|
||||
} from 'react'
|
||||
import { router, useSegments } from 'expo-router'
|
||||
import { router, usePathname, useSegments } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes'
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseHostWorkspaceTerminalRouteFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
@@ -69,6 +74,7 @@ interface ProjectRowProps {
|
||||
|
||||
interface WorkspaceRowProps {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
onLongPress: () => void
|
||||
}
|
||||
@@ -195,7 +201,7 @@ function ProjectRow({
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
|
||||
function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRowProps) {
|
||||
const didLongPressRef = useRef(false)
|
||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||
|
||||
@@ -216,6 +222,7 @@ function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
selected && styles.workspaceRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
@@ -274,6 +281,7 @@ export function SidebarAgentList({
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
|
||||
const segments = useSegments()
|
||||
const pathname = usePathname()
|
||||
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
||||
@@ -283,6 +291,23 @@ export function SidebarAgentList({
|
||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder)
|
||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder)
|
||||
|
||||
const activeWorkspaceSelection = useMemo(() => {
|
||||
if (!pathname) {
|
||||
return null
|
||||
}
|
||||
const parsed =
|
||||
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
|
||||
parseHostWorkspaceTerminalRouteFromPathname(pathname) ??
|
||||
parseHostWorkspaceRouteFromPathname(pathname)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
serverId: parsed.serverId,
|
||||
workspaceId: parsed.workspaceId,
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
|
||||
@@ -421,10 +446,15 @@ export function SidebarAgentList({
|
||||
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
|
||||
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||
const isSelected =
|
||||
Boolean(serverId) &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
activeWorkspaceSelection.workspaceId === item.workspace.cwd
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
workspace={item.workspace}
|
||||
selected={isSelected}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
@@ -437,6 +467,7 @@ export function SidebarAgentList({
|
||||
)
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
collapsedProjectKeys,
|
||||
isMobile,
|
||||
onWorkspacePress,
|
||||
@@ -632,6 +663,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
workspaceRowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
|
||||
export function SortableInlineList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
}: {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
onDragEnd?: (data: T[]) => void;
|
||||
useDragHandle?: boolean;
|
||||
disabled?: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{data.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableInlineListItem key={id} item={item} index={index} renderItem={renderItem} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableInlineListItem<T>({
|
||||
item,
|
||||
index,
|
||||
renderItem,
|
||||
}: {
|
||||
item: T;
|
||||
index: number;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
}): ReactElement {
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag: () => {},
|
||||
isActive: false,
|
||||
};
|
||||
return renderItem(info);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file exists for TypeScript resolution.
|
||||
// The actual implementations are in:
|
||||
// - sortable-inline-list.native.tsx (iOS/Android)
|
||||
// - sortable-inline-list.web.tsx (Web)
|
||||
// Metro's platform-specific extensions will pick the right one at runtime.
|
||||
|
||||
export * from "./sortable-inline-list.native";
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useEffect, useState, type ReactElement } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type Modifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
|
||||
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
function SortableItem<T>({
|
||||
id,
|
||||
item,
|
||||
index,
|
||||
renderItem,
|
||||
activeId,
|
||||
useDragHandle,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
item: T;
|
||||
index: number;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
activeId: string | null;
|
||||
useDragHandle: boolean;
|
||||
disabled: boolean;
|
||||
}): ReactElement {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
|
||||
const drag = useCallback(() => {
|
||||
// dnd-kit handles drag initiation via listeners
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
const baseTransform = CSS.Transform.toString(transform);
|
||||
const scaleTransform = isDragging ? "scale(1.01)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
|
||||
const style = {
|
||||
transform: combinedTransform || undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.9 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
};
|
||||
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive: activeId === id,
|
||||
dragHandleProps: useDragHandle
|
||||
? {
|
||||
attributes: attributes as unknown as Record<string, unknown>,
|
||||
listeners: listeners as unknown as Record<string, unknown>,
|
||||
setActivatorNodeRef: setActivatorNodeRef as unknown as (
|
||||
node: unknown
|
||||
) => void,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const wrapperProps = useDragHandle
|
||||
? { ref: setNodeRef }
|
||||
: { ref: setNodeRef, ...attributes, ...listeners };
|
||||
|
||||
return (
|
||||
<div {...wrapperProps} style={style}>
|
||||
{renderItem(info)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableInlineList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
useDragHandle = false,
|
||||
disabled = false,
|
||||
activationDistance = 8,
|
||||
onDragBegin,
|
||||
}: {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
onDragEnd?: (data: T[]) => void;
|
||||
useDragHandle?: boolean;
|
||||
disabled?: boolean;
|
||||
activationDistance?: number;
|
||||
onDragBegin?: () => void;
|
||||
}): ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<T[]>(() => data);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
return;
|
||||
}
|
||||
setItems((current) => (current === data ? current : data));
|
||||
}, [activeId, data]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: activationDistance,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
setActiveId(String(event.active.id));
|
||||
onDragBegin?.();
|
||||
},
|
||||
[disabled, onDragBegin]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
setActiveId(null);
|
||||
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex(
|
||||
(item, i) => keyExtractor(item, i) === active.id
|
||||
);
|
||||
const newIndex = items.findIndex(
|
||||
(item, i) => keyExtractor(item, i) === over.id
|
||||
);
|
||||
|
||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newItems);
|
||||
onDragEnd?.(newItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, items, keyExtractor, onDragEnd]
|
||||
);
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ interface TerminalEmulatorProps {
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
cursorColor?: string;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
@@ -45,6 +48,9 @@ export default function TerminalEmulator({
|
||||
backgroundColor = "#0b0b0b",
|
||||
foregroundColor = "#e6e6e6",
|
||||
cursorColor = "#e6e6e6",
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
@@ -59,6 +65,122 @@ export default function TerminalEmulator({
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
const appliedInitialOutputRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const SWIPE_MIN_PX = 22;
|
||||
const VERTICAL_CANCEL_PX = 12;
|
||||
const HORIZONTAL_DOMINANCE_RATIO = 1.2;
|
||||
|
||||
let tracking = false;
|
||||
let activePointerId: number | null = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let fired = false;
|
||||
|
||||
const reset = () => {
|
||||
tracking = false;
|
||||
activePointerId = null;
|
||||
startX = 0;
|
||||
startY = 0;
|
||||
fired = false;
|
||||
};
|
||||
|
||||
const shouldTreatAsVertical = (dx: number, dy: number) => {
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
if (absDy < VERTICAL_CANCEL_PX) {
|
||||
return false;
|
||||
}
|
||||
return absDy > absDx;
|
||||
};
|
||||
|
||||
const shouldTreatAsHorizontal = (dx: number, dy: number) => {
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
if (absDx < SWIPE_MIN_PX) {
|
||||
return false;
|
||||
}
|
||||
if (absDy === 0) {
|
||||
return true;
|
||||
}
|
||||
return absDx / absDy >= HORIZONTAL_DOMINANCE_RATIO;
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!event.isPrimary) {
|
||||
return;
|
||||
}
|
||||
tracking = true;
|
||||
fired = false;
|
||||
activePointerId = event.pointerId;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!tracking || fired) {
|
||||
return;
|
||||
}
|
||||
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
|
||||
if (shouldTreatAsVertical(dx, dy)) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldTreatAsHorizontal(dx, dy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fired = true;
|
||||
|
||||
if (dx > 0) {
|
||||
onSwipeRight?.();
|
||||
} else {
|
||||
onSwipeLeft?.();
|
||||
}
|
||||
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
};
|
||||
|
||||
const onPointerCancel = (event: PointerEvent) => {
|
||||
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
};
|
||||
|
||||
root.addEventListener("pointerdown", onPointerDown, { passive: true });
|
||||
root.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||
root.addEventListener("pointerup", onPointerUp, { passive: true });
|
||||
root.addEventListener("pointercancel", onPointerCancel, { passive: true });
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("pointerdown", onPointerDown);
|
||||
root.removeEventListener("pointermove", onPointerMove);
|
||||
root.removeEventListener("pointerup", onPointerUp);
|
||||
root.removeEventListener("pointercancel", onPointerCancel);
|
||||
};
|
||||
}, [onSwipeLeft, onSwipeRight, swipeGesturesEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const root = rootRef.current;
|
||||
@@ -185,6 +307,7 @@ export default function TerminalEmulator({
|
||||
backgroundColor,
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
touchAction: "pan-y",
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
runtimeRef.current?.focus();
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
summarizeTerminalText,
|
||||
terminalDebugLog,
|
||||
} from "@/terminal/runtime/terminal-debug";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -147,6 +148,10 @@ export function TerminalPane({
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
||||
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: isMobile,
|
||||
@@ -1121,6 +1126,19 @@ export function TerminalPane({
|
||||
backgroundColor={theme.colors.background}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
cursorColor={theme.colors.foreground}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
onSwipeRight={() => {
|
||||
if (!swipeGesturesEnabled) {
|
||||
return;
|
||||
}
|
||||
openAgentList();
|
||||
}}
|
||||
onSwipeLeft={() => {
|
||||
if (!swipeGesturesEnabled) {
|
||||
return;
|
||||
}
|
||||
openFileExplorer();
|
||||
}}
|
||||
onInput={handleTerminalData}
|
||||
onResize={handleTerminalResize}
|
||||
onTerminalKey={handleTerminalKey}
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Text,
|
||||
View,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
|
||||
type Placement = "top" | "bottom" | "left" | "right";
|
||||
type Alignment = "start" | "center" | "end";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type 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;
|
||||
}
|
||||
|
||||
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,
|
||||
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 };
|
||||
}
|
||||
|
||||
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
|
||||
const nativeEvent: any = (event as any)?.nativeEvent ?? event;
|
||||
const pageX = nativeEvent?.pageX;
|
||||
const pageY = nativeEvent?.pageY;
|
||||
if (typeof pageX === "number" && typeof pageY === "number") {
|
||||
return { pageX, pageY };
|
||||
}
|
||||
const clientX = nativeEvent?.clientX;
|
||||
const clientY = nativeEvent?.clientY;
|
||||
if (typeof clientX === "number" && typeof clientY === "number") {
|
||||
return { pageX: clientX, pageY: clientY };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
type TriggerState = { pressed: boolean; hovered: boolean; open: boolean };
|
||||
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
|
||||
|
||||
export function ContextMenuTrigger({
|
||||
children,
|
||||
disabled,
|
||||
style,
|
||||
enabled = true,
|
||||
enabledOnMobile = false,
|
||||
enabledOnWeb = true,
|
||||
longPressDelayMs,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
style?: TriggerStyleProp;
|
||||
enabled?: boolean;
|
||||
enabledOnMobile?: boolean;
|
||||
enabledOnWeb?: boolean;
|
||||
longPressDelayMs?: number;
|
||||
}
|
||||
>): ReactElement {
|
||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||
|
||||
const shouldEnableOnThisPlatform =
|
||||
enabled &&
|
||||
(Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
|
||||
|
||||
const openAtEvent = useCallback(
|
||||
(event: unknown) => {
|
||||
if (!shouldEnableOnThisPlatform || disabled) {
|
||||
return;
|
||||
}
|
||||
const point = coerceEventPoint(event);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
ctx.setAnchorRect({
|
||||
x: point.pageX,
|
||||
y: point.pageY + statusBarHeight,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
ctx.setOpen(true);
|
||||
},
|
||||
[ctx, disabled, shouldEnableOnThisPlatform]
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
ref={ctx.triggerRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
delayLongPress={longPressDelayMs}
|
||||
onLongPress={(event) => {
|
||||
if (Platform.OS === "web") {
|
||||
props.onLongPress?.(event);
|
||||
return;
|
||||
}
|
||||
openAtEvent(event);
|
||||
props.onLongPress?.(event);
|
||||
}}
|
||||
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
||||
onContextMenu={(event: unknown) => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
const e: any = event;
|
||||
e?.preventDefault?.();
|
||||
e?.stopPropagation?.();
|
||||
openAtEvent(event);
|
||||
}}
|
||||
style={({ pressed, hovered = false }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||
}
|
||||
return style;
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuContent({
|
||||
children,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
offset = 4,
|
||||
width,
|
||||
minWidth = 180,
|
||||
maxWidth,
|
||||
fullWidth = false,
|
||||
horizontalPadding = 16,
|
||||
testID,
|
||||
}: PropsWithChildren<{
|
||||
side?: Placement;
|
||||
align?: Alignment;
|
||||
offset?: number;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
fullWidth?: boolean;
|
||||
horizontalPadding?: number;
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const { open, setOpen, triggerRef, anchorRect } = useContextMenuContext("ContextMenuContent");
|
||||
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]);
|
||||
|
||||
// Measure trigger when opening (fallback) and capture point anchors.
|
||||
useEffect(() => {
|
||||
if (!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;
|
||||
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({
|
||||
...rect,
|
||||
y: rect.y + statusBarHeight,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [anchorRect, open, triggerRef]);
|
||||
|
||||
// Calculate position when we have both measurements
|
||||
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,
|
||||
placement: side,
|
||||
alignment: align,
|
||||
offset,
|
||||
});
|
||||
|
||||
const x = fullWidth ? horizontalPadding : result.x;
|
||||
setPosition({ x, y: result.y });
|
||||
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
|
||||
|
||||
const handleContentLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width: w, height: h } = event.nativeEvent.layout;
|
||||
setContentSize({ width: w, height: h });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Menu backdrop"
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleContentLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuLabel({
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
|
||||
return (
|
||||
<View style={[styles.labelContainer, style]} testID={testID}>
|
||||
<Text style={styles.labelText}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
|
||||
return <View style={[styles.separator, style]} 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
children,
|
||||
description,
|
||||
onSelect,
|
||||
disabled,
|
||||
destructive,
|
||||
selected,
|
||||
showSelectedCheck = false,
|
||||
selectedVariant = "default",
|
||||
leading,
|
||||
trailing,
|
||||
loading,
|
||||
status,
|
||||
pendingLabel,
|
||||
successLabel,
|
||||
closeOnSelect = true,
|
||||
testID,
|
||||
}: 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;
|
||||
}>): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { setOpen } = useContextMenuContext("ContextMenuItem");
|
||||
|
||||
const isPending = status === "pending" || loading;
|
||||
const isSuccess = status === "success";
|
||||
const isDisabled = disabled || isPending || isSuccess;
|
||||
|
||||
let leadingContent: ReactElement | null = null;
|
||||
if (isPending) {
|
||||
leadingContent = <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
|
||||
} else if (isSuccess) {
|
||||
leadingContent = <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
|
||||
} else if (leading) {
|
||||
leadingContent = leading;
|
||||
}
|
||||
|
||||
let label = children;
|
||||
if (isPending && pendingLabel) {
|
||||
label = pendingLabel;
|
||||
} else if (isSuccess && successLabel) {
|
||||
label = successLabel;
|
||||
}
|
||||
|
||||
const trailingContent =
|
||||
trailing ?? (!showSelectedCheck && selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
disabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isDisabled) return;
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
onSelect?.();
|
||||
}}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.item,
|
||||
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
|
||||
selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
]}
|
||||
>
|
||||
{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={[
|
||||
styles.itemText,
|
||||
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
||||
isSuccess ? styles.itemTextSuccess : null,
|
||||
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{description && !isPending && !isSuccess ? (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
styles.itemDescription,
|
||||
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
|
||||
]}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
overflow: "hidden",
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user