From df16117e76e90c63bd10f10c9aff99929df569b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sat, 4 Jul 2026 16:21:52 +0200 Subject: [PATCH 1/8] Keep native context menus open on long press and expose isDragging --- .../providers/shared/CommonValuesProvider.ts | 2 + .../src/providers/shared/DragProvider.ts | 58 +++++--- .../providers/shared/ItemContextProvider.ts | 7 + .../shared/hooks/useItemZIndex.test.ts | 130 ++++++++++++++++++ .../providers/shared/hooks/useItemZIndex.ts | 16 ++- .../src/types/providers/shared.ts | 6 + 6 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.test.ts diff --git a/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts b/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts index 35e26e9d..e1327894 100644 --- a/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts @@ -100,6 +100,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } = const activeAnimationProgress = useMutableValue(0); const inactiveAnimationProgress = useMutableValue(0); const activeItemDropped = useMutableValue(true); + const activeItemBroughtToFront = useMutableValue(false); // ITEM ACTIVATION SETTINGS const dragActivationDelay = useAnimatableValue(_dragActivationDelay); @@ -148,6 +149,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } = activationAnimationDuration, activationState, activeAnimationProgress, + activeItemBroughtToFront, activeItemDimensions, activeItemDropped, activeItemKey, diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index a8c1702e..9b527ca3 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -81,6 +81,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< activationAnimationDuration, activationState, activeAnimationProgress, + activeItemBroughtToFront, activeItemDimensions, activeItemDropped, activeItemKey, @@ -289,6 +290,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< activeItemPosition.value = itemPosition; activeItemDimensions.value = itemDimensions; activationState.value = DragActivationState.ACTIVE; + activeItemBroughtToFront.value = false; ctx.dragStartIndex = keyToIndex.value[key] ?? -1; if (activeContainerId) { @@ -298,7 +300,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< multiZoneActiveItemDimensions.value = itemDimensions; } - updateLayer?.(LayerState.FOCUSED); + // zIndex is elevated later, on the first drag move (see handleTouchesMove) // We need to update the custom handle measurements if the custom handle // is used (touch position is relative to the handle in this case) @@ -349,6 +351,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< activeAnimationProgress, activeContainerId, activeHandleOffset, + activeItemBroughtToFront, activeItemDimensions, activeItemDropped, activationState, @@ -366,7 +369,6 @@ const { DragProvider, useDragContext } = createProvider('Drag')< prevActiveItemKey, onDragStart, touchPosition, - updateLayer, updateActiveHandleMeasurements ] ); @@ -457,6 +459,14 @@ const { DragProvider, useDragContext } = createProvider('Drag')< } const ctx = context.value; + const startTouch = ctx.touchStartTouch; + + let movedDistance = 0; + if (startTouch) { + const dX = touch.absoluteX - startTouch.absoluteX; + const dY = touch.absoluteY - startTouch.absoluteY; + movedDistance = Math.sqrt(dX * dX + dY * dY); + } if (activeItemKey.value) { onDragMove({ @@ -464,25 +474,20 @@ const { DragProvider, useDragContext } = createProvider('Drag')< key: activeItemKey.value, touchData: touch }); - } - if (activationState.value === DragActivationState.TOUCHED) { - if (!ctx.touchStartTouch) { - fail(); - return; - } - const dX = touch.absoluteX - ctx.touchStartTouch.absoluteX; - const dY = touch.absoluteY - ctx.touchStartTouch.absoluteY; - - // Cancel touch if the touch moved too far from the initial position - // before the item activation animation starts - const r = Math.sqrt(dX * dX + dY * dY); + // Bring the item to the front only once it is actually dragged, so a + // long press alone (e.g. to open a native context menu) doesn't change + // the stacking context and dismiss the menu. if ( - // activeItemKey is set after the drag activation delay passes - // and we don't want to cancel the touch anymore after this time - activeItemKey.value === null && - r >= dragActivationFailOffset.value + !activeItemBroughtToFront.value && + movedDistance >= dragActivationFailOffset.value ) { + activeItemBroughtToFront.value = true; + updateLayer?.(LayerState.FOCUSED); + } + } else if (activationState.value === DragActivationState.TOUCHED) { + // Cancel the touch if it moves before activation (it is a scroll) + if (!startTouch || movedDistance >= dragActivationFailOffset.value) { fail(); return; } @@ -490,11 +495,13 @@ const { DragProvider, useDragContext } = createProvider('Drag')< }, [ activationState, + activeItemBroughtToFront, activeItemKey, dragActivationFailOffset, currentTouch, context, - onDragMove + onDragMove, + updateLayer ] ); @@ -549,7 +556,13 @@ const { DragProvider, useDragContext } = createProvider('Drag')< inactiveAnimationProgress.value = animate(); activeAnimationProgress.value = animate(); - updateLayer?.(LayerState.INTERMEDIATE); + // Restore the layer zIndex only if it was elevated. The flag is left as + // is and reset on the next handleDragStart - resetting it here would race + // with a new overlapping drag and drop its item below its siblings. + const wasBroughtToFront = activeItemBroughtToFront.value; + if (wasBroughtToFront) { + updateLayer?.(LayerState.INTERMEDIATE); + } haptics.medium(); stableOnDragEnd({ @@ -562,7 +575,9 @@ const { DragProvider, useDragContext } = createProvider('Drag')< setAnimatedTimeout(() => { activeItemDropped.value = true; - updateLayer?.(LayerState.IDLE); + if (wasBroughtToFront) { + updateLayer?.(LayerState.IDLE); + } onActiveItemDropped({ fromIndex, indexToKey: indexToKey.value, @@ -574,6 +589,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< }, [ activeContainerId, + activeItemBroughtToFront, activeItemKey, activeItemDimensions, activeItemDropped, diff --git a/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts b/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts index 732164bf..d50e4d80 100644 --- a/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts @@ -1,4 +1,5 @@ import type { PropsWithChildren } from 'react'; +import { useDerivedValue } from 'react-native-reanimated'; import type { ItemContextType } from '../../types'; import { createProvider } from '../utils'; @@ -20,12 +21,17 @@ const { ItemContextProvider, useItemContextContext: useItemContext } = >(({ activationAnimationProgress, gesture, isActive, itemKey }) => { const { activationState, + activeItemBroughtToFront, activeItemKey, indexToKey, keyToIndex, prevActiveItemKey } = useCommonValuesContext(); + const isDragging = useDerivedValue( + () => isActive.value && activeItemBroughtToFront.value + ); + return { value: { activationAnimationProgress, @@ -34,6 +40,7 @@ const { ItemContextProvider, useItemContextContext: useItemContext } = gesture, indexToKey, isActive, + isDragging, itemKey, keyToIndex, prevActiveItemKey diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.test.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.test.ts new file mode 100644 index 00000000..b92ab706 --- /dev/null +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.test.ts @@ -0,0 +1,130 @@ +import { renderHook } from '@testing-library/react-hooks'; +import type { SharedValue } from 'react-native-reanimated'; +import { makeMutable } from 'react-native-reanimated'; + +import { useCommonValuesContext } from '../CommonValuesProvider'; +import useItemZIndex from './useItemZIndex'; + +jest.mock('../CommonValuesProvider', () => ({ + useCommonValuesContext: jest.fn() +})); + +const mockedUseCommonValuesContext = useCommonValuesContext as jest.Mock; + +type ContextOverrides = { + activeItemBroughtToFront?: boolean; + activeItemKey?: null | string; + prevActiveItemKey?: null | string; + isStackingOrderDesc?: boolean; + indexToKey?: Array; +}; + +function setupContext({ + activeItemBroughtToFront = false, + activeItemKey = null, + indexToKey = ['a', 'b', 'c'], + isStackingOrderDesc = false, + prevActiveItemKey = null +}: ContextOverrides) { + const keyToIndex = Object.fromEntries(indexToKey.map((key, i) => [key, i])); + mockedUseCommonValuesContext.mockReturnValue({ + activeItemBroughtToFront: makeMutable(activeItemBroughtToFront), + activeItemKey: makeMutable(activeItemKey), + indexToKey: makeMutable(indexToKey), + isStackingOrderDesc, + keyToIndex: makeMutable(keyToIndex), + prevActiveItemKey: makeMutable(prevActiveItemKey) + }); +} + +function renderZIndex(key: string, activationProgress: number): number { + const progress = makeMutable(activationProgress); + const { result } = renderHook(() => useItemZIndex(key, progress)) as { + result: { current: SharedValue }; + }; + return result.current.value; +} + +// itemCount = 3 in all cases below + +describe(useItemZIndex, () => { + describe('when the active item has NOT been brought to front (long press)', () => { + it('keeps the active item at its plain order zIndex', () => { + setupContext({ + activeItemBroughtToFront: false, + activeItemKey: 'a' + }); + // active + activation running, but not brought to front => order zIndex + expect(renderZIndex('a', 1)).toBe(0); + }); + + it('keeps inactive items at their plain order zIndex', () => { + setupContext({ + activeItemBroughtToFront: false, + activeItemKey: 'a' + }); + expect(renderZIndex('b', 1)).toBe(1); + expect(renderZIndex('c', 1)).toBe(2); + }); + + it('respects descending stacking order', () => { + setupContext({ + activeItemBroughtToFront: false, + activeItemKey: 'a', + isStackingOrderDesc: true + }); + // desc order zIndex for index 0 of 3 items => 3 - 0 - 1 = 2 + expect(renderZIndex('a', 1)).toBe(2); + }); + }); + + describe('when the active item HAS been brought to front (real drag)', () => { + it('elevates the active item above everything', () => { + setupContext({ + activeItemBroughtToFront: true, + activeItemKey: 'a' + }); + // 2 * itemCount + 1 = 7 + expect(renderZIndex('a', 1)).toBe(7); + }); + + it('lifts inactive items by itemCount while the activation runs', () => { + setupContext({ + activeItemBroughtToFront: true, + activeItemKey: 'a' + }); + // itemCount + orderZIndex + expect(renderZIndex('b', 1)).toBe(4); + expect(renderZIndex('c', 1)).toBe(5); + }); + + it('keeps the previously active item above siblings during the drop', () => { + setupContext({ + activeItemBroughtToFront: true, + activeItemKey: null, + prevActiveItemKey: 'a' + }); + // 2 * itemCount = 6 for the dropping item + expect(renderZIndex('a', 0.5)).toBe(6); + // other items are lifted by itemCount while the drop animates + expect(renderZIndex('b', 0.5)).toBe(4); + }); + }); + + describe('at rest (no active item, animation finished)', () => { + it.each([false, true])( + 'returns the plain order zIndex (broughtToFront=%s)', + broughtToFront => { + setupContext({ + activeItemBroughtToFront: broughtToFront, + activeItemKey: null, + prevActiveItemKey: 'a' + }); + // a stale broughtToFront=true is harmless at rest + expect(renderZIndex('a', 0)).toBe(0); + expect(renderZIndex('b', 0)).toBe(1); + expect(renderZIndex('c', 0)).toBe(2); + } + ); + }); +}); diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts index fc411a13..6870ac00 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts @@ -8,6 +8,7 @@ export default function useItemZIndex( activationAnimationProgress: SharedValue ): SharedValue { const { + activeItemBroughtToFront, activeItemKey, indexToKey, isStackingOrderDesc, @@ -17,16 +18,21 @@ export default function useItemZIndex( return useDerivedValue(() => { const itemCount = indexToKey.value.length; - - if (activeItemKey.value === key) { - return 2 * itemCount + 1; - } - const realIndex = keyToIndex.value[key] ?? 0; const orderZIndex = isStackingOrderDesc ? itemCount - realIndex - 1 : realIndex; + // Keep the order zIndex until the item is actually dragged. See + // https://github.com/MatiPl01/react-native-sortables/issues/417 + if (!activeItemBroughtToFront.value) { + return orderZIndex; + } + + if (activeItemKey.value === key) { + return 2 * itemCount + 1; + } + if (activationAnimationProgress.value > 0) { if (prevActiveItemKey.value === key) { return 2 * itemCount; diff --git a/packages/react-native-sortables/src/types/providers/shared.ts b/packages/react-native-sortables/src/types/providers/shared.ts index 5c4bcc9a..c08ecddc 100644 --- a/packages/react-native-sortables/src/types/providers/shared.ts +++ b/packages/react-native-sortables/src/types/providers/shared.ts @@ -87,6 +87,8 @@ export type CommonValuesContextType = activeAnimationProgress: SharedValue; inactiveAnimationProgress: SharedValue; activeItemDropped: SharedValue; + // True once the active item is dragged (not just long pressed) + activeItemBroughtToFront: SharedValue; // OTHER containerRef: AnimatedRef; @@ -154,6 +156,10 @@ export type ItemContextType = Simplify< Pick & { itemKey: string; isActive: SharedValue; + // True once the active item is actually dragged (moved past the drag + // fail offset), as opposed to just long pressed. Use it to disable + // interactive children (e.g. a native context menu) while dragging. + isDragging: SharedValue; activationAnimationProgress: SharedValue; } > & { From 40add4de4aa68a809162d6df4d55928f7f508049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sat, 4 Jul 2026 16:22:11 +0200 Subject: [PATCH 2/8] Add sortable grid context menu example --- example/app/package.json | 6 +- .../features/ContextMenuExample.tsx | 84 +++ .../examples/SortableGrid/features/index.ts | 1 + example/app/src/examples/navigation/routes.ts | 4 + yarn.lock | 678 +++++++++++++++++- 5 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx diff --git a/example/app/package.json b/example/app/package.json index a48096da..09caf5a9 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -5,6 +5,7 @@ "@fortawesome/free-solid-svg-icons": "6.5.2", "@fortawesome/react-native-fontawesome": "0.3.2", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-menu/menu": "2.0.0", "@react-navigation/bottom-tabs": "7.3.10", "@react-navigation/native": "7.0.14", "@react-navigation/native-stack": "7.2.0", @@ -13,12 +14,15 @@ "react-native": "0.86.0", "react-native-gesture-handler": "2.32.0", "react-native-haptic-feedback": "2.3.3", + "react-native-ios-context-menu": "3.1.0", + "react-native-ios-utilities": "5.1.2", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "5.7.0", "react-native-screens": "4.25.2", "react-native-sortables": "workspace:*", "react-native-svg": "15.15.4", - "react-native-worklets": "0.10.1" + "react-native-worklets": "0.10.1", + "zeego": "3.0.6" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx new file mode 100644 index 00000000..4f160d8f --- /dev/null +++ b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState } from 'react'; +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; +import type { SortableGridRenderItem } from 'react-native-sortables'; +import Sortable, { useItemContext } from 'react-native-sortables'; +import * as ContextMenu from 'zeego/context-menu'; + +import { GridCard, ScrollScreen, Section, Stagger } from '@/components'; +import { spacing } from '@/theme'; +import { getItems } from '@/utils'; + +const DATA = getItems(6); +const COLUMNS = 2; + +// The native menu appears on the OS long-press timeout (~500ms) and can't be +// changed, so we tune the sortable side instead: it claims the gesture a bit +// sooner and treats even a small move as a drag, so `isDragging` flips - and the +// menu is dismissed - as soon as the item starts moving. Lower both to make the +// sortable win the long press more eagerly. +const DRAG_ACTIVATION_DELAY = 120; // ms until the item can be dragged (default 200) +const DRAG_FAIL_OFFSET = 3; // px of movement that counts as a drag (default 5) + +function MenuCard({ item }: { item: string }) { + const { isDragging } = useItemContext(); + const [dragging, setDragging] = useState(false); + + useAnimatedReaction( + () => isDragging.value, + (value, prev) => { + if (value !== prev) { + runOnJS(setDragging)(value); + } + } + ); + + // Once the item is actually dragged, drop the context menu so it can't open + // (and it closes if it was already open) - the sortable owns the gesture from + // that point on. On a stationary long press the menu stays available. + if (dragging) { + return {item}; + } + + return ( + + + {item} + + + + Edit + + + Delete + + + + ); +} + +export default function ContextMenuExample() { + const renderItem = useCallback>( + ({ item }) => , + [] + ); + + return ( + + +
+ +
+
+
+ ); +} diff --git a/example/app/src/examples/SortableGrid/features/index.ts b/example/app/src/examples/SortableGrid/features/index.ts index c06de529..1f75540f 100644 --- a/example/app/src/examples/SortableGrid/features/index.ts +++ b/example/app/src/examples/SortableGrid/features/index.ts @@ -1,5 +1,6 @@ export { default as AutoScrollExample } from './AutoScrollExample'; export { default as CallbacksExample } from './CallbacksExample'; +export { default as ContextMenuExample } from './ContextMenuExample'; export { default as DataChangeExample } from './DataChangeExample'; export { default as DebugExample } from './DebugExample'; export { default as DifferentSizeItems } from './DifferentSizeItems'; diff --git a/example/app/src/examples/navigation/routes.ts b/example/app/src/examples/navigation/routes.ts index ee52357d..296b87fa 100644 --- a/example/app/src/examples/navigation/routes.ts +++ b/example/app/src/examples/navigation/routes.ts @@ -27,6 +27,10 @@ const routes: Routes = { Component: SortableGrid.features.DragHandleExample, name: 'Drag Handle' }, + ContextMenu: { + Component: SortableGrid.features.ContextMenuExample, + name: 'Context Menu' + }, DataChange: { Component: SortableGrid.features.DataChangeExample, name: 'Data Change' diff --git a/yarn.lock b/yarn.lock index e887683b..0c3b1e99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1975,6 +1975,13 @@ __metadata: languageName: node linkType: hard +"@dominicstop/ts-event-emitter@npm:^1.1.0": + version: 1.1.0 + resolution: "@dominicstop/ts-event-emitter@npm:1.1.0" + checksum: 10c0/fe16c03132736a0a798c232636ab6b127fd8063509511ccafbd99405c224a106cfb7a8d4af13c4f6f412ec0ea3baae3678aa2c8c09d81c9985c423d476404e1a + languageName: node + linkType: hard + "@effect/schema@npm:0.69.0": version: 0.69.0 resolution: "@effect/schema@npm:0.69.0" @@ -2641,6 +2648,44 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.7.5": + version: 1.7.5 + resolution: "@floating-ui/core@npm:1.7.5" + dependencies: + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/f9c52205e198b231d63a387b09c659aab08c46a1899e0b0bbe147b8b4f048b546f15ba17cb5d2a471da9534f1883d979425e13e5c4ceee67be63e4b0abd4db5d + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.7.6": + version: 1.7.6 + resolution: "@floating-ui/dom@npm:1.7.6" + dependencies: + "@floating-ui/core": "npm:^1.7.5" + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/5c098e0d7b58c9bc769f276cca1766994c2c9c70c92d091a61bba8b3e9be53c011e0a79a8457fc2fb2f3d91697a26eb52e0a4962ef936dc963b45f58613c212f + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.0.0": + version: 2.1.8 + resolution: "@floating-ui/react-dom@npm:2.1.8" + dependencies: + "@floating-ui/dom": "npm:^1.7.6" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/26260ca4bb23b57c73b824062505abf977a008ce6e0463bdacca74f7e49853c4cd1d2bbf1a77c6caa17fa37dfffda2c6c4cd07a8737ebd7474aaff7818401d75 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.11": + version: 0.2.11 + resolution: "@floating-ui/utils@npm:0.2.11" + checksum: 10c0/f4bcea1559bdbb721ecc8e8ead423ac58d6a5b6e70b602cf0810ba6ad4ed1c77211b207faa88b278a9042f0c743133de08a203ed6741c1b6443423332884d5b3 + languageName: node + linkType: hard + "@fortawesome/fontawesome-common-types@npm:6.5.2": version: 6.5.2 resolution: "@fortawesome/fontawesome-common-types@npm:6.5.2" @@ -3604,6 +3649,471 @@ __metadata: languageName: node linkType: hard +"@radix-ui/primitive@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/primitive@npm:1.1.4" + checksum: 10c0/f366fa15b721a466ad78f9b5b966f7129fb6932f6f33ab117253ce8c6a13f4895dce4a1fd483d3f15859623d3bfd964a4b172fe1d6ccc3a1a3c4de4c2b861119 + languageName: node + linkType: hard + +"@radix-ui/react-arrow@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-arrow@npm:1.1.11" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.7" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/d8db1fc66022057fbdb02f4a20c747706f9b9dc7e6966bd83a0c7d1aa81f27cbcfa48deb9bd86c2714f16d9c813868240bb584473e3344beb436ce4e03f6bbaf + languageName: node + linkType: hard + +"@radix-ui/react-collection@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-collection@npm:1.1.11" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.3" + "@radix-ui/react-context": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-slot": "npm:1.3.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/529325b57e5ec347c91d19e23e94bb0a78d01f8cf1f4f74bfc7683467656bb05f82b60e51937e7b17c89d58a129170e6825ce383dfc8408a1de9ae120cbdfe53 + languageName: node + linkType: hard + +"@radix-ui/react-compose-refs@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/react-compose-refs@npm:1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/c4853e7bc15cda6f68a1bbd7910412cc7f298419ee363fb4e40494e16a1cfadc857b2384dbe4d98b58c1312f280ca3474f67dc14da325d29ae8b7ebcfddaf743 + languageName: node + linkType: hard + +"@radix-ui/react-context-menu@npm:^2.0.1": + version: 2.3.2 + resolution: "@radix-ui/react-context-menu@npm:2.3.2" + dependencies: + "@radix-ui/primitive": "npm:1.1.4" + "@radix-ui/react-context": "npm:1.1.4" + "@radix-ui/react-menu": "npm:2.1.19" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-use-controllable-state": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/ae423e564cdf03412e89ea4fb9b50c3b15a7b5048908b431ff3787744abb4f6ea72c9053c48ef36cb5cfeac10e6731da64d9170b2d18ef308d7b8762f0669cc0 + languageName: node + linkType: hard + +"@radix-ui/react-context@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-context@npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/917be4dcb9fd9f465ab18f6982879daf83a5ca298a22504fa3bc4abd8dea963a57abb9ad38c099dd756ba2cddff0edde680bf8369012f44dedede20912702c8f + languageName: node + linkType: hard + +"@radix-ui/react-direction@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-direction@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/e8172f8598b5bddaf82bdc2e16e9561d0d89eaf85ef3cd1fe2506a1564398ee6cb07b22fd7d0ac892e733d4f6247b1e328c4b3680141d22a04e262f14ff11230 + languageName: node + linkType: hard + +"@radix-ui/react-dismissable-layer@npm:1.1.14": + version: 1.1.14 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.14" + dependencies: + "@radix-ui/primitive": "npm:1.1.4" + "@radix-ui/react-compose-refs": "npm:1.1.3" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-use-callback-ref": "npm:1.1.2" + "@radix-ui/react-use-effect-event": "npm:0.0.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/371ec716e2029c503739a9efe2f85afca8d158474023a670fc546a1104824c5d78fcaba1484c652832fa006050fde2a8b52c851cd41d02cf8d564d8937aec027 + languageName: node + linkType: hard + +"@radix-ui/react-dropdown-menu@npm:^2.0.1": + version: 2.1.19 + resolution: "@radix-ui/react-dropdown-menu@npm:2.1.19" + dependencies: + "@radix-ui/primitive": "npm:1.1.4" + "@radix-ui/react-compose-refs": "npm:1.1.3" + "@radix-ui/react-context": "npm:1.1.4" + "@radix-ui/react-id": "npm:1.1.2" + "@radix-ui/react-menu": "npm:2.1.19" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-use-controllable-state": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/901a290f3542163403dd6d5439cc43c37ee8e8b3949e3b017784660f6c5bf477165d9fb5f483f52fd5914d72aa84275778312502257ce045b8c6fd553f758234 + languageName: node + linkType: hard + +"@radix-ui/react-focus-guards@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-focus-guards@npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/0447a97a2672ad04fa73e0af3a09adafa1e85327c43cec2ae7267daa8544c9e6ef3136fbadcbdd3e28bf1ff0864537241c159e26cf5800b7698e5e69772e7ed3 + languageName: node + linkType: hard + +"@radix-ui/react-focus-scope@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-focus-scope@npm:1.1.11" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.3" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-use-callback-ref": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/e773dfbe4c3907067db4d96082bf9a4dad730ad7396dc181f09fc79e054c4a7382dee6a3b948efef97904ae0387dc81b7733e479af69f5a22de2649c99847561 + languageName: node + linkType: hard + +"@radix-ui/react-id@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-id@npm:1.1.2" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/cd8638f0e259b9cee26cc8248b96533927abf91302a9164d674a45119a15dbb6929652e450c18bc3b99adf5d02a698152bf45d3ae052e1cd63f68fe8dd1ca87d + languageName: node + linkType: hard + +"@radix-ui/react-menu@npm:2.1.19": + version: 2.1.19 + resolution: "@radix-ui/react-menu@npm:2.1.19" + dependencies: + "@radix-ui/primitive": "npm:1.1.4" + "@radix-ui/react-collection": "npm:1.1.11" + "@radix-ui/react-compose-refs": "npm:1.1.3" + "@radix-ui/react-context": "npm:1.1.4" + "@radix-ui/react-direction": "npm:1.1.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.14" + "@radix-ui/react-focus-guards": "npm:1.1.4" + "@radix-ui/react-focus-scope": "npm:1.1.11" + "@radix-ui/react-id": "npm:1.1.2" + "@radix-ui/react-popper": "npm:1.3.2" + "@radix-ui/react-portal": "npm:1.1.13" + "@radix-ui/react-presence": "npm:1.1.6" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-roving-focus": "npm:1.1.14" + "@radix-ui/react-slot": "npm:1.3.0" + "@radix-ui/react-use-callback-ref": "npm:1.1.2" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.7.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/25a625a2d0e08b60ce29c015f19c582e2729f25321c07b03b9b2ffe0a538628fbea50353f9d447077c3c224b09c21cda64d6e24e8cbbdb52d041b5cd6b0253ca + languageName: node + linkType: hard + +"@radix-ui/react-popper@npm:1.3.2": + version: 1.3.2 + resolution: "@radix-ui/react-popper@npm:1.3.2" + dependencies: + "@floating-ui/react-dom": "npm:^2.0.0" + "@radix-ui/react-arrow": "npm:1.1.11" + "@radix-ui/react-compose-refs": "npm:1.1.3" + "@radix-ui/react-context": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-use-callback-ref": "npm:1.1.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.2" + "@radix-ui/react-use-rect": "npm:1.1.2" + "@radix-ui/react-use-size": "npm:1.1.2" + "@radix-ui/rect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/84d6ed24c0fe36acecd09b7b8c3343eaa2bdc7500cf28dec18c8236d4ebb12d7df48df2b866232bd035ccbc42be57fff1144454ae9cab6a6a3a75b073e9d022d + languageName: node + linkType: hard + +"@radix-ui/react-portal@npm:1.1.13": + version: 1.1.13 + resolution: "@radix-ui/react-portal@npm:1.1.13" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-use-layout-effect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/3a158e4e9c4360dec0ff4303be2d3eead0c4438c8c240194a6116901cd8fcf98e22bcd65f33caaa778556cc434a5048b16a8deea82b60eb8f487f36f67fecf06 + languageName: node + linkType: hard + +"@radix-ui/react-presence@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-presence@npm:1.1.6" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/8b96ba32eae20162005f7e818b07e1e6e351da03f4753a79403ec58d27c4965e881a506960283fd7ded5b8ecf5ccb6a58bbc1d6799f5254a6cd4e5ae8837e345 + languageName: node + linkType: hard + +"@radix-ui/react-primitive@npm:2.1.7": + version: 2.1.7 + resolution: "@radix-ui/react-primitive@npm:2.1.7" + dependencies: + "@radix-ui/react-slot": "npm:1.3.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/0cb8711df46447966f32752ca10237a635a5207e75e73f716cc740b418aa4a1089fce342c9741e3c727274468dea205cee3b8a4433a80100bfdb624b7ba4ade6 + languageName: node + linkType: hard + +"@radix-ui/react-roving-focus@npm:1.1.14": + version: 1.1.14 + resolution: "@radix-ui/react-roving-focus@npm:1.1.14" + dependencies: + "@radix-ui/primitive": "npm:1.1.4" + "@radix-ui/react-collection": "npm:1.1.11" + "@radix-ui/react-compose-refs": "npm:1.1.3" + "@radix-ui/react-context": "npm:1.1.4" + "@radix-ui/react-direction": "npm:1.1.2" + "@radix-ui/react-id": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.7" + "@radix-ui/react-use-callback-ref": "npm:1.1.2" + "@radix-ui/react-use-controllable-state": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/76be4ec8ba0c86b6115be02d2b5377af78f2871a323199887f1746ae5e7937656e9e4a79ceff08f4ee0984a222de5b343444d989e424ea56cf1a2a52cd8a6969 + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:1.3.0": + version: 1.3.0 + resolution: "@radix-ui/react-slot@npm:1.3.0" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/7353520950dcbc5c331f79aeac5bc8efa5130014e8121f85b337996ec5a2cef89f1375ff879b72ba05182de93c1126b22ec328f171edd7a2888c1532d10f74b5 + languageName: node + linkType: hard + +"@radix-ui/react-use-callback-ref@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-use-callback-ref@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/aa43df8cf54b410f2b0fff817247cee5e4d4560b86d9bff7c17c19441726163c28f09f908e154607e5e6d0a055538c768381b723c9f5d1019807546f352b4cc1 + languageName: node + linkType: hard + +"@radix-ui/react-use-controllable-state@npm:1.2.3": + version: 1.2.3 + resolution: "@radix-ui/react-use-controllable-state@npm:1.2.3" + dependencies: + "@radix-ui/react-use-effect-event": "npm:0.0.3" + "@radix-ui/react-use-layout-effect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/8c551263a2753cebc1d60019d8beb307d20cbb1b9847887a9eed13db5392672c6d5179b7f46a2cbd34a64bda702617670012ebcd32cffeb22f6645fcf5345ee8 + languageName: node + linkType: hard + +"@radix-ui/react-use-effect-event@npm:0.0.3": + version: 0.0.3 + resolution: "@radix-ui/react-use-effect-event@npm:0.0.3" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/8808fab0b26e30da9b50070a426b3ac135132c360b23a2a5de331bd06d8b164b10cd5561573dd17530172ba67dde428057fbf9a1f5fae2cba3cc66f118e72406 + languageName: node + linkType: hard + +"@radix-ui/react-use-layout-effect@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-use-layout-effect@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/c7fc76744a4d37b5010eac33eda6439db2ac4d657e2bb5f596236e60aa4384ac88a7b2e3ebc1c88312c5c536a40081cc85ae6d70c0bba4bb8702d302b8ff07f7 + languageName: node + linkType: hard + +"@radix-ui/react-use-rect@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-use-rect@npm:1.1.2" + dependencies: + "@radix-ui/rect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/eafaa88ea33bfb3761a81a3badb73ecf99c613aa7c10e53dac08fee3457d21f4dfd1e65b5a47e7925e4aa1da60f2793404183353d4d21e85aae4009c76de64d4 + languageName: node + linkType: hard + +"@radix-ui/react-use-size@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-use-size@npm:1.1.2" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/0a088412bb831fd1d59f9058352aca384c2d0a07b894b35707b3486eea4a1d8a37a04fb117379f9a8a46a921b0092f97b29b517689c8f97029ec73eb68973521 + languageName: node + linkType: hard + +"@radix-ui/rect@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/rect@npm:1.1.2" + checksum: 10c0/304d648c4b6786755a10eabf2327f40b7c6303bb588174ab6194a5bb032c195c51f3e43395dee1dd37239fa68b63558092f2bc8ce5a14962d5e4303afb0a17ca + languageName: node + linkType: hard + "@react-native-async-storage/async-storage@npm:2.2.0": version: 2.2.0 resolution: "@react-native-async-storage/async-storage@npm:2.2.0" @@ -3793,6 +4303,16 @@ __metadata: languageName: node linkType: hard +"@react-native-menu/menu@npm:2.0.0": + version: 2.0.0 + resolution: "@react-native-menu/menu@npm:2.0.0" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/1e88e1892730c70e28a654a8df6427f80f1b88880eaa62f2744072b9cebead4e834490d15ef7f0b91eadf9cb1ec9241b29f116ce7e94661685b41faf10af3fa3 + languageName: node + linkType: hard + "@react-native/assets-registry@npm:0.81.4": version: 0.81.4 resolution: "@react-native/assets-registry@npm:0.81.4" @@ -5577,6 +6097,15 @@ __metadata: languageName: node linkType: hard +"aria-hidden@npm:^1.2.4": + version: 1.2.6 + resolution: "aria-hidden@npm:1.2.6" + dependencies: + tslib: "npm:^2.0.0" + checksum: 10c0/7720cb539497a9f760f68f98a4b30f22c6767aa0e72fa7d58279f7c164e258fc38b2699828f8de881aab0fc8e9c56d1313a3f1a965046fc0381a554dbc72b54a + languageName: node + linkType: hard + "arktype@npm:^2.1.15": version: 2.1.20 resolution: "arktype@npm:2.1.20" @@ -7394,6 +7923,13 @@ __metadata: languageName: node linkType: hard +"detect-node-es@npm:^1.1.0": + version: 1.1.0 + resolution: "detect-node-es@npm:1.1.0" + checksum: 10c0/e562f00de23f10c27d7119e1af0e7388407eb4b06596a25f6d79a360094a109ff285de317f02b090faae093d314cf6e73ac3214f8a5bb3a0def5bece94557fbe + languageName: node + linkType: hard + "detective-amd@npm:^6.0.0": version: 6.0.0 resolution: "detective-amd@npm:6.0.0" @@ -8439,6 +8975,7 @@ __metadata: "@react-native-community/cli": "npm:20.2.0" "@react-native-community/cli-platform-android": "npm:20.2.0" "@react-native-community/cli-platform-ios": "npm:20.2.0" + "@react-native-menu/menu": "npm:2.0.0" "@react-native/babel-preset": "npm:0.86.0" "@react-native/jest-preset": "npm:0.86.0" "@react-native/metro-config": "npm:0.86.0" @@ -8460,6 +8997,8 @@ __metadata: react-native: "npm:0.86.0" react-native-gesture-handler: "npm:2.32.0" react-native-haptic-feedback: "npm:2.3.3" + react-native-ios-context-menu: "npm:3.1.0" + react-native-ios-utilities: "npm:5.1.2" react-native-reanimated: "npm:4.5.1" react-native-safe-area-context: "npm:5.7.0" react-native-screens: "npm:4.25.2" @@ -8468,6 +9007,7 @@ __metadata: react-native-worklets: "npm:0.10.1" react-test-renderer: "npm:19.2.3" typescript: "npm:^5.8.3" + zeego: "npm:3.0.6" languageName: unknown linkType: soft @@ -9363,6 +9903,13 @@ __metadata: languageName: node linkType: hard +"get-nonce@npm:^1.0.0": + version: 1.0.1 + resolution: "get-nonce@npm:1.0.1" + checksum: 10c0/2d7df55279060bf0568549e1ffc9b84bc32a32b7541675ca092dce56317cdd1a59a98dcc4072c9f6a980779440139a3221d7486f52c488e69dc0fd27b1efb162 + languageName: node + linkType: hard + "get-own-enumerable-property-symbols@npm:^3.0.0": version: 3.0.2 resolution: "get-own-enumerable-property-symbols@npm:3.0.2" @@ -14939,6 +15486,29 @@ __metadata: languageName: node linkType: hard +"react-native-ios-context-menu@npm:3.1.0": + version: 3.1.0 + resolution: "react-native-ios-context-menu@npm:3.1.0" + dependencies: + "@dominicstop/ts-event-emitter": "npm:^1.1.0" + peerDependencies: + react: "*" + react-native: "*" + react-native-ios-utilities: "*" + checksum: 10c0/7dda8d8ee2b0871b9c1bfce985bce49b8762988b34c5af6679fcdf47f125e98df7c1159245f7ca4070a00fd9874336a4fa17f8bf582377e849a850eb511d7680 + languageName: node + linkType: hard + +"react-native-ios-utilities@npm:5.1.2": + version: 5.1.2 + resolution: "react-native-ios-utilities@npm:5.1.2" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/b1b700faa2f447423cbfbe941a5e0eaeb7357df5f85f0a5d27a717f040174ecc3f972007f39e76cd053e4d90248028b137726524d71756e3a051efa32136754d + languageName: node + linkType: hard + "react-native-is-edge-to-edge@npm:1.1.7": version: 1.1.7 resolution: "react-native-is-edge-to-edge@npm:1.1.7" @@ -15321,6 +15891,57 @@ __metadata: languageName: node linkType: hard +"react-remove-scroll-bar@npm:^2.3.7": + version: 2.3.8 + resolution: "react-remove-scroll-bar@npm:2.3.8" + dependencies: + react-style-singleton: "npm:^2.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/9a0675c66cbb52c325bdbfaed80987a829c4504cefd8ff2dd3b6b3afc9a1500b8ec57b212e92c1fb654396d07bbe18830a8146fe77677d2a29ce40b5e1f78654 + languageName: node + linkType: hard + +"react-remove-scroll@npm:^2.7.2": + version: 2.7.2 + resolution: "react-remove-scroll@npm:2.7.2" + dependencies: + react-remove-scroll-bar: "npm:^2.3.7" + react-style-singleton: "npm:^2.2.3" + tslib: "npm:^2.1.0" + use-callback-ref: "npm:^1.3.3" + use-sidecar: "npm:^1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/b5f3315bead75e72853f492c0b51ba8fb4fa09a4534d7fc42d6fcd59ca3e047cf213279ffc1e35b337e314ef5a04cb2b12544c85e0078802271731c27c09e5aa + languageName: node + linkType: hard + +"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": + version: 2.2.3 + resolution: "react-style-singleton@npm:2.2.3" + dependencies: + get-nonce: "npm:^1.0.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/841938ff16d16a6b76895f4cb2e1fea957e5fe3b30febbf03a54892dae1c9153f2383e231dea0b3ba41192ad2f2849448fa859caccd288943bce32639e971bee + languageName: node + linkType: hard + "react-test-renderer@npm:19.2.3": version: 19.2.3 resolution: "react-test-renderer@npm:19.2.3" @@ -16219,6 +16840,13 @@ __metadata: languageName: node linkType: hard +"sf-symbols-typescript@npm:^2.0.0": + version: 2.2.0 + resolution: "sf-symbols-typescript@npm:2.2.0" + checksum: 10c0/3f3bbf33aaad19e619d6f169899b39e9fe9c5fd21f0d6d511100e36887606ad349109ddc6ff82933f2b8cbf437dd7105c2ae6b0059b291dc47f143b30c2074cc + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -17468,7 +18096,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0": +"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -17916,6 +18544,21 @@ __metadata: languageName: node linkType: hard +"use-callback-ref@npm:^1.3.3": + version: 1.3.3 + resolution: "use-callback-ref@npm:1.3.3" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/f887488c6e6075cdad4962979da1714b217bcb1ee009a9e57ce9a844bcfc4c3a99e93983dfc2e5af9e0913824d24e730090ff255e902c516dcb58d2d3837e01c + languageName: node + linkType: hard + "use-latest-callback@npm:^0.2.1": version: 0.2.1 resolution: "use-latest-callback@npm:0.2.1" @@ -17925,6 +18568,22 @@ __metadata: languageName: node linkType: hard +"use-sidecar@npm:^1.1.3": + version: 1.1.3 + resolution: "use-sidecar@npm:1.1.3" + dependencies: + detect-node-es: "npm:^1.1.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/161599bf921cfaa41c85d2b01c871975ee99260f3e874c2d41c05890d41170297bdcf314bc5185e7a700de2034ac5b888e3efc8e9f35724f4918f53538d717c9 + languageName: node + linkType: hard + "use-sync-external-store@npm:^1.2.2": version: 1.4.0 resolution: "use-sync-external-store@npm:1.4.0" @@ -18485,6 +19144,23 @@ __metadata: languageName: node linkType: hard +"zeego@npm:3.0.6": + version: 3.0.6 + resolution: "zeego@npm:3.0.6" + dependencies: + "@radix-ui/react-context-menu": "npm:^2.0.1" + "@radix-ui/react-dropdown-menu": "npm:^2.0.1" + sf-symbols-typescript: "npm:^2.0.0" + peerDependencies: + "@react-native-menu/menu": 1.2.2 + react: "*" + react-native: "*" + react-native-ios-context-menu: 3.1.0 + react-native-ios-utilities: 5.1.2 + checksum: 10c0/76eec9ca0cee085da0bb50188ee08d6d67e3b2b2ea17b0a30e34fb3e39cd67a75d2650bdd4f5c593aec89cd778622d083dd20c61fd6adebe5ca01d694ac30fd5 + languageName: node + linkType: hard + "zod-validation-error@npm:^3.0.3": version: 3.5.0 resolution: "zod-validation-error@npm:3.5.0" From 93ddaf8b0320b03a6260e37f6eb936935341f642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sat, 4 Jul 2026 17:21:06 +0200 Subject: [PATCH 3/8] Trim comments in the context menu changes --- .../SortableGrid/features/ContextMenuExample.tsx | 14 +++++--------- .../src/providers/shared/DragProvider.ts | 12 +++++------- .../src/providers/shared/hooks/useItemZIndex.ts | 3 +-- .../src/types/providers/shared.ts | 5 ++--- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx index 4f160d8f..8dc4a051 100644 --- a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx +++ b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx @@ -11,12 +11,9 @@ import { getItems } from '@/utils'; const DATA = getItems(6); const COLUMNS = 2; -// The native menu appears on the OS long-press timeout (~500ms) and can't be -// changed, so we tune the sortable side instead: it claims the gesture a bit -// sooner and treats even a small move as a drag, so `isDragging` flips - and the -// menu is dismissed - as soon as the item starts moving. Lower both to make the -// sortable win the long press more eagerly. -const DRAG_ACTIVATION_DELAY = 120; // ms until the item can be dragged (default 200) +// The native menu opens on the OS long-press timeout (~500ms, not tunable), so +// tune the sortable side: pick up sooner and treat a small move as a drag. +const DRAG_ACTIVATION_DELAY = 120; // ms until draggable (default 200) const DRAG_FAIL_OFFSET = 3; // px of movement that counts as a drag (default 5) function MenuCard({ item }: { item: string }) { @@ -32,9 +29,8 @@ function MenuCard({ item }: { item: string }) { } ); - // Once the item is actually dragged, drop the context menu so it can't open - // (and it closes if it was already open) - the sortable owns the gesture from - // that point on. On a stationary long press the menu stays available. + // While dragging, drop the menu so it can't open (and closes if already open). + // A stationary long press keeps it. if (dragging) { return {item}; } diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index 9b527ca3..e278fbee 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -300,7 +300,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< multiZoneActiveItemDimensions.value = itemDimensions; } - // zIndex is elevated later, on the first drag move (see handleTouchesMove) + // zIndex is elevated on the first drag move (see handleTouchesMove) // We need to update the custom handle measurements if the custom handle // is used (touch position is relative to the handle in this case) @@ -475,9 +475,8 @@ const { DragProvider, useDragContext } = createProvider('Drag')< touchData: touch }); - // Bring the item to the front only once it is actually dragged, so a - // long press alone (e.g. to open a native context menu) doesn't change - // the stacking context and dismiss the menu. + // Elevate only once dragged, so a bare long press doesn't change the + // stacking context and dismiss a native context menu (#417) if ( !activeItemBroughtToFront.value && movedDistance >= dragActivationFailOffset.value @@ -556,9 +555,8 @@ const { DragProvider, useDragContext } = createProvider('Drag')< inactiveAnimationProgress.value = animate(); activeAnimationProgress.value = animate(); - // Restore the layer zIndex only if it was elevated. The flag is left as - // is and reset on the next handleDragStart - resetting it here would race - // with a new overlapping drag and drop its item below its siblings. + // Restore the layer only if it was elevated. The flag resets on the next + // drag start, not here, to avoid racing an overlapping drag. const wasBroughtToFront = activeItemBroughtToFront.value; if (wasBroughtToFront) { updateLayer?.(LayerState.INTERMEDIATE); diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts index 6870ac00..985a3567 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts @@ -23,8 +23,7 @@ export default function useItemZIndex( ? itemCount - realIndex - 1 : realIndex; - // Keep the order zIndex until the item is actually dragged. See - // https://github.com/MatiPl01/react-native-sortables/issues/417 + // Keep the order zIndex until the item is dragged (#417) if (!activeItemBroughtToFront.value) { return orderZIndex; } diff --git a/packages/react-native-sortables/src/types/providers/shared.ts b/packages/react-native-sortables/src/types/providers/shared.ts index c08ecddc..9a126762 100644 --- a/packages/react-native-sortables/src/types/providers/shared.ts +++ b/packages/react-native-sortables/src/types/providers/shared.ts @@ -156,9 +156,8 @@ export type ItemContextType = Simplify< Pick & { itemKey: string; isActive: SharedValue; - // True once the active item is actually dragged (moved past the drag - // fail offset), as opposed to just long pressed. Use it to disable - // interactive children (e.g. a native context menu) while dragging. + // True once the active item is dragged (moved), not just long pressed. + // Use it to disable interactive children while dragging. isDragging: SharedValue; activationAnimationProgress: SharedValue; } From 836817165ba526bd05b4f5fec7b1517cbcb4bb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 6 Jul 2026 11:28:12 +0200 Subject: [PATCH 4/8] Defer sortable pickup scale and transform until the item is dragged Gate the pickup scale/shadow and the transform-based layout on activeItemBroughtToFront, so a bare long press leaves the item untouched (letting a native context menu lift it) while a real drag is unchanged. Ramp the pickup in on drag start so it still animates up smoothly. --- .../shared/hooks/useItemDecoration.ts | 18 +++++++++++++- .../providers/shared/hooks/useItemLayout.ts | 24 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemDecoration.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemDecoration.ts index e386a262..ebe3e9e1 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemDecoration.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemDecoration.ts @@ -17,6 +17,7 @@ export default function useItemDecoration( ): SharedValue { const { activationAnimationDuration, + activeItemBroughtToFront, activeItemOpacity, activeItemScale, activeItemShadowOpacity, @@ -38,8 +39,23 @@ export default function useItemDecoration( ); }); + // Ramp the "picked up" progress in when the item is actually brought to front + // (the first drag move), so the scale/shadow animate up smoothly even if the + // activation timing already finished during a long hold. Gating the raw + // progress alone would make it jump 0 -> 1 in a single frame in that case. + const broughtToFrontProgress = useDerivedValue(() => + activeItemBroughtToFront.value + ? withTiming(1, { duration: activationAnimationDuration.value }) + : 0 + ); + return useDerivedValue(() => { - const progress = activationAnimationProgress.value; + // Apply the "picked up" scale/shadow only once the item is actually dragged, + // not on a bare long press (otherwise a native context menu taking over the + // gesture makes the item scale up and then back down). The drop animation + // still runs via activationAnimationProgress falling back to 0. + const progress = + broughtToFrontProgress.value * activationAnimationProgress.value; const zeroProgressOpacity = interpolate( adjustedInactiveProgress.value, [0, 1], diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemLayout.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemLayout.ts index 8f1cf278..c9a46c79 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemLayout.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemLayout.ts @@ -88,14 +88,30 @@ function useItemLayoutFabric( position: SharedValue, zIndex: SharedValue ): SharedValue { - const { activeItemDropped, usesAbsoluteLayout } = useCommonValuesContext(); + const { activeItemBroughtToFront, activeItemDropped, usesAbsoluteLayout } = + useCommonValuesContext(); const transformStartPosition = useMutableValue(null); const debounce = useAnimatedDebounce(); useAnimatedReaction( - () => ({ current: position.value, dropped: activeItemDropped.value }), - ({ current, dropped }) => { - transformStartPosition.value ??= current; + () => ({ + broughtToFront: activeItemBroughtToFront.value, + current: position.value, + dropped: activeItemDropped.value + }), + ({ broughtToFront, current, dropped }) => { + // Only switch to transform-based positioning once the item is actually + // dragged (brought to front). A bare long press that never turns into a + // drag then stays on a stable left/top-only style and never toggles the + // transform<->layout shape, so it never re-commits a frame to the item + // subtree. That matters on Fabric because a frame/onLayout on the item + // while a native context menu is lifting it (the menu portals the live + // view) invalidates the lift and snaps the item back into place. For a + // stationary item the transform deltas are always 0, so skipping the + // transform branch is visually identical. + if (broughtToFront) { + transformStartPosition.value ??= current; + } if (dropped) { debounce.schedule(() => { transformStartPosition.value = null; From 8f4b6374a4e68fb8e18bc70237e47f68576d1ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 6 Jul 2026 11:28:16 +0200 Subject: [PATCH 5/8] Add a native-lift context menu example and use @react-native-menu/menu for the flat one react-native-context-menu-view gives a real UIContextMenuInteraction lift; it is patched (.yarn/patches) to lift a separate static preview view rather than the live sortable view, which the New Architecture snaps mid-interaction. @react-native-menu/menu replaces zeego for the flat example (zeego's iOS backend cannot run on RN 0.86). --- ...text-menu-view-npm-1.21.0-e518051dbe.patch | 24 +++ example/app/package.json | 1 + .../features/ContextMenuExample.tsx | 64 ++++++-- .../features/ContextMenuViewExample.tsx | 148 ++++++++++++++++++ .../examples/SortableGrid/features/index.ts | 1 + example/app/src/examples/navigation/routes.ts | 4 + yarn.lock | 21 +++ 7 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 .yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch create mode 100644 example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx diff --git a/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch b/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch new file mode 100644 index 00000000..e1fd6ad0 --- /dev/null +++ b/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch @@ -0,0 +1,24 @@ +diff --git a/ios/ContextMenuView.m b/ios/ContextMenuView.m +index e10a830c1620d8def92d690e44d3ef058c7e78a5..da3fb5ab4f8d43b8df64c5e30d8d7b362e2f18bc 100644 +--- a/ios/ContextMenuView.m ++++ b/ios/ContextMenuView.m +@@ -159,10 +159,16 @@ - (UIPreviewParameters *)getPreviewParams { + } + + - (UITargetedPreview *)contextMenuInteraction:(UIContextMenuInteraction *)interaction previewForHighlightingMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) { ++ // PATCH (react-native-sortables example): lift the custom `preview` view (a ++ // separate static view) instead of the live RN subview when one is provided. ++ // Portaling the live subview breaks when its host re-lays it out mid- ++ // interaction (New Architecture commits a frame -> child onLayout -> ++ // UITargetedPreview invalidated -> lift dismissed, e.g. a sortable item ++ // snapping back or going blank). ++ UIView *previewView = _customView != nil ? _customView : self.reactSubviews.firstObject; + UIPreviewTarget* previewTarget = [[UIPreviewTarget alloc] initWithContainer:self center:self.reactSubviews.firstObject.center]; +- +- +- return [[UITargetedPreview alloc] initWithView:self.reactSubviews.firstObject ++ ++ return [[UITargetedPreview alloc] initWithView:previewView + parameters:[self getPreviewParams] + target:previewTarget]; + } diff --git a/example/app/package.json b/example/app/package.json index 09caf5a9..d6bd32e6 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -12,6 +12,7 @@ "@shopify/flash-list": "2.3.1", "react": "19.2.3", "react-native": "0.86.0", + "react-native-context-menu-view": "patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch", "react-native-gesture-handler": "2.32.0", "react-native-haptic-feedback": "2.3.3", "react-native-ios-context-menu": "3.1.0", diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx index 8dc4a051..3d79e463 100644 --- a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx +++ b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx @@ -1,8 +1,8 @@ +import { MenuView } from '@react-native-menu/menu'; import { useCallback, useState } from 'react'; import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; import type { SortableGridRenderItem } from 'react-native-sortables'; import Sortable, { useItemContext } from 'react-native-sortables'; -import * as ContextMenu from 'zeego/context-menu'; import { GridCard, ScrollScreen, Section, Stagger } from '@/components'; import { spacing } from '@/theme'; @@ -14,7 +14,38 @@ const COLUMNS = 2; // The native menu opens on the OS long-press timeout (~500ms, not tunable), so // tune the sortable side: pick up sooner and treat a small move as a drag. const DRAG_ACTIVATION_DELAY = 120; // ms until draggable (default 200) -const DRAG_FAIL_OFFSET = 3; // px of movement that counts as a drag (default 5) +// Small enough that the sortable claims the touch before the surrounding +// ScrollView starts scrolling, but larger than the finger jitter of a +// stationary long press (which must be left to the native context menu). +const DRAG_FAIL_OFFSET = 8; // px of movement that counts as a drag (default 5) + +const MENU_ACTIONS = [ + { id: 'edit', title: 'Edit' }, + { attributes: { destructive: true }, id: 'delete', title: 'Delete' } +]; + +function noop() {} + +// Baseline for comparison: the native context menu on a PLAIN view (no +// sortable). Long press it to see how the OS lifts the view and keeps it +// scaled up while the menu is open. +function PlainMenuCard() { + return ( + // Without an explicit size the native MenuView stretches to fill the row, + // so the long press triggers anywhere in the container, not just on the + // card. Size it to the card so the hit area matches what you see. + + + Plain + + + ); +} function MenuCard({ item }: { item: string }) { const { isDragging } = useItemContext(); @@ -36,19 +67,13 @@ function MenuCard({ item }: { item: string }) { } return ( - - - {item} - - - - Edit - - - Delete - - - + + {item} + ); } @@ -62,8 +87,13 @@ export default function ContextMenuExample() {
+ description='Baseline (no sortable): long press this plain view. The OS lifts it and keeps it scaled up while the menu is open. Compare this with the sortable items below.' + title='Plain view + context menu'> + +
+
+ Plain + + } + previewBackgroundColor='transparent' + style={{ alignSelf: 'flex-start' }}> + + Plain + + + ); +} + +function MenuCard({ item }: { item: string }) { + const { isDragging } = useItemContext(); + const [dragging, setDragging] = useState(false); + // Measure the cell so the lifted preview matches the item's size. Measure on a + // plain RN View (onLayout fires reliably), NOT on the native ContextMenu: its + // legacy-interop onLayout doesn't fire dependably on the New Architecture, so + // the size stayed null, the preview was undefined, and the OS fell back to + // lifting the live view -> the sortable snapped it. + const [size, setSize] = useState<{ height: number; width: number }>({ + // Fallback so the preview is NEVER undefined (an undefined preview forces + // the live-view fallback). Real size overwrites this on first layout. + height: 170, + width: 170 + }); + + useAnimatedReaction( + () => isDragging.value, + (value, prev) => { + if (value !== prev) { + runOnJS(setDragging)(value); + } + } + ); + + return ( + // Keep the ContextMenu mounted and just disable it while dragging - do NOT + // unmount it (its native subview would go nil mid-interaction and crash). + // `preview` is a SEPARATE static view; the patched library lifts THAT for + // both the highlight and the commit, so the sortable re-laying out the live + // item view never disturbs the lift (no blank, no snap-back). + + {item} + + } + previewBackgroundColor='transparent'> + + setSize({ height: layout.height, width: layout.width }) + }> + {item} + + + ); +} + +export default function ContextMenuViewExample() { + const renderItem = useCallback>( + ({ item }) => , + [] + ); + + return ( + + +
+ +
+
+ +
+
+
+ ); +} diff --git a/example/app/src/examples/SortableGrid/features/index.ts b/example/app/src/examples/SortableGrid/features/index.ts index 1f75540f..a46bdca4 100644 --- a/example/app/src/examples/SortableGrid/features/index.ts +++ b/example/app/src/examples/SortableGrid/features/index.ts @@ -1,6 +1,7 @@ export { default as AutoScrollExample } from './AutoScrollExample'; export { default as CallbacksExample } from './CallbacksExample'; export { default as ContextMenuExample } from './ContextMenuExample'; +export { default as ContextMenuViewExample } from './ContextMenuViewExample'; export { default as DataChangeExample } from './DataChangeExample'; export { default as DebugExample } from './DebugExample'; export { default as DifferentSizeItems } from './DifferentSizeItems'; diff --git a/example/app/src/examples/navigation/routes.ts b/example/app/src/examples/navigation/routes.ts index 296b87fa..98a0912f 100644 --- a/example/app/src/examples/navigation/routes.ts +++ b/example/app/src/examples/navigation/routes.ts @@ -31,6 +31,10 @@ const routes: Routes = { Component: SortableGrid.features.ContextMenuExample, name: 'Context Menu' }, + ContextMenuView: { + Component: SortableGrid.features.ContextMenuViewExample, + name: 'Context Menu (native lift)' + }, DataChange: { Component: SortableGrid.features.DataChangeExample, name: 'Data Change' diff --git a/yarn.lock b/yarn.lock index 0c3b1e99..a2448cca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8995,6 +8995,7 @@ __metadata: prettier: "npm:^3.3.2" react: "npm:19.2.3" react-native: "npm:0.86.0" + react-native-context-menu-view: "patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch" react-native-gesture-handler: "npm:2.32.0" react-native-haptic-feedback: "npm:2.3.3" react-native-ios-context-menu: "npm:3.1.0" @@ -15435,6 +15436,26 @@ __metadata: languageName: node linkType: hard +"react-native-context-menu-view@npm:1.21.0": + version: 1.21.0 + resolution: "react-native-context-menu-view@npm:1.21.0" + peerDependencies: + react: ^16.8.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: ">=0.60.0-rc.0 <1.0.x" + checksum: 10c0/923a11c034da814ac24402af0308b219a319f8b22d9a0ca381cf820f3f863c15bc08fa3c6107cdc7127004f5ba0096374a764b511edb664acb53a9212b68ae26 + languageName: node + linkType: hard + +"react-native-context-menu-view@patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch": + version: 1.21.0 + resolution: "react-native-context-menu-view@patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch::version=1.21.0&hash=9b49f3" + peerDependencies: + react: ^16.8.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: ">=0.60.0-rc.0 <1.0.x" + checksum: 10c0/c7b5016d19f977310869202cb47760bf2063c8bd410caf6d7f57c3b3d340502997330a26f1bd75caf35d6b78ec0553130490175ddd01aa377dbc3c692e766294 + languageName: node + linkType: hard + "react-native-gesture-handler@npm:2.28.0": version: 2.28.0 resolution: "react-native-gesture-handler@npm:2.28.0" From 94e9d9ddf56fe46a122d718794b73a1d621c312d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 6 Jul 2026 12:26:00 +0200 Subject: [PATCH 6/8] Fix lint in the context menu examples Sort props and imports to satisfy react/jsx-sort-props and simple-import-sort, and give the no-op menu handlers a comment body so no-empty-function passes. --- .../features/ContextMenuExample.tsx | 14 +++++++------ .../features/ContextMenuViewExample.tsx | 20 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx index 3d79e463..fb38f5fe 100644 --- a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx +++ b/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx @@ -24,7 +24,9 @@ const MENU_ACTIONS = [ { attributes: { destructive: true }, id: 'delete', title: 'Delete' } ]; -function noop() {} +function noop() { + // Menu actions are no-ops in this example. +} // Baseline for comparison: the native context menu on a PLAIN view (no // sortable). Long press it to see how the OS lifts the view and keeps it @@ -36,10 +38,10 @@ function PlainMenuCard() { // card. Size it to the card so the hit area matches what you see. + title='Plain' + shouldOpenOnLongPress + onPressAction={noop}> Plain @@ -69,9 +71,9 @@ function MenuCard({ item }: { item: string }) { return ( + onPressAction={noop}> {item} ); diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx b/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx index 80cd7119..0866b70d 100644 --- a/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx +++ b/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx @@ -13,8 +13,8 @@ // with the OS lift (or a drag-handle UX - long press = menu, handle = reorder). import { useCallback, useState } from 'react'; import { View } from 'react-native'; -import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; import ContextMenu from 'react-native-context-menu-view'; +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; import type { SortableGridRenderItem } from 'react-native-sortables'; import Sortable, { useItemContext } from 'react-native-sortables'; @@ -33,7 +33,9 @@ const MENU_ACTIONS = [ { destructive: true, systemIcon: 'trash', title: 'Delete' } ]; -function noop() {} +function noop() { + // Menu actions are no-ops in this example. +} // Baseline (no sortable): react-native-context-menu-view uses the real // UIContextMenuInteraction, so the view lifts and stays scaled up while the @@ -47,15 +49,15 @@ function PlainMenuCard() { Plain } - previewBackgroundColor='transparent' - style={{ alignSelf: 'flex-start' }}> + disableShadow + onPress={noop}> Plain @@ -96,15 +98,15 @@ function MenuCard({ item }: { item: string }) { {item} } - previewBackgroundColor='transparent'> + disableShadow + onPress={noop}> setSize({ height: layout.height, width: layout.width }) From 4f4374dd5372df09906ebf6fabe1998be47ac50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 6 Jul 2026 12:32:35 +0200 Subject: [PATCH 7/8] Drop the react-native-context-menu-view patch and use it unmodified The example no longer patches the library. Removing the patch also removes the custom preview + onLayout measurement that only existed to feed it; the plain card still lifts and stays via the stock library, and the sortable item keeps the small scale-down settle documented in the file header. --- ...text-menu-view-npm-1.21.0-e518051dbe.patch | 24 -------- example/app/package.json | 2 +- .../features/ContextMenuViewExample.tsx | 59 ++++--------------- yarn.lock | 12 +--- 4 files changed, 13 insertions(+), 84 deletions(-) delete mode 100644 .yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch diff --git a/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch b/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch deleted file mode 100644 index e1fd6ad0..00000000 --- a/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/ios/ContextMenuView.m b/ios/ContextMenuView.m -index e10a830c1620d8def92d690e44d3ef058c7e78a5..da3fb5ab4f8d43b8df64c5e30d8d7b362e2f18bc 100644 ---- a/ios/ContextMenuView.m -+++ b/ios/ContextMenuView.m -@@ -159,10 +159,16 @@ - (UIPreviewParameters *)getPreviewParams { - } - - - (UITargetedPreview *)contextMenuInteraction:(UIContextMenuInteraction *)interaction previewForHighlightingMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) { -+ // PATCH (react-native-sortables example): lift the custom `preview` view (a -+ // separate static view) instead of the live RN subview when one is provided. -+ // Portaling the live subview breaks when its host re-lays it out mid- -+ // interaction (New Architecture commits a frame -> child onLayout -> -+ // UITargetedPreview invalidated -> lift dismissed, e.g. a sortable item -+ // snapping back or going blank). -+ UIView *previewView = _customView != nil ? _customView : self.reactSubviews.firstObject; - UIPreviewTarget* previewTarget = [[UIPreviewTarget alloc] initWithContainer:self center:self.reactSubviews.firstObject.center]; -- -- -- return [[UITargetedPreview alloc] initWithView:self.reactSubviews.firstObject -+ -+ return [[UITargetedPreview alloc] initWithView:previewView - parameters:[self getPreviewParams] - target:previewTarget]; - } diff --git a/example/app/package.json b/example/app/package.json index d6bd32e6..93aba0f6 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -12,7 +12,7 @@ "@shopify/flash-list": "2.3.1", "react": "19.2.3", "react-native": "0.86.0", - "react-native-context-menu-view": "patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch", + "react-native-context-menu-view": "1.21.0", "react-native-gesture-handler": "2.32.0", "react-native-haptic-feedback": "2.3.3", "react-native-ios-context-menu": "3.1.0", diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx b/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx index 0866b70d..5e0c11ef 100644 --- a/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx +++ b/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx @@ -2,17 +2,13 @@ // Long press lifts the item and opens the menu (it stays open); a drag reorders // and dismisses it. // -// iOS lift caveat: the library portals the item's LIVE view into the lift. Under -// the New Architecture, when the sortable re-lays that view out mid-interaction -// the OS tears the lift down (item goes blank, or snaps back). To avoid that this -// example (a) passes a separate static `preview` view and (b) relies on a patch to -// the library (see .yarn/patches) so the highlight lifts that static view too, -// not the live one. Known remaining nuance: the lifted sortable item still shows a -// small scale-down settle on commit that a plain (non-sortable) view does not; -// fully removing it would need reworking how the sortable's activation coexists -// with the OS lift (or a drag-handle UX - long press = menu, handle = reorder). +// iOS lift nuance: the library lifts the item's live view for the menu preview. +// A plain view lifts and stays scaled up; a sortable item - whose view the +// sortable re-lays out as the gesture ends - shows a small scale-down settle as +// the menu commits. Removing it entirely would need the library (or the sortable) +// to lift a detached snapshot rather than the live view, or a drag-handle UX +// (long press = menu, a handle = reorder). Left as a known nuance here. import { useCallback, useState } from 'react'; -import { View } from 'react-native'; import ContextMenu from 'react-native-context-menu-view'; import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; import type { SortableGridRenderItem } from 'react-native-sortables'; @@ -37,25 +33,15 @@ function noop() { // Menu actions are no-ops in this example. } -// Baseline (no sortable): react-native-context-menu-view uses the real -// UIContextMenuInteraction, so the view lifts and stays scaled up while the -// menu is open (unlike @react-native-menu/menu). +// Baseline (no sortable): the native menu lifts the card and keeps it scaled up +// while the menu is open. function PlainMenuCard() { return ( - // `preview` renders a SEPARATE static view for the lift. Our patched build of - // react-native-context-menu-view uses it for the highlight too (not just the - // commit), so the lift never portals the live RN view - it stays put and - // grows, immune to whatever the host does to the real view. - Plain - - } disableShadow onPress={noop}> @@ -68,17 +54,6 @@ function PlainMenuCard() { function MenuCard({ item }: { item: string }) { const { isDragging } = useItemContext(); const [dragging, setDragging] = useState(false); - // Measure the cell so the lifted preview matches the item's size. Measure on a - // plain RN View (onLayout fires reliably), NOT on the native ContextMenu: its - // legacy-interop onLayout doesn't fire dependably on the New Architecture, so - // the size stayed null, the preview was undefined, and the OS fell back to - // lifting the live view -> the sortable snapped it. - const [size, setSize] = useState<{ height: number; width: number }>({ - // Fallback so the preview is NEVER undefined (an undefined preview forces - // the live-view fallback). Real size overwrites this on first layout. - height: 170, - width: 170 - }); useAnimatedReaction( () => isDragging.value, @@ -91,28 +66,16 @@ function MenuCard({ item }: { item: string }) { return ( // Keep the ContextMenu mounted and just disable it while dragging - do NOT - // unmount it (its native subview would go nil mid-interaction and crash). - // `preview` is a SEPARATE static view; the patched library lifts THAT for - // both the highlight and the commit, so the sortable re-laying out the live - // item view never disturbs the lift (no blank, no snap-back). + // unmount it, or its native subview goes nil mid-interaction and the library + // crashes trying to lift it. - {item} - - } disableShadow onPress={noop}> - - setSize({ height: layout.height, width: layout.width }) - }> - {item} - + {item} ); } diff --git a/yarn.lock b/yarn.lock index a2448cca..8878a367 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8995,7 +8995,7 @@ __metadata: prettier: "npm:^3.3.2" react: "npm:19.2.3" react-native: "npm:0.86.0" - react-native-context-menu-view: "patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch" + react-native-context-menu-view: "npm:1.21.0" react-native-gesture-handler: "npm:2.32.0" react-native-haptic-feedback: "npm:2.3.3" react-native-ios-context-menu: "npm:3.1.0" @@ -15446,16 +15446,6 @@ __metadata: languageName: node linkType: hard -"react-native-context-menu-view@patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch": - version: 1.21.0 - resolution: "react-native-context-menu-view@patch:react-native-context-menu-view@npm%3A1.21.0#~/.yarn/patches/react-native-context-menu-view-npm-1.21.0-e518051dbe.patch::version=1.21.0&hash=9b49f3" - peerDependencies: - react: ^16.8.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-native: ">=0.60.0-rc.0 <1.0.x" - checksum: 10c0/c7b5016d19f977310869202cb47760bf2063c8bd410caf6d7f57c3b3d340502997330a26f1bd75caf35d6b78ec0553130490175ddd01aa377dbc3c692e766294 - languageName: node - linkType: hard - "react-native-gesture-handler@npm:2.28.0": version: 2.28.0 resolution: "react-native-gesture-handler@npm:2.28.0" From 1e268ad6f5ce9c3cf9f58e9b216e6d228c6ff102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 9 Jul 2026 10:43:08 +0200 Subject: [PATCH 8/8] Group the context menu examples into one tabbed test screen Move both native context menu demos out of the Features category into a single Context Menu screen under Test examples, with a top tab switching between @react-native-menu/menu and react-native-context-menu-view. Give the native-lift cards an explicitly sized preview so they no longer collapse (disappear) when the OS lifts them. --- .../features/ContextMenuViewExample.tsx | 113 ---------------- .../examples/SortableGrid/features/index.ts | 2 - .../ContextMenuExample.tsx | 44 +++---- .../tests/ContextMenuViewExample.tsx | 124 ++++++++++++++++++ .../tests/ContextMenusExample.tsx | 83 ++++++++++++ .../src/examples/SortableGrid/tests/index.ts | 1 + example/app/src/examples/navigation/routes.ts | 14 +- 7 files changed, 235 insertions(+), 146 deletions(-) delete mode 100644 example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx rename example/app/src/examples/SortableGrid/{features => tests}/ContextMenuExample.tsx (72%) create mode 100644 example/app/src/examples/SortableGrid/tests/ContextMenuViewExample.tsx create mode 100644 example/app/src/examples/SortableGrid/tests/ContextMenusExample.tsx diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx b/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx deleted file mode 100644 index 5e0c11ef..00000000 --- a/example/app/src/examples/SortableGrid/features/ContextMenuViewExample.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// Sortable grid items with a native iOS context menu (react-native-context-menu-view). -// Long press lifts the item and opens the menu (it stays open); a drag reorders -// and dismisses it. -// -// iOS lift nuance: the library lifts the item's live view for the menu preview. -// A plain view lifts and stays scaled up; a sortable item - whose view the -// sortable re-lays out as the gesture ends - shows a small scale-down settle as -// the menu commits. Removing it entirely would need the library (or the sortable) -// to lift a detached snapshot rather than the live view, or a drag-handle UX -// (long press = menu, a handle = reorder). Left as a known nuance here. -import { useCallback, useState } from 'react'; -import ContextMenu from 'react-native-context-menu-view'; -import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; -import type { SortableGridRenderItem } from 'react-native-sortables'; -import Sortable, { useItemContext } from 'react-native-sortables'; - -import { GridCard, ScrollScreen, Section, Stagger } from '@/components'; -import { radius, spacing } from '@/theme'; -import { getItems } from '@/utils'; - -const DATA = getItems(6); -const COLUMNS = 2; - -const DRAG_ACTIVATION_DELAY = 120; // ms until draggable (default 200) -const DRAG_FAIL_OFFSET = 8; // px of movement that counts as a drag (default 5) - -const MENU_ACTIONS = [ - { systemIcon: 'pencil', title: 'Edit' }, - { destructive: true, systemIcon: 'trash', title: 'Delete' } -]; - -function noop() { - // Menu actions are no-ops in this example. -} - -// Baseline (no sortable): the native menu lifts the card and keeps it scaled up -// while the menu is open. -function PlainMenuCard() { - return ( - - - Plain - - - ); -} - -function MenuCard({ item }: { item: string }) { - const { isDragging } = useItemContext(); - const [dragging, setDragging] = useState(false); - - useAnimatedReaction( - () => isDragging.value, - (value, prev) => { - if (value !== prev) { - runOnJS(setDragging)(value); - } - } - ); - - return ( - // Keep the ContextMenu mounted and just disable it while dragging - do NOT - // unmount it, or its native subview goes nil mid-interaction and the library - // crashes trying to lift it. - - {item} - - ); -} - -export default function ContextMenuViewExample() { - const renderItem = useCallback>( - ({ item }) => , - [] - ); - - return ( - - -
- -
-
- -
-
-
- ); -} diff --git a/example/app/src/examples/SortableGrid/features/index.ts b/example/app/src/examples/SortableGrid/features/index.ts index a46bdca4..c06de529 100644 --- a/example/app/src/examples/SortableGrid/features/index.ts +++ b/example/app/src/examples/SortableGrid/features/index.ts @@ -1,7 +1,5 @@ export { default as AutoScrollExample } from './AutoScrollExample'; export { default as CallbacksExample } from './CallbacksExample'; -export { default as ContextMenuExample } from './ContextMenuExample'; -export { default as ContextMenuViewExample } from './ContextMenuViewExample'; export { default as DataChangeExample } from './DataChangeExample'; export { default as DebugExample } from './DebugExample'; export { default as DifferentSizeItems } from './DifferentSizeItems'; diff --git a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx b/example/app/src/examples/SortableGrid/tests/ContextMenuExample.tsx similarity index 72% rename from example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx rename to example/app/src/examples/SortableGrid/tests/ContextMenuExample.tsx index fb38f5fe..216498a1 100644 --- a/example/app/src/examples/SortableGrid/features/ContextMenuExample.tsx +++ b/example/app/src/examples/SortableGrid/tests/ContextMenuExample.tsx @@ -4,7 +4,7 @@ import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; import type { SortableGridRenderItem } from 'react-native-sortables'; import Sortable, { useItemContext } from 'react-native-sortables'; -import { GridCard, ScrollScreen, Section, Stagger } from '@/components'; +import { GridCard, Section } from '@/components'; import { spacing } from '@/theme'; import { getItems } from '@/utils'; @@ -86,27 +86,25 @@ export default function ContextMenuExample() { ); return ( - - -
- -
-
- -
-
-
+ <> +
+ +
+
+ +
+ ); } diff --git a/example/app/src/examples/SortableGrid/tests/ContextMenuViewExample.tsx b/example/app/src/examples/SortableGrid/tests/ContextMenuViewExample.tsx new file mode 100644 index 00000000..a213e40c --- /dev/null +++ b/example/app/src/examples/SortableGrid/tests/ContextMenuViewExample.tsx @@ -0,0 +1,124 @@ +// Grid cards with a native iOS context menu (react-native-context-menu-view). +// Long press a card and the OS lifts it and opens the menu (it stays open); a +// drag reorders. Active drag-reorder and the native lift fundamentally fight - +// the drag arms on the long press and the OS cancels that touch when the menu +// commits - so the card shows a small scale-down settle as the menu opens. +import { useCallback, useState } from 'react'; +import { View } from 'react-native'; +import ContextMenu from 'react-native-context-menu-view'; +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; +import type { SortableGridRenderItem } from 'react-native-sortables'; +import Sortable, { useItemContext } from 'react-native-sortables'; + +import { GridCard, Section } from '@/components'; +import { radius, spacing } from '@/theme'; +import { getItems } from '@/utils'; + +const DATA = getItems(6); +const COLUMNS = 2; + +// The native menu opens on the OS long-press timeout (~500ms, not tunable), so +// tune the sortable side: pick up sooner and treat a small move as a drag. +const DRAG_ACTIVATION_DELAY = 120; // ms until draggable (default 200) +const DRAG_FAIL_OFFSET = 8; // px of movement that counts as a drag (default 5) + +const MENU_ACTIONS = [ + { systemIcon: 'pencil', title: 'Edit' }, + { destructive: true, systemIcon: 'trash', title: 'Delete' } +]; + +function noop() { + // Menu actions are no-ops in this example. +} + +// Baseline (no sortable): the native menu lifts the card and keeps it scaled up +// while the menu is open. +function PlainMenuCard() { + return ( + + + Plain + + + ); +} + +function MenuCard({ item }: { item: string }) { + const { isDragging } = useItemContext(); + const [dragging, setDragging] = useState(false); + // Grid cards stretch to fill their cell (no explicit size). When the OS lifts + // such a card for the preview it has nothing to size against and collapses to + // nothing (the card "disappears"), so render an explicitly sized `preview`. + // Measure the cell on a plain wrapper View - onLayout fires reliably there - + // and mirror that size into the preview card. + const [size, setSize] = useState({ height: 170, width: 170 }); + + useAnimatedReaction( + () => isDragging.value, + (value, prev) => { + if (value !== prev) { + runOnJS(setDragging)(value); + } + } + ); + + return ( + // Keep the ContextMenu mounted and just disable it while dragging - a + // stationary long press keeps the menu; a drag drops it (and reorders). + + {item} + + } + disableShadow + onPress={noop}> + + setSize({ height: layout.height, width: layout.width }) + }> + {item} + + + ); +} + +export default function ContextMenuViewExample() { + const renderItem = useCallback>( + ({ item }) => , + [] + ); + + return ( + <> +
+ +
+
+ +
+ + ); +} diff --git a/example/app/src/examples/SortableGrid/tests/ContextMenusExample.tsx b/example/app/src/examples/SortableGrid/tests/ContextMenusExample.tsx new file mode 100644 index 00000000..ec289526 --- /dev/null +++ b/example/app/src/examples/SortableGrid/tests/ContextMenusExample.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Sortable from 'react-native-sortables'; + +import { ScrollScreen } from '@/components'; +import { colors, radius, spacing } from '@/theme'; + +import ContextMenuExample from './ContextMenuExample'; +import ContextMenuViewExample from './ContextMenuViewExample'; + +// Two ways to attach a native iOS context menu to sortable grid items, one per +// tab. They behave the same on a long press; the difference is the library. +const TABS = [ + { + Content: ContextMenuExample, + key: 'menu', + label: '@react-native-menu/menu' + }, + { + Content: ContextMenuViewExample, + key: 'view', + label: 'react-native-context-menu-view' + } +]; + +export default function ContextMenusExample() { + const [activeKey, setActiveKey] = useState(TABS[0]!.key); + const ActiveContent = (TABS.find(tab => tab.key === activeKey) ?? TABS[0]!) + .Content; + + return ( + + + {TABS.map(({ key, label }) => { + const active = key === activeKey; + return ( + setActiveKey(key)}> + + {label} + + + ); + })} + + {/* SortableLayer lets the dragged item render above the sections. */} + + + + + ); +} + +const styles = StyleSheet.create({ + tab: { + alignItems: 'center', + backgroundColor: colors.background3, + borderRadius: radius.sm, + flex: 1, + paddingHorizontal: spacing.xs, + paddingVertical: spacing.sm + }, + tabActive: { + backgroundColor: colors.primary + }, + tabBar: { + flexDirection: 'row', + gap: spacing.xs, + marginBottom: spacing.md + }, + tabLabel: { + color: colors.foreground2, + fontSize: 12, + fontWeight: '600' + }, + tabLabelActive: { + color: colors.background1 + } +}); diff --git a/example/app/src/examples/SortableGrid/tests/index.ts b/example/app/src/examples/SortableGrid/tests/index.ts index 4451576a..5109516a 100644 --- a/example/app/src/examples/SortableGrid/tests/index.ts +++ b/example/app/src/examples/SortableGrid/tests/index.ts @@ -1,4 +1,5 @@ export { default as BottomTabsNavigatorExample } from './BottomTabsNavigatorExample'; +export { default as ContextMenusExample } from './ContextMenusExample'; export { default as HidingItemsExample } from './HidingItemsExample'; export { default as InputFocusExample } from './InputFocusExample'; export { default as MaxOverscrollOffsetExample } from './MaxOverscrollOffsetExample'; diff --git a/example/app/src/examples/navigation/routes.ts b/example/app/src/examples/navigation/routes.ts index 98a0912f..01380aa0 100644 --- a/example/app/src/examples/navigation/routes.ts +++ b/example/app/src/examples/navigation/routes.ts @@ -27,14 +27,6 @@ const routes: Routes = { Component: SortableGrid.features.DragHandleExample, name: 'Drag Handle' }, - ContextMenu: { - Component: SortableGrid.features.ContextMenuExample, - name: 'Context Menu' - }, - ContextMenuView: { - Component: SortableGrid.features.ContextMenuViewExample, - name: 'Context Menu (native lift)' - }, DataChange: { Component: SortableGrid.features.DataChangeExample, name: 'Data Change' @@ -100,6 +92,12 @@ const routes: Routes = { Component: SortableGrid.tests.BottomTabsNavigatorExample, name: 'Bottom Tabs Navigator' }, + ...(IS_IOS && { + ContextMenu: { + Component: SortableGrid.tests.ContextMenusExample, + name: 'Context Menu' + } + }), HidingItems: { Component: SortableGrid.tests.HidingItemsExample, name: 'Hiding Items'