Skip to content

Commit d6be63a

Browse files
rohitr-razclaudecursoragentrzp-slash[bot]
authored
feat(rn): add React Native support for motion presets (Fade, Move, Slide, Scale, Morph, Stagger, AnimateInteractions) (#3760)
* feat(rn): add React Native support for BaseMotion engine Adds the native implementation of the shared BaseMotion animation engine that powers Blade's motion presets (Fade, Move, Slide, Morph, Scale, Stagger, AnimateInteractions). - Renames BaseMotion.tsx -> BaseMotion.web.tsx (framer-motion + styled-components) - Adds BaseMotion.native.tsx exporting the same public API (MotionDiv, BaseMotionBox, BaseMotionEnhancerBox, BaseMotionEntryExit) built on react-native-reanimated + styled-components/native - Adds baseMotionInterpreter.native.ts: a declarative-variant -> reanimated interpreter (resolveVariantStyle, parseTransform, useAnimatedVariant) - Reuses shared useMotionVariants from baseMotionUtils.ts (no duplication) - Adds Platform.Select types for animate / MotionMeta.innerRef - Manual exit-before-unmount (runOnJS) replaces AnimatePresence - Triggers: mount + tap at parity; hover/focus/in-view degrade to mount - Morph ships degraded (color/radius/opacity); true FLIP/shared-element and Stagger orchestration deferred to the wrapper migration batch The 7 preset wrappers remain native stubs and will be migrated in a follow-up batch on top of this engine. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix: auto-heal PR — fix lint errors, update snapshots, add changeset Co-authored-by: admin <admin> * fix: add __DEV__ warning for color variant on native View and document as known Phase 1 limitation [resolved by agent] Co-authored-by: admin <admin> * test(web): update snapshots for BaseMotion styled-component rename Renaming BaseMotion.tsx -> BaseMotion.web.tsx changes the styled-components generated id (BaseMotion__StyledDiv -> BaseMotionweb__StyledDiv), which surfaces in the web/SSR snapshots of Stagger and ChatInput. Regenerate those snapshots; the change is a cosmetic class-name id only, functionally identical. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix: auto-heal PR via Slash PR Healer - Fix TypeScript compilation errors in baseMotionInterpreter.native.ts: replaced incorrect '(t: number) => number' easing type with EasingFactoryFn from react-native-reanimated (matches existing codebase pattern in makeBezier.native.ts and Rotate.native.tsx) - Revert all unrelated changes: StepGroup native stubs, blade-svelte CounterInput/Button/MinusIcon deletions, figma pattern creator changes, CI workflow modifications, yarn.lock, and 3 unrelated changesets Co-authored-by: admin <admin> * fix: auto-heal PR via Slash PR Healer - Fix TypeScript build errors in baseMotionInterpreter.native.ts: Easing.bezier returns EasingFunctionFactory while Easing.ease returns EasingFunction — type ReanimatedEasing now accepts both. Also fix array element narrowing for framer-motion ease values. - Fix changeset formatting: use double quotes in blade-svelte-counterinput-component.md (changesets require double quotes) - Update snapshots for Stagger.web, Stagger.ssr, ChatInput.ssr tests after BaseMotion.tsx → BaseMotion.web.tsx rename changed styled-components class name hash Co-authored-by: admin <admin> * feat(rn): add React Native support for motion presets Add native implementations for the 7 motion presets on top of the native BaseMotion engine: - Fade / Move / Slide / Scale — dedicated .native.tsx (bezier-array easing; Slide handles 100vw/vh + keyframe collapse; Scale maps isHighlighted). - Morph — degraded native tween (color/radius/opacity; no geometric FLIP) with a platform-resolved AnimatePresence shim so the story bundles on RN. - Stagger — hand-built per-child delay orchestration via context; adds withDelay to baseMotionInterpreter.native (also fixes standalone delay props on native). - AnimateInteractions — press-and-hold replaces hover; publishes isInteracting via context which BaseMotion.native reads to drive on-animate-interactions children. useFocusWithin.ts renamed to .web.ts. Also: - Un-exclude motion stories from RN Storybook + swap web-only story nodes for cross-platform equivalents; add native no-op StoryRouter. - patch-storybook-hermes.js: neutralise `new RegExp("...\p{}...")` string-form Unicode property escapes in minified Storybook browser chunks (es-toolkit CASE_SPLIT + jsdoc identifier regexes) that crash Hermes on RN 0.72. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: auto-heal PR via Slash PR Healer Co-authored-by: admin <admin> * fix: auto-heal PR via Slash PR Healer Co-authored-by: admin <admin> * fix(rn): make Stagger cascade correctly on native Flatten Fragment children so Storybook args get per-child targets, drive enter/exit with timed visibility (forward, 80ms like web), and isolate Metro from sibling worktrees. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: address all review comments on PR #3738 [resolved by agent] Co-authored-by: admin <admin> * fix: resolve review comments — use parseUnit for scalar values, bump changeset to minor [resolved by agent] Co-authored-by: admin <admin> * fix(rn): resolve BaseMotion CI lint and native test failures Move interactionHandleRef before use, prettier-format BaseMotion native sources, and re-wrap rerender calls with BladeProvider so theme context is preserved. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: auto-heal PR via Slash PR Healer - Fix prettier formatting in baseMotionInterpreter.native.ts, test files - Fix interactionHandleRef used-before-defined in BaseMotion.native.tsx - Fix renderWithTheme.native.tsx to use wrapper option for rerender theme context Co-authored-by: admin <admin> * fix(rn): Morph layoutId FLIP so text morphs from center to top Approximate web framer shared-element morph on native: stash window bounds on unmount and FLIP the next Morph with the same layoutId from that pose into its natural layout (Morph On Text: card center → page top). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(rn): enable OnRouteChange stories with state-based stepper Replace the native StepperRouterExample stub with a state-driven step UI so motion presets can demo route transitions without react-router, and nest Stagger under ChipGroup so chips cascade instead of animating in lockstep. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: resolve PR review comments — memory leaks, type safety, stale closures, regex escaping, docs [resolved by agent] Co-authored-by: admin <admin> * fix: auto-heal PR via Slash PR Healer Co-authored-by: admin <admin> * fix: auto-heal PR via Slash PR Healer Co-authored-by: admin <admin> * fix: auto-heal PR via Slash PR Healer - move early return after all hooks in Morph.native.tsx Co-authored-by: admin <admin> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: rzp-slash[bot] <swe-agent@razorpay.com>
1 parent bb934b3 commit d6be63a

42 files changed

Lines changed: 3820 additions & 115 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@razorpay/blade": minor
3+
---
4+
5+
feat(rn): add React Native support for motion presets — Fade, Move, Slide, Scale, Morph, Stagger, and AnimateInteractions (built on the native BaseMotion engine)

packages/blade/.storybook/react-native/main.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,12 @@ const WEB_ONLY_STORIES = [
88
'DatePicker',
99
'DetailedView',
1010
'DrawerExamples',
11-
'Fade',
1211
'ListView',
1312
'ListViewFigmaExample',
1413
'Menu',
15-
'Morph',
16-
'Move',
1714
'Settings',
1815
'SideNav',
19-
'Slide',
2016
'RazorSense',
21-
'Stagger',
2217
'TableEditableCellAPI',
2318
'TableFigmaExample',
2419
'TableStripedRows',

packages/blade/.storybook/react-native/storybook.requires.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ const normalizedStories = [
1010
{
1111
titlePrefix: "",
1212
directory: "./src",
13-
files: "**/!(_KitchenSink|*.test|MotionRecipes|BottomNav|ChatInput|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview)*.stories.?(ts|tsx|js|jsx)",
14-
importPathMatcher: /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|ChatInput|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/,
13+
files: "**/!(_KitchenSink|*.test|MotionRecipes|BottomNav|DatePicker|DetailedView|DrawerExamples|ListView|ListViewFigmaExample|Menu|Settings|SideNav|RazorSense|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|NativeChatMessageDemo|CreationView|TopNav|LightBox|Preview)*.stories.?(ts|tsx|js|jsx)",
14+
importPathMatcher: /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|DatePicker|DetailedView|DrawerExamples|ListView|ListViewFigmaExample|Menu|Settings|SideNav|RazorSense|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|NativeChatMessageDemo|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/,
1515
// @ts-ignore
1616
req: require.context(
1717
'../../src',
1818
true,
19-
/^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|ChatInput|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/
19+
/^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|DatePicker|DetailedView|DrawerExamples|ListView|ListViewFigmaExample|Menu|Settings|SideNav|RazorSense|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|NativeChatMessageDemo|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/
2020
),
2121
},
2222
{

packages/blade/metro.config.js

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const path = require('path');
22
const fs = require('fs');
33
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
44
const { withStorybook } = require('@storybook/react-native/metro/withStorybook');
5+
const exclusionList = require('metro-config/src/defaults/exclusionList');
56

67
/**
78
* Metro configuration
@@ -10,10 +11,22 @@ const { withStorybook } = require('@storybook/react-native/metro/withStorybook')
1011
* @type {import('metro-config').MetroConfig}
1112
*/
1213

13-
const mocksDir = path.resolve(__dirname, '.storybook/react-native/mocks');
14-
const nodeModulesDir = path.resolve(__dirname, 'node_modules');
14+
const projectRoot = __dirname;
15+
const mocksDir = path.resolve(projectRoot, '.storybook/react-native/mocks');
16+
const nodeModulesDir = path.resolve(projectRoot, 'node_modules');
1517
const realNodeModulesDir = fs.realpathSync(nodeModulesDir);
1618

19+
// Sibling git worktrees under `.claude/worktrees/*` share the same relative paths
20+
// (`src/components/Stagger/Stagger.native.tsx`, etc.). If Metro's haste map ever sees more than
21+
// one worktree it will pick an arbitrary copy (we observed LineChart's stub Stagger being
22+
// bundled while cwd was MotionPresets). Block every worktree except this one.
23+
const thisWorktreeName = path.basename(path.resolve(projectRoot, '../..')); // e.g. MotionPresets
24+
// Escape regex special characters in the worktree name so they're treated literally.
25+
const escapedWorktreeName = thisWorktreeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26+
const siblingWorktreesBlock = new RegExp(
27+
`/\\.claude/worktrees/(?!${escapedWorktreeName}(?:/|$))[^/]+/`,
28+
);
29+
1730
// Metro 0.76 doesn't fully support package.json "exports" for subpath imports.
1831
// Manually resolve subpath exports for storybook packages.
1932
const pkgJsonCache = new Map();
@@ -52,7 +65,10 @@ function resolveSubpathExport(moduleName) {
5265
const isWorktreeSymlink = realNodeModulesDir !== nodeModulesDir;
5366

5467
const config = {
55-
watchFolders: isWorktreeSymlink ? [realNodeModulesDir] : [nodeModulesDir],
68+
projectRoot,
69+
watchFolders: isWorktreeSymlink
70+
? [projectRoot, realNodeModulesDir]
71+
: [projectRoot, nodeModulesDir],
5672
transformer: {
5773
unstable_allowRequireContext: true,
5874
getTransformOptions: async () => ({
@@ -63,6 +79,14 @@ const config = {
6379
}),
6480
},
6581
resolver: {
82+
blockList: exclusionList([siblingWorktreesBlock, /\/__tests__\/.*/]),
83+
// Disabled because this repo uses yarn workspaces with hoisted node_modules. Metro's
84+
// hierarchical lookup would traverse parent directories and could resolve modules from
85+
// sibling worktree node_modules (when running inside a `.claude/worktrees/*` symlink),
86+
// picking up stale or incompatible copies. With `nodeModulesPaths` explicitly set above
87+
// and `unstable_enableSymlinks: true`, Metro resolves all dependencies from the correct
88+
// node_modules without needing parent-directory traversal.
89+
disableHierarchicalLookup: true,
6690
unstable_enableSymlinks: true,
6791
nodeModulesPaths: [nodeModulesDir],
6892
resolverMainFields: ['react-native', 'browser', 'main'],
@@ -113,8 +137,8 @@ const config = {
113137
},
114138
};
115139

116-
module.exports = withStorybook(mergeConfig(getDefaultConfig(__dirname), config), {
117-
configPath: path.resolve(__dirname, '.storybook/react-native'),
140+
module.exports = withStorybook(mergeConfig(getDefaultConfig(projectRoot), config), {
141+
configPath: path.resolve(projectRoot, '.storybook/react-native'),
118142
enabled: true,
119143
docTools: false,
120144
});

packages/blade/scripts/patch-storybook-hermes.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,45 @@ for (const file of allFiles) {
119119
"const rUnicodeLower = '[a-z]';",
120120
);
121121

122+
// Case 5: `new RegExp("…\p{…}…", "…u…")` string form.
123+
// Minified Storybook browser chunks build these patterns dynamically (with mangled var
124+
// names), so Case 2 (literal /…/ only) and Case 3 (matches specific var names) both miss
125+
// them. Hermes rejects \p{} escapes even inside `new RegExp` with the `u` flag, so we
126+
// neutralise them by pattern *content* and emit an ASCII-equivalent literal instead.
127+
//
128+
// **Limitation:** This uses content-based matching (`body.includes('\\p{Lu}')`) rather than
129+
// identifying the source library. Any new RegExp with these Unicode properties from any
130+
// library bundled into Storybook's browser chunks would be replaced with the es-toolkit
131+
// CASE_SPLIT_PATTERN. The "Unknown \p{} pattern" fall-through below mitigates this for
132+
// unrecognised patterns, but known patterns (Lu/Ll, ID_Start, ID_Continue) are matched by
133+
// content alone. If a non-Storybook library starts using these exact patterns, add a more
134+
// specific match (e.g. checking surrounding variable names or file origin) before this block.
135+
content = content.replace(
136+
/new RegExp\(\s*"((?:[^"\\]|\\.)*)"\s*(?:,\s*"([a-z]*)")?\s*\)/g,
137+
(match, body, flags) => {
138+
// `body` is the raw source between the quotes, so \p{ appears as the two chars "\\p{".
139+
if (!body.includes('\\p{')) return match;
140+
// ASCII fallbacks don't need the `u` flag (and keeping it would re-enable strict escapes).
141+
const safeFlags = (flags || '').replace(/u/g, '');
142+
// es-toolkit CASE_SPLIT_PATTERN (word/case splitting)
143+
if (body.includes('\\p{Lu}') && body.includes('\\p{Ll}')) {
144+
return `/[A-Z]?[a-z]+|[0-9]+|[A-Z]+(?![a-z])/${safeFlags}`;
145+
}
146+
// jsdoc-type-pratt-parser identifierStartRegex
147+
if (body.includes('\\p{ID_Start}')) {
148+
return `/[$_a-zA-Z]/${safeFlags}`;
149+
}
150+
// jsdoc-type-pratt-parser identifierContinueRegex
151+
if (body.includes('\\p{ID_Continue}')) {
152+
return `/[$_a-zA-Z0-9]/${safeFlags}`;
153+
}
154+
// Unknown \p{} pattern — leave it untouched rather than risk corrupting an unrelated
155+
// library (zod, pdfjs, …) that shares node_modules with the web build. If a new
156+
// Storybook-bundled pattern starts crashing Hermes, add an explicit branch above.
157+
return match;
158+
},
159+
);
160+
122161
if (content !== orig) {
123162
fs.writeFileSync(file, content);
124163
console.log('[patch-storybook-hermes] Patched:', file);
Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,66 @@
1+
import React from 'react';
2+
import { Pressable } from 'react-native';
3+
import { AnimateInteractionsContext } from './AnimateInteractionsProvider';
14
import type { AnimateInteractionsProps } from './types';
2-
import { Text } from '~components/Typography';
3-
import { throwBladeError } from '~utils/logger';
45

5-
const AnimateInteractions = (_props: AnimateInteractionsProps): React.ReactElement => {
6-
throwBladeError({
7-
message: 'AnimateInteractions is not yet implemented for native',
8-
moduleName: 'AnimateInteractions',
9-
});
6+
/**
7+
* ## AnimateInteractions (native)
8+
*
9+
* AnimateInteractions is the utility preset that animates children when the parent is interacted
10+
* with — the CSS `.parent:hover .child {}` analogue.
11+
*
12+
* On web this is driven by hover / focus-within (framer control propagation). Touch devices have
13+
* neither hover nor DOM focus-within, so the closest analogue is **press-and-hold**: children
14+
* animate while the parent is pressed (`onPressIn` → `'animate'`, `onPressOut` → `'exit'`), exactly
15+
* mirroring web `onFocusWithin`/`onBlurWithin`.
16+
*
17+
* The parent does not self-animate. It publishes the interaction state through
18+
* `AnimateInteractionsContext`; the shared `BaseMotion.native` engine reads `isInteracting` to
19+
* drive every `on-animate-interactions` descendant. `motionTriggers` (hover / focus) is accepted
20+
* for API parity but has no native meaning and is intentionally ignored.
21+
*
22+
* ### Known layout divergence from web
23+
*
24+
* On web, `AnimateInteractions` uses `BaseMotionEnhancerBox` which clones the child element and
25+
* applies interaction handlers in-place (no extra DOM node). On native, we wrap children in a
26+
* `<Pressable>` because React Native has no equivalent of DOM event delegation — `onPressIn` /
27+
* `onPressOut` must be attached to a host component. This adds an extra `View` node to the layout
28+
* tree, which may cause minor flex/layout differences versus web. Consumers porting layouts that
29+
* depend on the enhancer not adding a wrapper should account for this extra nesting.
30+
*
31+
* ### Usage
32+
*
33+
* ```jsx
34+
* <AnimateInteractions> // <-- press-and-hold this
35+
* <Box>
36+
* <Move motionTriggers={['on-animate-interactions']}> // <-- this animates in
37+
* <Box />
38+
* </Move>
39+
* </Box>
40+
* </AnimateInteractions>
41+
* ```
42+
*/
43+
const AnimateInteractions = ({
44+
children,
45+
// Accepted for cross-platform API parity; hover / focus don't exist on touch devices so this is
46+
// intentionally ignored on native.
47+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
48+
motionTriggers = ['hover'],
49+
}: AnimateInteractionsProps): React.ReactElement => {
50+
const [isInteracting, setIsInteracting] = React.useState(false);
1051

11-
return <Text>AnimateInteractions Component is not available for Native mobile apps.</Text>;
52+
return (
53+
<AnimateInteractionsContext.Provider
54+
value={{ isInsideAnimateInteractionsContainer: true, isInteracting }}
55+
>
56+
<Pressable
57+
onPressIn={() => setIsInteracting(true)}
58+
onPressOut={() => setIsInteracting(false)}
59+
>
60+
{children}
61+
</Pressable>
62+
</AnimateInteractionsContext.Provider>
63+
);
1264
};
1365

1466
export { AnimateInteractions };

packages/blade/src/components/AnimateInteractions/AnimateInteractions.stories.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,7 @@ ShowOverlayOnImageHover.args = {
113113
elevation="midRaised"
114114
overflow="hidden"
115115
>
116-
<img
117-
src="https://live.staticflickr.com/65535/54142149265_a0cf25efa4_b.jpg"
118-
width=""
119-
height="300px"
120-
alt="Hubble Takes a Look at Tangled Galaxies"
121-
/>
116+
<Box width="300px" height="300px" backgroundColor="surface.background.gray.subtle" />
122117
<Fade motionTriggers={['on-animate-interactions']}>
123118
<Box
124119
display="flex"
@@ -173,11 +168,11 @@ ScaleOnParentHoverAndFocus.args = {
173168

174169
<Box>
175170
<Scale motionTriggers={['on-animate-interactions']}>
176-
<img
177-
src="https://live.staticflickr.com/65535/54142149265_a0cf25efa4_b.jpg"
178-
width=""
171+
<Box
172+
width="140px"
179173
height="140px"
180-
alt="Hubble Takes a Look at Tangled Galaxies"
174+
backgroundColor="surface.background.gray.subtle"
175+
borderRadius="medium"
181176
/>
182177
</Scale>
183178
</Box>

packages/blade/src/components/AnimateInteractions/AnimateInteractionsProvider.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,21 @@ import React from 'react';
22

33
type AnimateInteractionsContextType = {
44
isInsideAnimateInteractionsContainer: boolean;
5+
/**
6+
* Native-only: current interaction state of the parent `AnimateInteractions` container.
7+
*
8+
* On native there is no hover / DOM focus-within, so `AnimateInteractions.native` publishes a
9+
* press-and-hold flag here and the shared `BaseMotion.native` engine reads it to drive every
10+
* `on-animate-interactions` descendant towards `'animate'` (interacting) or `'exit'` (idle).
11+
*
12+
* Web ignores this field — framer control propagation handles descendant animation there.
13+
*/
14+
isInteracting?: boolean;
515
};
616

717
const AnimateInteractionsContext = React.createContext<AnimateInteractionsContextType>({
818
isInsideAnimateInteractionsContainer: false,
19+
isInteracting: false,
920
});
1021

1122
const useAnimateInteractions = (): AnimateInteractionsContextType => {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React from 'react';
2+
import { Pressable } from 'react-native';
3+
import { fireEvent } from '@testing-library/react-native';
4+
import { AnimateInteractions } from '../index';
5+
import renderWithTheme from '~utils/testing/renderWithTheme.native';
6+
import { Box } from '~components/Box';
7+
import { Fade } from '~components/Fade';
8+
import { Move } from '~components/Move';
9+
import { Text } from '~components/Typography';
10+
11+
beforeAll(() => jest.spyOn(console, 'error').mockImplementation());
12+
afterAll(() => jest.restoreAllMocks());
13+
14+
describe('<AnimateInteractions /> (native)', () => {
15+
it('should render its children as expected', () => {
16+
const { toJSON } = renderWithTheme(
17+
<AnimateInteractions>
18+
<Box>
19+
<Text>Test</Text>
20+
<Fade motionTriggers={['on-animate-interactions']}>
21+
<Text>Reveal me</Text>
22+
</Fade>
23+
</Box>
24+
</AnimateInteractions>,
25+
);
26+
expect(toJSON()).toMatchSnapshot();
27+
});
28+
29+
it('should wrap children in a Pressable interaction surface', () => {
30+
const { UNSAFE_getByType: unsafe_getByType, getByText } = renderWithTheme(
31+
<AnimateInteractions>
32+
<Box>
33+
<Text>Press me for magic</Text>
34+
<Fade motionTriggers={['on-animate-interactions']}>
35+
<Text>Reveal me</Text>
36+
</Fade>
37+
</Box>
38+
</AnimateInteractions>,
39+
);
40+
41+
expect(unsafe_getByType(Pressable)).toBeTruthy();
42+
expect(getByText('Press me for magic')).toBeTruthy();
43+
});
44+
45+
it('should drive descendants on press-and-hold without throwing', () => {
46+
const { UNSAFE_getByType: unsafe_getByType, getByText } = renderWithTheme(
47+
<AnimateInteractions>
48+
<Box>
49+
<Text>Press me</Text>
50+
<Move motionTriggers={['on-animate-interactions']}>
51+
<Text>I move in on interaction</Text>
52+
</Move>
53+
</Box>
54+
</AnimateInteractions>,
55+
);
56+
57+
const pressable = unsafe_getByType(Pressable);
58+
59+
// Press-and-hold → children animate to `'animate'`; release → back to `'exit'`.
60+
expect(() => {
61+
fireEvent(pressable, 'pressIn');
62+
fireEvent(pressable, 'pressOut');
63+
}).not.toThrow();
64+
65+
// Node stays mounted throughout the interaction (deep reanimated style assertions are brittle,
66+
// so we assert render stability instead).
67+
expect(getByText('I move in on interaction')).toBeTruthy();
68+
});
69+
70+
it('should ignore motionTriggers on native (API parity only)', () => {
71+
const { toJSON } = renderWithTheme(
72+
<AnimateInteractions motionTriggers={['hover', 'focus']}>
73+
<Box>
74+
<Fade motionTriggers={['on-animate-interactions']}>
75+
<Text>Reveal me</Text>
76+
</Fade>
77+
</Box>
78+
</AnimateInteractions>,
79+
);
80+
expect(toJSON()).toMatchSnapshot();
81+
});
82+
});

0 commit comments

Comments
 (0)