Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/interactive-card-content.md
Original file line number Diff line number Diff line change
@@ -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
<ProfileDeck.Card interactive={({ item }) => item.type === 'ad'}>
{({ item }) => <AdCard ad={item} onPressCta={() => openAd(item)} />}
</ProfileDeck.Card>
```

Only the active card can become interactive. Background cards remain non-interactive to preserve swipe-deck safety.
9 changes: 5 additions & 4 deletions docs/docs/1.x/en/guide/usage/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ Static `SwipeDeck.Root` accepts the same props except `id`.

## `SwipeDeck.Card` Props

| Prop | Type | Notes |
| ---------- | ----------------------------------------- | --------------------------------------- |
| `style` | `StyleProp<ViewStyle>` | Style applied to the absolute card. |
| `children` | `(info: SwipeRenderInfo<T>) => ReactNode` | Renders one item in the bounded window. |
| Prop | Type | Notes |
| ------------- | ---------------------------------------------------- | --------------------------------------------------- |
| `interactive` | `boolean \| ((info: SwipeRenderInfo<T>) => boolean)` | Enables touchable children on the active card only. |
| `style` | `StyleProp<ViewStyle>` | Style applied to the absolute card. |
| `children` | `(info: SwipeRenderInfo<T>) => ReactNode` | Renders one item in the bounded window. |

`SwipeRenderInfo<T>` includes `item`, `index`, `offset`, `role`, and
`isActive`.
Expand Down
23 changes: 23 additions & 0 deletions docs/docs/1.x/en/guide/usage/basic-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ repeating generics in JSX.
</ProfileDeck.Card>
```

## 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
<ProfileDeck.Card interactive>
{({ item }) => <AdCard ad={item} onPressCta={() => openAd(item)} />}
</ProfileDeck.Card>
```

Use the predicate form when only some items should expose touchable children.

```tsx
<ProfileDeck.Card interactive={({ item }) => item.type === 'ad'}>
{({ item }) => <SwipeVoteCard item={item} />}
</ProfileDeck.Card>
```

`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.
Expand Down
9 changes: 5 additions & 4 deletions docs/docs/1.x/ko/guide/usage/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ Static `SwipeDeck.Root`는 `id`를 제외한 같은 props를 받습니다.

## `SwipeDeck.Card` props

| Prop | Type | 설명 |
| ---------- | ----------------------------------------- | ------------------------------------------ |
| `style` | `StyleProp<ViewStyle>` | Absolute card container에 적용됩니다. |
| `children` | `(info: SwipeRenderInfo<T>) => ReactNode` | Bounded window의 item 하나를 렌더링합니다. |
| Prop | Type | 설명 |
| ------------- | ---------------------------------------------------- | --------------------------------------------------- |
| `interactive` | `boolean \| ((info: SwipeRenderInfo<T>) => boolean)` | Active card 안의 touchable children을 활성화합니다. |
| `style` | `StyleProp<ViewStyle>` | Absolute card container에 적용됩니다. |
| `children` | `(info: SwipeRenderInfo<T>) => ReactNode` | Bounded window의 item 하나를 렌더링합니다. |

`SwipeRenderInfo<T>`는 `item`, `index`, `offset`, `role`, `isActive`를 포함합니다.

Expand Down
23 changes: 23 additions & 0 deletions docs/docs/1.x/ko/guide/usage/basic-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ Factory는 `Root`, `Card`, hook이 같은 item type을 공유하도록 해 JSX
</ProfileDeck.Card>
```

## Interactive Card Content

Card는 기본적으로 non-interactive입니다. 그래야 deck이 swipe gesture를 안전하게 소유할 수 있습니다.
Active card 안에 CTA button이나 link 같은 touchable children이 있다면 `interactive`를 사용하세요.

```tsx
<ProfileDeck.Card interactive>
{({ item }) => <AdCard ad={item} onPressCta={() => openAd(item)} />}
</ProfileDeck.Card>
```

일부 item에서만 touchable children을 열어야 한다면 predicate form을 사용하세요.

```tsx
<ProfileDeck.Card interactive={({ item }) => item.type === 'ad'}>
{({ item }) => <SwipeVoteCard item={item} />}
</ProfileDeck.Card>
```

`interactive`는 active card에만 적용됩니다. 뒤쪽 card는 계속 non-interactive입니다.
Card 내부 carousel이나 scroll view처럼 더 깊은 nested gesture 조정은 raw `pointerEvents`를
노출하기보다 별도의 gesture-coordination API로 다루는 것이 좋습니다.

## Allowed Directions

`allowedDirections`는 dismiss로 받아들일 방향을 제한합니다.
Expand Down
103 changes: 102 additions & 1 deletion src/__tests__/SwipeDeck.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -365,6 +365,107 @@ describe('SwipeDeck factory hooks', () => {
expect(getVisibleProfileKey).toHaveBeenCalled();
});

it('enables touchable children only for interactive active cards', async () => {
const ProfileDeck = createSwipeDeck<Profile>();

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<Profile>;
styledPointerEvents?: boolean;
visibleProfiles?: readonly Profile[];
}) {
return (
<ProfileDeck.Root data={visibleProfiles} getKey={getProfileKey}>
<ProfileDeck.Card
interactive={interactive}
style={styledPointerEvents ? { pointerEvents: 'auto' } : undefined}
>
{({ item }) => <Text>{item.name}</Text>}
</ProfileDeck.Card>
</ProfileDeck.Root>
);
}

const renderResult = await render(<Example />);

expectCardPointerEvents('none', 'none');

await renderResult.rerender(<Example interactive />);

expectCardPointerEvents('box-none', 'none');

await renderResult.rerender(<Example styledPointerEvents />);

expectCardPointerEvents('none', 'none');

await renderResult.rerender(<Example interactive styledPointerEvents />);

expectCardPointerEvents('box-none', 'none');

await renderResult.rerender(<Example interactive={({ item }) => item.id === 'ada'} />);

expectCardPointerEvents('box-none', 'none');

await renderResult.rerender(
<Example
interactive={({ item }) => item.id === 'ada'}
visibleProfiles={[graceProfile, adaProfile]}
/>,
);

expectCardPointerEvents('none', 'none');
});

it('allows nested pressables only when the active card is interactive', async () => {
const ProfileDeck = createSwipeDeck<Profile>();
const onOpenProfile = jest.fn();
const user = userEvent.setup();

function Example({ interactive = false }: { interactive?: boolean }) {
return (
<ProfileDeck.Root data={profiles} getKey={getProfileKey}>
<ProfileDeck.Card interactive={interactive}>
{({ item }) => (
<Pressable
accessibilityLabel={`Open ${item.name}`}
accessibilityRole="button"
onPress={() => onOpenProfile(item.id)}
>
<Text>{item.name}</Text>
</Pressable>
)}
</ProfileDeck.Card>
</ProfileDeck.Root>
);
}

const renderResult = await render(<Example />);

await user.press(screen.getByRole('button', { name: 'Open Ada' }));

expect(onOpenProfile).not.toHaveBeenCalled();

await renderResult.rerender(<Example interactive />);

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<Profile>();
const user = userEvent.setup();
Expand Down
46 changes: 43 additions & 3 deletions src/components/SwipeDeckRenderedCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactElement } from 'react';
import type { ViewStyle } from 'react-native';

import { Fragment } from 'react';
import { StyleSheet } from 'react-native';
Expand All @@ -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,
Expand All @@ -17,6 +18,8 @@ import {

const STACK_TRANSFORM_ORIGIN: [string, string, number] = ['50%', '0%', 0];

type SwipeDeckCardPointerEvents = NonNullable<ViewStyle['pointerEvents']>;

type SwipeDeckRenderedCardProps<T> = {
itemIndex: number;
itemKey: string;
Expand All @@ -36,6 +39,32 @@ type SwipeDeckRenderedCardProps<T> = {
motionConfig: SwipeDeckRenderedCardMotionConfig;
};

function resolveSwipeDeckCardInteractive<T>(
interactive: SwipeDeckCardInteractive<T> | undefined,
renderInfo: SwipeRenderInfo<T>,
): boolean {
if (!renderInfo.isActive) {
return false;
}

if (typeof interactive === 'function') {
return interactive(renderInfo);
}

return interactive === true;
}

function resolveSwipeDeckCardPointerEvents<T>(
interactive: SwipeDeckCardInteractive<T> | undefined,
renderInfo: SwipeRenderInfo<T>,
): SwipeDeckCardPointerEvents {
if (resolveSwipeDeckCardInteractive(interactive, renderInfo)) {
return 'box-none';
}

return 'none';
}

export function SwipeDeckRenderedCard<T>({
itemIndex,
itemKey,
Expand Down Expand Up @@ -171,11 +200,16 @@ export function SwipeDeckRenderedCard<T>({

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 (
<Animated.View
pointerEvents="none"
style={[styles.card, cardStyle, cardAnimatedStyle]}
style={[styles.card, cardStyle, cardAnimatedStyle, cardPointerEventsStyle]}
testID={`swipe-deck-card-${descriptor.role}`}
>
<Fragment key={itemKey}>{content}</Fragment>
Expand All @@ -191,4 +225,10 @@ const styles = StyleSheet.create({
bottom: 0,
left: 0,
},
interactiveCard: {
pointerEvents: 'box-none',
},
nonInteractiveCard: {
pointerEvents: 'none',
},
});
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
SwipeDeckActionMotionRecipe,
SwipeDeckActionSpringboardMotionOptions,
SwipeDeckActionSpringboardMotionRecipe,
SwipeDeckCardInteractive,
SwipeDeckCardProps,
SwipeDeckActions,
SwipeDeckEventHook,
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type SwipeRenderInfo<T> = {
isActive: boolean;
};

export type SwipeDeckCardInteractive<T> = boolean | ((info: SwipeRenderInfo<T>) => boolean);

export type SwipeEvent<T> = {
item: T;
index: number;
Expand Down Expand Up @@ -474,6 +476,13 @@ export type SwipeDeckProps<T> = {
};

export type SwipeDeckCardProps<T> = {
/**
* 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<T>;
/** Style applied to the absolute card container. */
style?: StyleProp<ViewStyle>;
/** Renders a card for one item in the bounded window. */
Expand Down
36 changes: 35 additions & 1 deletion type-tests/useDeckEvent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { SwipeEvent, SwipeEventSource } from '../src';
import type {
SwipeDeckCardInteractive,
SwipeEvent,
SwipeEventSource,
SwipeRenderInfo,
} from '../src';

import { createSwipeDeck } from '../src';

Expand All @@ -12,6 +17,13 @@ const profile: Profile = { id: 'ada', name: 'Ada' };

function expectType<T>(_value: T): void {}

expectType<SwipeDeckCardInteractive<Profile>>(true);
expectType<SwipeDeckCardInteractive<Profile>>((info) => {
expectType<SwipeRenderInfo<Profile>>(info);
expectType<Profile>(info.item);

return info.isActive;
});
expectType<SwipeEventSource>('gesture');
expectType<SwipeEventSource>('programmatic');
expectType<SwipeEvent<Profile> | undefined>(ProfileDeck.useDeckEvent('swipe'));
Expand Down Expand Up @@ -47,4 +59,26 @@ const rootWithCallbackProp = (
</ProfileDeck.Root>
);

const booleanInteractiveCard = <ProfileDeck.Card interactive>{() => null}</ProfileDeck.Card>;
const predicateInteractiveCard = (
<ProfileDeck.Card
interactive={(info) => {
expectType<SwipeRenderInfo<Profile>>(info);
expectType<Profile>(info.item);

return info.item.id === profile.id;
}}
>
{() => null}
</ProfileDeck.Card>
);

const invalidInteractiveCard = (
// @ts-expect-error Card interactive only accepts a boolean or typed predicate.
<ProfileDeck.Card interactive="yes">{() => null}</ProfileDeck.Card>
);

void booleanInteractiveCard;
void predicateInteractiveCard;
void invalidInteractiveCard;
void rootWithCallbackProp;
Loading