Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9f53ad0
feat(toaster): add ToasterApi controller and types
eumaninho54 May 4, 2026
08c333e
feat(toaster): add useToasterViewModel and reanimated styles
eumaninho54 May 4, 2026
4592b5c
feat(toaster): add Toaster component and styles
eumaninho54 May 4, 2026
a5745e3
test(toaster): add ToasterApi, ViewModel and component tests
eumaninho54 May 4, 2026
6886256
docs(storybook): add Toaster stories
eumaninho54 May 4, 2026
0d18ba9
fix(toaster): extract duration and slide offset constants
eumaninho54 May 4, 2026
88b6bdd
fix(toaster): resolve review issues in component and hooks
eumaninho54 May 4, 2026
e199a4c
refactor(toaster): move types to folder, map to constants, simplify vm
eumaninho54 May 4, 2026
2672413
style(toaster): remove unnecessary eslint-disable comment
eumaninho54 May 4, 2026
a6d068c
refactor(toaster): move IToasterApi to own file in types folder
eumaninho54 May 4, 2026
ca28b89
feat(toaster): add drag-to-dismiss, slingshot and close button
eumaninho54 May 4, 2026
a79f424
feat(toaster): spring animation on show, timing on hide
eumaninho54 May 4, 2026
9ec75b7
fix(toaster): reduce drag dismiss threshold from 80 to 40
eumaninho54 May 4, 2026
f1c94f3
fix(toaster): dismiss by drag animates down instead of snapping back
eumaninho54 May 4, 2026
fd10aff
fix(toaster): separate opacity and translateY animations to avoid spr…
eumaninho54 May 4, 2026
9845ec8
style(ui): increase activeOpacity from 0.7 to 0.8
eumaninho54 May 4, 2026
be17e94
style(toaster): improve close button touch feedback
eumaninho54 May 4, 2026
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
29 changes: 29 additions & 0 deletions __mocks__/react-native-gesture-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import { View } from 'react-native';

const makePanGesture = () => {
const gesture = {
onUpdate: () => gesture,
onEnd: () => gesture,
onStart: () => gesture,
onBegin: () => gesture,
onFinalize: () => gesture,
enabled: () => gesture,
minDistance: () => gesture,
activeOffsetX: () => gesture,
activeOffsetY: () => gesture,
};
return gesture;
};

const Gesture = {
Pan: makePanGesture,
};

const GestureDetector = ({ children }: { children: React.ReactNode }) =>
React.createElement(View, null, children);

module.exports = {
Gesture,
GestureDetector,
};
3 changes: 3 additions & 0 deletions __mocks__/react-native-reanimated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const useSharedValue = (init: unknown) => ({ value: init });
const useAnimatedStyle = (fn: () => object) => fn();
const withTiming = (value: unknown) => value;
const withSpring = (value: unknown) => value;
const interpolate = (_value: unknown, _input: unknown, output: number[]) =>
output[0];
const interpolateColor = (_value: unknown, _range: unknown, colors: string[]) =>
colors[0];
const runOnJS = (fn: (...args: unknown[]) => unknown) => fn;
Expand Down Expand Up @@ -42,6 +44,7 @@ module.exports = {
useAnimatedGestureHandler,
withTiming,
withSpring,
interpolate,
interpolateColor,
runOnJS,
runOnUI,
Expand Down
99 changes: 99 additions & 0 deletions example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import React, { useState } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { Toaster, ToasterApi } from 'rubber-duck-ui';
import { styles } from './Toaster.styles';

const Trigger = ({ label, onPress }: { label: string; onPress: () => void }) => (
<TouchableOpacity style={styles.trigger} onPress={onPress}>
<Text style={styles.triggerText}>{label}</Text>
</TouchableOpacity>
);

const DefaultDemo = () => (
<View style={styles.container}>
<Trigger
label="Show Success"
onPress={() =>
ToasterApi.show({
type: 'success',
title: 'Success!',
description: 'Your action was completed.',
})
}
/>
<Trigger
label="Show Error"
onPress={() =>
ToasterApi.show({
type: 'error',
title: 'Error!',
description: 'Something went wrong.',
})
}
/>
<Trigger
label="Show Warning"
onPress={() =>
ToasterApi.show({
type: 'warning',
title: 'Warning!',
description: 'Please check your input.',
})
}
/>
<Trigger
label="Show Info"
onPress={() =>
ToasterApi.show({
type: 'info',
title: 'Info',
description: 'Here is some information.',
})
}
/>
<Toaster />
</View>
);

type ToasterType = 'success' | 'error' | 'warning' | 'info';

const TYPES: ToasterType[] = ['success', 'error', 'warning', 'info'];

const InteractiveDemo = () => {
const [currentIndex, setCurrentIndex] = useState(0);

const showNext = () => {
const type = TYPES[currentIndex % TYPES.length];
ToasterApi.show({
type,
title: `${type.charAt(0).toUpperCase()}${type.slice(1)} toast`,
description: `This is a ${type} notification.`,
});
setCurrentIndex((prev) => (prev + 1) % TYPES.length);
};

return (
<View style={styles.container}>
<Trigger label="Cycle through types" onPress={showNext} />
<Toaster />
</View>
);
};

const meta = {
title: 'Organisms/Toaster',
component: View,
} satisfies Meta<typeof View>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: () => <DefaultDemo />,
};

export const Interactive: Story = {
render: () => <InteractiveDemo />,
};
24 changes: 24 additions & 0 deletions example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { StyleSheet } from 'react-native';

export const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: 24,
},
trigger: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
backgroundColor: '#FFD600',
alignItems: 'center',
width: '100%',
},
triggerText: {
color: '#000',
fontWeight: '600',
fontSize: 14,
},
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@
"node_modules/(?!(react-native|@react-native|lucide-react-native)/)"
],
"moduleNameMapper": {
"^react-native-reanimated$": "<rootDir>/__mocks__/react-native-reanimated.ts"
"^react-native-reanimated$": "<rootDir>/__mocks__/react-native-reanimated.ts",
"^react-native-gesture-handler$": "<rootDir>/__mocks__/react-native-gesture-handler.ts"
},
"modulePathIgnorePatterns": [
"<rootDir>/example/node_modules",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Molecules/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const Button: React.FC<IButtonProps> = (props) => {
return (
<TouchableOpacity
testID={testID}
activeOpacity={disabled ? 1 : 0.7}
activeOpacity={disabled ? 1 : 0.8}
disabled={disabled || isLoading}
onPress={onPressButton}
style={styles.button}>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Molecules/RadioButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const RadioButton: React.FC<IRadioButtonProps> = (props) => {
<TouchableOpacity
onPress={onPress}
style={styles.wrapper}
activeOpacity={0.7}>
activeOpacity={0.8}>
<View style={styles.leftCheckWrapper}>
<Animated.View style={[styles.leftCheck, leftCheckStyle]} />
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const mountRenderItem = (params: IMountRenderItemParams): ListRenderItem<

case 'avatar':
return (
<TouchableOpacity style={styles.itemWrapper} onPress={onPressItem}>
<TouchableOpacity activeOpacity={0.8} style={styles.itemWrapper} onPress={onPressItem}>
<Avatar source={item.avatarSource} size="small_32" />
<View style={styles.textWrapper}>
<Text>{item.title}</Text>
Expand All @@ -67,7 +67,7 @@ export const mountRenderItem = (params: IMountRenderItemParams): ListRenderItem<
case 'icon':
if (!item.iconName) { return null; }
return (
<TouchableOpacity style={styles.itemWrapper} onPress={onPressItem}>
<TouchableOpacity activeOpacity={0.8} style={styles.itemWrapper} onPress={onPressItem}>
<Icon
icon={item.iconName}
size="tiny_20"
Expand Down
70 changes: 70 additions & 0 deletions src/components/Organisms/Toaster/__tests__/Toaster.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen, act } from '@testing-library/react-native';
import { Toaster } from '../index';
import { ToasterApi } from '../controllers';

describe('Toaster', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
ToasterApi.setRef({ current: null });
});

it('Renders without crashing', () => {
const { toJSON } = render(<Toaster />);
expect(toJSON()).toBeTruthy();
});

it('Shows title after ToasterApi.show is called', () => {
render(<Toaster />);

act(() => {
ToasterApi.show({ type: 'success', title: 'Hello World' });
});

expect(screen.getByText('Hello World')).toBeTruthy();
});

it('Shows both title and description when description is provided', () => {
render(<Toaster />);

act(() => {
ToasterApi.show({
type: 'info',
title: 'Info Title',
description: 'Info Description',
});
});

expect(screen.getByText('Info Title')).toBeTruthy();
expect(screen.getByText('Info Description')).toBeTruthy();
});

it('Replaces content when show is called twice', () => {
render(<Toaster />);

act(() => {
ToasterApi.show({ type: 'success', title: 'First' });
});

act(() => {
ToasterApi.show({ type: 'error', title: 'Second' });
jest.advanceTimersByTime(100);
});

expect(screen.queryByText('First')).toBeNull();
expect(screen.getByText('Second')).toBeTruthy();
});

it('ToasterApi.hide does not throw', () => {
render(<Toaster />);

expect(() => {
act(() => {
ToasterApi.hide();
});
}).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEFAULT_TOAST_DURATION_MS = 3000;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const TOAST_DRAG_DISMISS_THRESHOLD = 40;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Tokens } from '../../../../tokens/Tokens.class';

export const TOAST_SLIDE_OFFSET = Tokens.spacer({ key: 'xl' });
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Tokens } from '../../../../tokens/Tokens.class';

export const TOAST_SLINGSHOT_MAX_OFFSET = Tokens.spacer({ key: 'lg' });
9 changes: 9 additions & 0 deletions src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ToasterType } from '../types';
import type { IconName } from '../../../Atoms/Icon/types/IconName';

export const TYPE_ICON_MAP: Record<ToasterType, IconName> = {
success: 'CircleCheck',
error: 'CircleX',
warning: 'TriangleAlert',
info: 'CircleAlert',
};
5 changes: 5 additions & 0 deletions src/components/Organisms/Toaster/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './DEFAULT_TOAST_DURATION_MS';
export * from './TOAST_DRAG_DISMISS_THRESHOLD';
export * from './TOAST_SLINGSHOT_MAX_OFFSET';
export * from './TOAST_SLIDE_OFFSET';
export * from './TYPE_ICON_MAP';
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ToasterApi } from '../index';

const makeRef = (overrides: Partial<{ show: jest.Mock; hide: jest.Mock }> = {}) => ({
current: {
show: jest.fn(),
hide: jest.fn(),
...overrides,
},
});

describe('ToasterApi', () => {
afterEach(() => {
ToasterApi.setRef({ current: null });
jest.clearAllMocks();
});

describe('show', () => {
it('Does not throw when no ref has been set', () => {
expect(() =>
ToasterApi.show({ type: 'success', title: 'Test' }),
).not.toThrow();
});

it('Does not throw when ref.current is null', () => {
ToasterApi.setRef({ current: null });
expect(() =>
ToasterApi.show({ type: 'success', title: 'Test' }),
).not.toThrow();
});

it('Calls ref.current.show with the provided props', () => {
const ref = makeRef();
ToasterApi.setRef(ref);

const props = { type: 'success' as const, title: 'Hello' };
ToasterApi.show(props);

expect(ref.current.show).toHaveBeenCalledTimes(1);
expect(ref.current.show).toHaveBeenCalledWith(props);
});
});

describe('hide', () => {
it('Does not throw when no ref has been set', () => {
expect(() => ToasterApi.hide()).not.toThrow();
});

it('Does not throw when ref.current is null', () => {
ToasterApi.setRef({ current: null });
expect(() => ToasterApi.hide()).not.toThrow();
});

it('Calls ref.current.hide', () => {
const ref = makeRef();
ToasterApi.setRef(ref);

ToasterApi.hide();

expect(ref.current.hide).toHaveBeenCalledTimes(1);
});
});

describe('setRef', () => {
it('Replaces the previous ref', () => {
const first = makeRef();
const second = makeRef();

ToasterApi.setRef(first);
ToasterApi.setRef(second);
ToasterApi.hide();

expect(first.current.hide).not.toHaveBeenCalled();
expect(second.current.hide).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading