diff --git a/packages/design-system-react-native/jest.config.js b/packages/design-system-react-native/jest.config.js index 34959046b..4427e7502 100644 --- a/packages/design-system-react-native/jest.config.js +++ b/packages/design-system-react-native/jest.config.js @@ -43,7 +43,7 @@ module.exports = merge(baseConfig, { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', }, transformIgnorePatterns: [ - 'node_modules/(?!react-native|@react-native|react-native-reanimated|@react-navigation)', + 'node_modules/(?!react-native|@react-native|react-native-reanimated|react-native-nitro-haptics|react-native-nitro-modules|@react-navigation)', ], moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], moduleNameMapper: { diff --git a/packages/design-system-react-native/jest.setup.js b/packages/design-system-react-native/jest.setup.js index 8e8bc8e8d..c47680302 100644 --- a/packages/design-system-react-native/jest.setup.js +++ b/packages/design-system-react-native/jest.setup.js @@ -15,6 +15,14 @@ jest.mock('react-native-svg', () => { }; }); +jest.mock('react-native-nitro-haptics', () => ({ + Haptics: { + impact: jest.fn(), + notification: jest.fn(), + selection: jest.fn(), + }, +}), { virtual: true }); + jest.mock('react-native-reanimated', () => { const Reanimated = require('react-native-reanimated/mock'); diff --git a/packages/design-system-react-native/package.json b/packages/design-system-react-native/package.json index 3714ad876..edb84e2fb 100644 --- a/packages/design-system-react-native/package.json +++ b/packages/design-system-react-native/package.json @@ -79,6 +79,8 @@ "react": "^18.2.0", "react-native": "^0.72.15", "react-native-gesture-handler": "2.12.0", + "react-native-nitro-haptics": "^0.2.3", + "react-native-nitro-modules": "^0.25.0", "react-native-reanimated": "~3.3.0", "react-native-safe-area-context": "4.14.1", "react-native-svg": "^15.10.1", @@ -96,6 +98,8 @@ "react": ">=18.2.0", "react-native": ">=0.72.0", "react-native-gesture-handler": ">=1.10.3", + "react-native-nitro-haptics": ">=0.3.0", + "react-native-nitro-modules": ">=0.25.0", "react-native-reanimated": ">=3.3.0", "react-native-safe-area-context": ">=4.0.0" }, diff --git a/packages/design-system-react-native/src/components/ButtonBase/ButtonBase.tsx b/packages/design-system-react-native/src/components/ButtonBase/ButtonBase.tsx index 1ae187d4b..744e9e286 100644 --- a/packages/design-system-react-native/src/components/ButtonBase/ButtonBase.tsx +++ b/packages/design-system-react-native/src/components/ButtonBase/ButtonBase.tsx @@ -36,6 +36,7 @@ export const ButtonBase = ({ accessibilityRole = 'button', accessibilityActions, onAccessibilityAction, + hapticFeedback, ...props }: ButtonBaseProps) => { const tw = useTailwind(); @@ -93,6 +94,7 @@ export const ButtonBase = ({ return ( void; + /** + * Optional haptic feedback style triggered on press. + * + * @default 'light' + */ + hapticFeedback?: HapticFeedbackStyle; } & Omit< PressableProps, | 'accessibilityRole' diff --git a/packages/design-system-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index 49f16c255..d754acdae 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -101,7 +101,10 @@ export { Card } from './Card'; export type { CardProps } from './Card'; export { ButtonAnimated } from './temp-components/ButtonAnimated'; -export type { ButtonAnimatedProps } from './temp-components/ButtonAnimated'; +export type { + ButtonAnimatedProps, + HapticFeedbackStyle, +} from './temp-components/ButtonAnimated'; export { ButtonBase, ButtonBaseSize } from './ButtonBase'; export type { ButtonBaseProps } from './ButtonBase'; diff --git a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.test.tsx b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.test.tsx index 7f508335a..ae89886ec 100644 --- a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.test.tsx +++ b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.test.tsx @@ -1,9 +1,14 @@ import { render, fireEvent } from '@testing-library/react-native'; import React from 'react'; +import { Haptics } from 'react-native-nitro-haptics'; import { ButtonAnimated } from './ButtonAnimated'; describe('ButtonAnimated', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders correctly', () => { const { getByTestId } = render(); expect(getByTestId('button')).not.toBeNull(); @@ -49,4 +54,26 @@ describe('ButtonAnimated', () => { fireEvent(getByTestId('button'), 'pressIn'); expect(onPressInMock).not.toHaveBeenCalled(); }); + + it('triggers light haptic feedback by default on press', () => { + const { getByTestId } = render(); + fireEvent(getByTestId('button'), 'pressIn'); + expect(Haptics.impact).toHaveBeenCalledWith('light'); + }); + + it('triggers custom haptic feedback style on press', () => { + const { getByTestId } = render( + , + ); + fireEvent(getByTestId('button'), 'pressIn'); + expect(Haptics.impact).toHaveBeenCalledWith('heavy'); + }); + + it('does not trigger haptic feedback when set to none', () => { + const { getByTestId } = render( + , + ); + fireEvent(getByTestId('button'), 'pressIn'); + expect(Haptics.impact).not.toHaveBeenCalled(); + }); }); diff --git a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.tsx b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.tsx index 2b7e88deb..404feb3ea 100644 --- a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.tsx +++ b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import type { GestureResponderEvent } from 'react-native'; import { Pressable } from 'react-native'; +import { Haptics } from 'react-native-nitro-haptics'; import Animated, { useSharedValue, useAnimatedStyle, @@ -18,6 +19,7 @@ export const ButtonAnimated = ({ disabled, style, children, + hapticFeedback = 'light', ...props }: ButtonAnimatedProps) => { const [isPressed, setIsPressed] = useState(false); @@ -31,6 +33,9 @@ export const ButtonAnimated = ({ const onPressInHandler = (event: GestureResponderEvent) => { setIsPressed(true); + if (hapticFeedback !== 'none') { + Haptics.impact(hapticFeedback); + } animation.value = withTiming(0.97, { duration: 100, easing: Easing.bezier(0.3, 0.8, 0.3, 1), diff --git a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.types.ts b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.types.ts index 9d7004be2..318c191ee 100644 --- a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.types.ts +++ b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/ButtonAnimated.types.ts @@ -1,6 +1,26 @@ import type { PressableProps } from 'react-native'; +/** + * Haptic feedback styles available for button press interactions. + * Maps to `Haptics.impact()` styles from react-native-nitro-haptics. + * Use `'none'` to disable haptic feedback. + */ +export type HapticFeedbackStyle = + | 'light' + | 'medium' + | 'heavy' + | 'soft' + | 'rigid' + | 'none'; + /** * ButtonAnimated component props. */ -export type ButtonAnimatedProps = PressableProps; +export type ButtonAnimatedProps = PressableProps & { + /** + * Optional haptic feedback style triggered on press. + * + * @default 'light' + */ + hapticFeedback?: HapticFeedbackStyle; +}; diff --git a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/index.ts b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/index.ts index 6c50f7515..e4adea93a 100644 --- a/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/index.ts +++ b/packages/design-system-react-native/src/components/temp-components/ButtonAnimated/index.ts @@ -1,2 +1,5 @@ export { ButtonAnimated } from './ButtonAnimated'; -export type { ButtonAnimatedProps } from './ButtonAnimated.types'; +export type { + ButtonAnimatedProps, + HapticFeedbackStyle, +} from './ButtonAnimated.types'; diff --git a/yarn.lock b/yarn.lock index 57fab5de1..cc6973fdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3401,6 +3401,8 @@ __metadata: react-native: "npm:^0.72.15" react-native-gesture-handler: "npm:2.12.0" react-native-jazzicon: "npm:^0.1.2" + react-native-nitro-haptics: "npm:^0.2.3" + react-native-nitro-modules: "npm:^0.25.0" react-native-reanimated: "npm:~3.3.0" react-native-safe-area-context: "npm:4.14.1" react-native-svg: "npm:^15.10.1" @@ -3417,6 +3419,8 @@ __metadata: react: ">=18.2.0" react-native: ">=0.72.0" react-native-gesture-handler: ">=1.10.3" + react-native-nitro-haptics: ">=0.3.0" + react-native-nitro-modules: ">=0.25.0" react-native-reanimated: ">=3.3.0" react-native-safe-area-context: ">=4.0.0" languageName: unknown @@ -18194,6 +18198,27 @@ __metadata: languageName: node linkType: hard +"react-native-nitro-haptics@npm:^0.2.3": + version: 0.2.3 + resolution: "react-native-nitro-haptics@npm:0.2.3" + peerDependencies: + react: "*" + react-native: "*" + react-native-nitro-modules: "*" + checksum: 10/7f17b002cd2d28a7b6669a454cb4943b18331fd3b2fd4b10ea3daf6cd051a4fb90c69e80d637968d6b96e7c1ec2adb3e28ccf114b9f0d143c30d5a514eb53d6b + languageName: node + linkType: hard + +"react-native-nitro-modules@npm:^0.25.0": + version: 0.25.2 + resolution: "react-native-nitro-modules@npm:0.25.2" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/45f0c639d21da55302e641cc6a36ffd388efb3233d12d804807f422a37387079bda713e59482ab5a5cf7c0b867a0d68bc6df729c15a2e6035c2b2cbb87a0e28a + languageName: node + linkType: hard + "react-native-reanimated@npm:~3.3.0": version: 3.3.0 resolution: "react-native-reanimated@npm:3.3.0"