From faffdc5e5830e469a2bd88bef5acb9f9fabf1195 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 7 Jul 2026 21:29:33 +0900 Subject: [PATCH] feat: support interactive card content Allow SwipeDeck.Card to opt the active card into touchable children. Background cards stay non-interactive so the deck keeps swipe ownership. The internal pointer policy remains after consumer styles for safety. Constraint: Preserve existing swipe and animation semantics Rejected: Expose raw cardPointerEvents | leaks RN pointer policy Confidence: high Scope-risk: narrow Directive: Keep nested gesture coordination as a separate API Tested: pnpm typecheck --pretty false; pnpm lint; pnpm format:check Tested: pnpm jest --runInBand --watchman=false; pnpm build Tested: pnpm docs:typecheck; pnpm docs:build; git diff --check Not-tested: Manual iOS/Android ad CTA press on physical devices --- .changeset/interactive-card-content.md | 13 +++ .../docs/1.x/en/guide/usage/api-reference.mdx | 9 +- docs/docs/1.x/en/guide/usage/basic-usage.mdx | 23 ++++ .../docs/1.x/ko/guide/usage/api-reference.mdx | 9 +- docs/docs/1.x/ko/guide/usage/basic-usage.mdx | 23 ++++ src/__tests__/SwipeDeck.integration.test.tsx | 103 +++++++++++++++++- src/components/SwipeDeckRenderedCard.tsx | 46 +++++++- src/index.tsx | 1 + src/types.ts | 9 ++ type-tests/useDeckEvent.tsx | 36 +++++- 10 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 .changeset/interactive-card-content.md diff --git a/.changeset/interactive-card-content.md b/.changeset/interactive-card-content.md new file mode 100644 index 0000000..f3ad171 --- /dev/null +++ b/.changeset/interactive-card-content.md @@ -0,0 +1,13 @@ +--- +'@react-native-motion-kit/swipe-deck': minor +--- + +Add `interactive` to `SwipeDeck.Card` so active-card children such as CTA buttons or links can receive touches without exposing raw React Native `pointerEvents`. + +```tsx + item.type === 'ad'}> + {({ item }) => openAd(item)} />} + +``` + +Only the active card can become interactive. Background cards remain non-interactive to preserve swipe-deck safety. diff --git a/docs/docs/1.x/en/guide/usage/api-reference.mdx b/docs/docs/1.x/en/guide/usage/api-reference.mdx index 461caa0..82bb6f2 100644 --- a/docs/docs/1.x/en/guide/usage/api-reference.mdx +++ b/docs/docs/1.x/en/guide/usage/api-reference.mdx @@ -69,10 +69,11 @@ Static `SwipeDeck.Root` accepts the same props except `id`. ## `SwipeDeck.Card` Props -| Prop | Type | Notes | -| ---------- | ----------------------------------------- | --------------------------------------- | -| `style` | `StyleProp` | Style applied to the absolute card. | -| `children` | `(info: SwipeRenderInfo) => ReactNode` | Renders one item in the bounded window. | +| Prop | Type | Notes | +| ------------- | ---------------------------------------------------- | --------------------------------------------------- | +| `interactive` | `boolean \| ((info: SwipeRenderInfo) => boolean)` | Enables touchable children on the active card only. | +| `style` | `StyleProp` | Style applied to the absolute card. | +| `children` | `(info: SwipeRenderInfo) => ReactNode` | Renders one item in the bounded window. | `SwipeRenderInfo` includes `item`, `index`, `offset`, `role`, and `isActive`. diff --git a/docs/docs/1.x/en/guide/usage/basic-usage.mdx b/docs/docs/1.x/en/guide/usage/basic-usage.mdx index 9687430..f4cc082 100644 --- a/docs/docs/1.x/en/guide/usage/basic-usage.mdx +++ b/docs/docs/1.x/en/guide/usage/basic-usage.mdx @@ -44,6 +44,29 @@ repeating generics in JSX. ``` +## Interactive Card Content + +Cards are non-interactive by default so the deck can own swipe gestures safely. +Use `interactive` when the active card contains touchable children such as CTA buttons or links. + +```tsx + + {({ item }) => openAd(item)} />} + +``` + +Use the predicate form when only some items should expose touchable children. + +```tsx + item.type === 'ad'}> + {({ item }) => } + +``` + +`interactive` only affects the active card. Background cards remain non-interactive. +For deeper nested gestures such as card-local carousels or scroll views, prefer a dedicated +future gesture-coordination API instead of exposing raw `pointerEvents`. + ## Allowed Directions `allowedDirections` limits which directions can be accepted as a dismiss. diff --git a/docs/docs/1.x/ko/guide/usage/api-reference.mdx b/docs/docs/1.x/ko/guide/usage/api-reference.mdx index 7fd5c75..86d0e6b 100644 --- a/docs/docs/1.x/ko/guide/usage/api-reference.mdx +++ b/docs/docs/1.x/ko/guide/usage/api-reference.mdx @@ -69,10 +69,11 @@ Static `SwipeDeck.Root`는 `id`를 제외한 같은 props를 받습니다. ## `SwipeDeck.Card` props -| Prop | Type | 설명 | -| ---------- | ----------------------------------------- | ------------------------------------------ | -| `style` | `StyleProp` | Absolute card container에 적용됩니다. | -| `children` | `(info: SwipeRenderInfo) => ReactNode` | Bounded window의 item 하나를 렌더링합니다. | +| Prop | Type | 설명 | +| ------------- | ---------------------------------------------------- | --------------------------------------------------- | +| `interactive` | `boolean \| ((info: SwipeRenderInfo) => boolean)` | Active card 안의 touchable children을 활성화합니다. | +| `style` | `StyleProp` | Absolute card container에 적용됩니다. | +| `children` | `(info: SwipeRenderInfo) => ReactNode` | Bounded window의 item 하나를 렌더링합니다. | `SwipeRenderInfo`는 `item`, `index`, `offset`, `role`, `isActive`를 포함합니다. diff --git a/docs/docs/1.x/ko/guide/usage/basic-usage.mdx b/docs/docs/1.x/ko/guide/usage/basic-usage.mdx index 9c78909..7639987 100644 --- a/docs/docs/1.x/ko/guide/usage/basic-usage.mdx +++ b/docs/docs/1.x/ko/guide/usage/basic-usage.mdx @@ -44,6 +44,29 @@ Factory는 `Root`, `Card`, hook이 같은 item type을 공유하도록 해 JSX ``` +## Interactive Card Content + +Card는 기본적으로 non-interactive입니다. 그래야 deck이 swipe gesture를 안전하게 소유할 수 있습니다. +Active card 안에 CTA button이나 link 같은 touchable children이 있다면 `interactive`를 사용하세요. + +```tsx + + {({ item }) => openAd(item)} />} + +``` + +일부 item에서만 touchable children을 열어야 한다면 predicate form을 사용하세요. + +```tsx + item.type === 'ad'}> + {({ item }) => } + +``` + +`interactive`는 active card에만 적용됩니다. 뒤쪽 card는 계속 non-interactive입니다. +Card 내부 carousel이나 scroll view처럼 더 깊은 nested gesture 조정은 raw `pointerEvents`를 +노출하기보다 별도의 gesture-coordination API로 다루는 것이 좋습니다. + ## Allowed Directions `allowedDirections`는 dismiss로 받아들일 방향을 제한합니다. diff --git a/src/__tests__/SwipeDeck.integration.test.tsx b/src/__tests__/SwipeDeck.integration.test.tsx index 5e72f59..93785b3 100644 --- a/src/__tests__/SwipeDeck.integration.test.tsx +++ b/src/__tests__/SwipeDeck.integration.test.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import { fireGestureHandler, getByGestureTestId } from 'react-native-gesture-handler/jest-utils'; -import type { SwipeDirection } from '../index'; +import type { SwipeDeckCardInteractive, SwipeDirection } from '../index'; import { createSwipeDeck, SwipeDeck, SwipeDeckActionMotion, SwipeDeckUndoMotion } from '../index'; @@ -365,6 +365,107 @@ describe('SwipeDeck factory hooks', () => { expect(getVisibleProfileKey).toHaveBeenCalled(); }); + it('enables touchable children only for interactive active cards', async () => { + const ProfileDeck = createSwipeDeck(); + + function expectCardPointerEvents(current: 'box-none' | 'none', next: 'none') { + expect(screen.getByTestId('swipe-deck-card-current')).toHaveStyle({ + pointerEvents: current, + }); + expect(screen.getByTestId('swipe-deck-card-next')).toHaveStyle({ + pointerEvents: next, + }); + } + + function Example({ + interactive, + styledPointerEvents = false, + visibleProfiles = profiles, + }: { + interactive?: SwipeDeckCardInteractive; + styledPointerEvents?: boolean; + visibleProfiles?: readonly Profile[]; + }) { + return ( + + + {({ item }) => {item.name}} + + + ); + } + + const renderResult = await render(); + + expectCardPointerEvents('none', 'none'); + + await renderResult.rerender(); + + expectCardPointerEvents('box-none', 'none'); + + await renderResult.rerender(); + + expectCardPointerEvents('none', 'none'); + + await renderResult.rerender(); + + expectCardPointerEvents('box-none', 'none'); + + await renderResult.rerender( item.id === 'ada'} />); + + expectCardPointerEvents('box-none', 'none'); + + await renderResult.rerender( + item.id === 'ada'} + visibleProfiles={[graceProfile, adaProfile]} + />, + ); + + expectCardPointerEvents('none', 'none'); + }); + + it('allows nested pressables only when the active card is interactive', async () => { + const ProfileDeck = createSwipeDeck(); + const onOpenProfile = jest.fn(); + const user = userEvent.setup(); + + function Example({ interactive = false }: { interactive?: boolean }) { + return ( + + + {({ item }) => ( + onOpenProfile(item.id)} + > + {item.name} + + )} + + + ); + } + + const renderResult = await render(); + + await user.press(screen.getByRole('button', { name: 'Open Ada' })); + + expect(onOpenProfile).not.toHaveBeenCalled(); + + await renderResult.rerender(); + + await user.press(screen.getByRole('button', { name: 'Open Ada' })); + await user.press(screen.getByRole('button', { name: 'Open Grace' })); + + expect(onOpenProfile).toHaveBeenCalledTimes(1); + expect(onOpenProfile).toHaveBeenCalledWith('ada'); + }); + it('updates action gating when disabled changes after mount', async () => { const ProfileDeck = createSwipeDeck(); const user = userEvent.setup(); diff --git a/src/components/SwipeDeckRenderedCard.tsx b/src/components/SwipeDeckRenderedCard.tsx index b2e39dd..c2f47a1 100644 --- a/src/components/SwipeDeckRenderedCard.tsx +++ b/src/components/SwipeDeckRenderedCard.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from 'react'; +import type { ViewStyle } from 'react-native'; import { Fragment } from 'react'; import { StyleSheet } from 'react-native'; @@ -7,7 +8,7 @@ import Animated, { type SharedValue, useAnimatedStyle } from 'react-native-reani import type { SwipeDeckRenderedCardMotionConfig } from '../core/renderedCardMotionTypes'; import type { SwipeRenderTransition } from '../core/rendering'; import type { SwipeWindowDescriptor } from '../core/windowing'; -import type { SwipeDeckCardProps, SwipeRenderInfo } from '../types'; +import type { SwipeDeckCardInteractive, SwipeDeckCardProps, SwipeRenderInfo } from '../types'; import { resolveSwipeDeckDragTranslateY, @@ -17,6 +18,8 @@ import { const STACK_TRANSFORM_ORIGIN: [string, string, number] = ['50%', '0%', 0]; +type SwipeDeckCardPointerEvents = NonNullable; + type SwipeDeckRenderedCardProps = { itemIndex: number; itemKey: string; @@ -36,6 +39,32 @@ type SwipeDeckRenderedCardProps = { motionConfig: SwipeDeckRenderedCardMotionConfig; }; +function resolveSwipeDeckCardInteractive( + interactive: SwipeDeckCardInteractive | undefined, + renderInfo: SwipeRenderInfo, +): boolean { + if (!renderInfo.isActive) { + return false; + } + + if (typeof interactive === 'function') { + return interactive(renderInfo); + } + + return interactive === true; +} + +function resolveSwipeDeckCardPointerEvents( + interactive: SwipeDeckCardInteractive | undefined, + renderInfo: SwipeRenderInfo, +): SwipeDeckCardPointerEvents { + if (resolveSwipeDeckCardInteractive(interactive, renderInfo)) { + return 'box-none'; + } + + return 'none'; +} + export function SwipeDeckRenderedCard({ itemIndex, itemKey, @@ -171,11 +200,16 @@ export function SwipeDeckRenderedCard({ const content = cardSlot.props.children(renderInfo); const cardStyle = cardSlot.props.style; + const cardPointerEvents = resolveSwipeDeckCardPointerEvents( + cardSlot.props.interactive, + renderInfo, + ); + const cardPointerEventsStyle = + cardPointerEvents === 'box-none' ? styles.interactiveCard : styles.nonInteractiveCard; return ( {content} @@ -191,4 +225,10 @@ const styles = StyleSheet.create({ bottom: 0, left: 0, }, + interactiveCard: { + pointerEvents: 'box-none', + }, + nonInteractiveCard: { + pointerEvents: 'none', + }, }); diff --git a/src/index.tsx b/src/index.tsx index 5f92cbf..ecd261d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ export type { SwipeDeckActionMotionRecipe, SwipeDeckActionSpringboardMotionOptions, SwipeDeckActionSpringboardMotionRecipe, + SwipeDeckCardInteractive, SwipeDeckCardProps, SwipeDeckActions, SwipeDeckEventHook, diff --git a/src/types.ts b/src/types.ts index debe10d..2331219 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,8 @@ export type SwipeRenderInfo = { isActive: boolean; }; +export type SwipeDeckCardInteractive = boolean | ((info: SwipeRenderInfo) => boolean); + export type SwipeEvent = { item: T; index: number; @@ -474,6 +476,13 @@ export type SwipeDeckProps = { }; export type SwipeDeckCardProps = { + /** + * Allows touchable children such as buttons or links to receive touches on the active card. + * + * Background cards always remain non-interactive. Use the predicate form when only specific + * items, such as ad cards with CTA buttons, should expose touchable children. + */ + interactive?: SwipeDeckCardInteractive; /** Style applied to the absolute card container. */ style?: StyleProp; /** Renders a card for one item in the bounded window. */ diff --git a/type-tests/useDeckEvent.tsx b/type-tests/useDeckEvent.tsx index 6ed6098..38891e4 100644 --- a/type-tests/useDeckEvent.tsx +++ b/type-tests/useDeckEvent.tsx @@ -1,4 +1,9 @@ -import type { SwipeEvent, SwipeEventSource } from '../src'; +import type { + SwipeDeckCardInteractive, + SwipeEvent, + SwipeEventSource, + SwipeRenderInfo, +} from '../src'; import { createSwipeDeck } from '../src'; @@ -12,6 +17,13 @@ const profile: Profile = { id: 'ada', name: 'Ada' }; function expectType(_value: T): void {} +expectType>(true); +expectType>((info) => { + expectType>(info); + expectType(info.item); + + return info.isActive; +}); expectType('gesture'); expectType('programmatic'); expectType | undefined>(ProfileDeck.useDeckEvent('swipe')); @@ -47,4 +59,26 @@ const rootWithCallbackProp = ( ); +const booleanInteractiveCard = {() => null}; +const predicateInteractiveCard = ( + { + expectType>(info); + expectType(info.item); + + return info.item.id === profile.id; + }} + > + {() => null} + +); + +const invalidInteractiveCard = ( + // @ts-expect-error Card interactive only accepts a boolean or typed predicate. + {() => null} +); + +void booleanInteractiveCard; +void predicateInteractiveCard; +void invalidInteractiveCard; void rootWithCallbackProp;