From 9f53ad07887c211dfbb0d94be5904243c20425fb Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:07:08 -0300 Subject: [PATCH 01/17] feat(toaster): add ToasterApi controller and types Co-Authored-By: Claude Sonnet 4.6 --- .../Toaster/controllers/ToasterApi/index.ts | 18 ++++++++++++++++++ .../Toaster/controllers/ToasterApi/types.ts | 6 ++++++ .../Organisms/Toaster/controllers/index.ts | 2 ++ .../Organisms/Toaster/types/IToasterProps.ts | 1 + .../Toaster/types/IToasterRefProps.ts | 8 ++++++++ .../Organisms/Toaster/types/ToasterType.ts | 1 + .../Organisms/Toaster/types/index.ts | 3 +++ 7 files changed, 39 insertions(+) create mode 100644 src/components/Organisms/Toaster/controllers/ToasterApi/index.ts create mode 100644 src/components/Organisms/Toaster/controllers/ToasterApi/types.ts create mode 100644 src/components/Organisms/Toaster/controllers/index.ts create mode 100644 src/components/Organisms/Toaster/types/IToasterProps.ts create mode 100644 src/components/Organisms/Toaster/types/IToasterRefProps.ts create mode 100644 src/components/Organisms/Toaster/types/ToasterType.ts create mode 100644 src/components/Organisms/Toaster/types/index.ts diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/index.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/index.ts new file mode 100644 index 0000000..2e7644b --- /dev/null +++ b/src/components/Organisms/Toaster/controllers/ToasterApi/index.ts @@ -0,0 +1,18 @@ +import type { RefObject } from 'react'; +import type { IToasterApi } from './types'; +import type { IToasterRefProps } from '../../types'; + +export class ToasterApi { + private static ref: RefObject | null = null; + + static setRef(ref: RefObject) { + this.ref = ref; + } + + static show = (props: IToasterRefProps) => + this.ref?.current?.show(props); + + static hide = () => { + this.ref?.current?.hide(); + }; +} diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/types.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/types.ts new file mode 100644 index 0000000..dd82cb7 --- /dev/null +++ b/src/components/Organisms/Toaster/controllers/ToasterApi/types.ts @@ -0,0 +1,6 @@ +import type { IToasterRefProps } from '../../types'; + +export interface IToasterApi { + show: (props: IToasterRefProps) => void; + hide: () => void; +} diff --git a/src/components/Organisms/Toaster/controllers/index.ts b/src/components/Organisms/Toaster/controllers/index.ts new file mode 100644 index 0000000..1efc333 --- /dev/null +++ b/src/components/Organisms/Toaster/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './ToasterApi'; +export * from './ToasterApi/types'; diff --git a/src/components/Organisms/Toaster/types/IToasterProps.ts b/src/components/Organisms/Toaster/types/IToasterProps.ts new file mode 100644 index 0000000..b201b01 --- /dev/null +++ b/src/components/Organisms/Toaster/types/IToasterProps.ts @@ -0,0 +1 @@ +export interface IToasterProps {} diff --git a/src/components/Organisms/Toaster/types/IToasterRefProps.ts b/src/components/Organisms/Toaster/types/IToasterRefProps.ts new file mode 100644 index 0000000..85f5bed --- /dev/null +++ b/src/components/Organisms/Toaster/types/IToasterRefProps.ts @@ -0,0 +1,8 @@ +import type { ToasterType } from './ToasterType'; + +export interface IToasterRefProps { + type: ToasterType; + title: string; + description?: string; + duration?: number; +} diff --git a/src/components/Organisms/Toaster/types/ToasterType.ts b/src/components/Organisms/Toaster/types/ToasterType.ts new file mode 100644 index 0000000..1cfa1a4 --- /dev/null +++ b/src/components/Organisms/Toaster/types/ToasterType.ts @@ -0,0 +1 @@ +export type ToasterType = 'success' | 'error' | 'warning' | 'info'; diff --git a/src/components/Organisms/Toaster/types/index.ts b/src/components/Organisms/Toaster/types/index.ts new file mode 100644 index 0000000..5661fb8 --- /dev/null +++ b/src/components/Organisms/Toaster/types/index.ts @@ -0,0 +1,3 @@ +export * from './ToasterType'; +export * from './IToasterRefProps'; +export * from './IToasterProps'; From 08c333e22ad1ddd19a502e273df1167b11e96a79 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:07:37 -0300 Subject: [PATCH 02/17] feat(toaster): add useToasterViewModel and reanimated styles Co-Authored-By: Claude Sonnet 4.6 --- __mocks__/react-native-reanimated.ts | 3 ++ .../Organisms/Toaster/hooks/index.ts | 2 + .../hooks/useReanimatedStyles/index.ts | 28 +++++++++++ .../hooks/useToasterViewModel/index.ts | 47 +++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 src/components/Organisms/Toaster/hooks/index.ts create mode 100644 src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts create mode 100644 src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts diff --git a/__mocks__/react-native-reanimated.ts b/__mocks__/react-native-reanimated.ts index 5c916ae..d8ff5f3 100644 --- a/__mocks__/react-native-reanimated.ts +++ b/__mocks__/react-native-reanimated.ts @@ -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; @@ -42,6 +44,7 @@ module.exports = { useAnimatedGestureHandler, withTiming, withSpring, + interpolate, interpolateColor, runOnJS, runOnUI, diff --git a/src/components/Organisms/Toaster/hooks/index.ts b/src/components/Organisms/Toaster/hooks/index.ts new file mode 100644 index 0000000..8a50dc4 --- /dev/null +++ b/src/components/Organisms/Toaster/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useToasterViewModel'; +export * from './useReanimatedStyles'; diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts new file mode 100644 index 0000000..0170fd4 --- /dev/null +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +export const useReanimatedStyles = ({ isVisible }: { isVisible: boolean }) => { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withTiming(isVisible ? 1 : 0, { duration: 250 }); + }, [isVisible, progress]); + + const container = useAnimatedStyle(() => ({ + opacity: progress.value, + transform: [ + { + translateY: interpolate(progress.value, [0, 1], [40, 0]), + }, + ], + }), []); + + return { + container, + }; +}; diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts new file mode 100644 index 0000000..315d2c1 --- /dev/null +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts @@ -0,0 +1,47 @@ +import type { IToasterApi } from '../../controllers/ToasterApi/types'; +import type { IToasterRefProps } from '../../types'; +import { useState, useRef, useCallback, useEffect } from 'react'; +import { ToasterApi } from '../../controllers'; + +export const useToasterViewModel = () => { + const [toasterProps, setToasterPropsState] = useState(); + const [isVisible, setIsVisible] = useState(false); + + const timeoutRef = useRef | null>(null); + const imperativeRef = useRef(null); + + const clearTimer = () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const show = useCallback((props: IToasterRefProps) => { + clearTimer(); + setToasterPropsState(props); + setIsVisible(true); + timeoutRef.current = setTimeout(() => setIsVisible(false), props.duration ?? 3000); + }, []); + + const hide = useCallback(() => { + clearTimer(); + setIsVisible(false); + }, []); + + useEffect(() => { + imperativeRef.current = { show, hide }; + ToasterApi.setRef(imperativeRef); + }, [show, hide]); + + useEffect(() => { + return () => { + clearTimer(); + }; + }, []); + + return { + toasterProps, + isVisible, + }; +}; From 4592b5c4f07b60843c1d7f55d3c030fff3e444f8 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:08:34 -0300 Subject: [PATCH 03/17] feat(toaster): add Toaster component and styles Co-Authored-By: Claude Sonnet 4.6 --- src/components/Organisms/Toaster/index.tsx | 55 ++++++++++++++++++++++ src/components/Organisms/Toaster/styles.ts | 40 ++++++++++++++++ src/components/Organisms/index.ts | 1 + 3 files changed, 96 insertions(+) create mode 100644 src/components/Organisms/Toaster/index.tsx create mode 100644 src/components/Organisms/Toaster/styles.ts diff --git a/src/components/Organisms/Toaster/index.tsx b/src/components/Organisms/Toaster/index.tsx new file mode 100644 index 0000000..28c1eac --- /dev/null +++ b/src/components/Organisms/Toaster/index.tsx @@ -0,0 +1,55 @@ +import type { IToasterProps } from './types'; +import type { IconName } from '../../Atoms/Icon/types/IconName'; +import type { ToasterType } from './types'; +import React from 'react'; +import { View } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { useStyles } from './styles'; +import { useToasterViewModel, useReanimatedStyles } from './hooks'; +import { Text } from '../../Atoms/Text'; +import { Icon } from '../../Atoms/Icon'; + +const TYPE_ICON_MAP: Record = { + success: 'CircleCheck', + error: 'CircleX', + warning: 'TriangleAlert', + info: 'CircleAlert', +}; + +export const Toaster: React.FC = () => { + const { toasterProps, isVisible } = useToasterViewModel(); + const styles = useStyles({ type: toasterProps?.type }); + const reanimatedStyles = useReanimatedStyles({ isVisible }); + + if (!toasterProps) { + return null; + } + + return ( + + + + + {toasterProps.title} + + {toasterProps.description + ? + + {toasterProps.description} + + : null + } + + + ); +}; + +export type { IToasterApi } from './controllers/ToasterApi/types'; +export type { IToasterRefProps, ToasterType } from './types'; +export { ToasterApi } from './controllers'; diff --git a/src/components/Organisms/Toaster/styles.ts b/src/components/Organisms/Toaster/styles.ts new file mode 100644 index 0000000..c22dfb3 --- /dev/null +++ b/src/components/Organisms/Toaster/styles.ts @@ -0,0 +1,40 @@ +import type { ToasterType } from './types'; +import { StyleSheet } from 'react-native'; +import { useRubberDuckStore } from '../../../store'; +import { Tokens } from '../../../tokens/Tokens.class'; + +export const useStyles = ({ type }: { type?: ToasterType }) => { + const colors = useRubberDuckStore((s) => s.colors); + + const backgroundColorMap: Record = { + success: colors.success, + error: colors.error, + warning: colors.warning, + info: colors.info, + }; + + const backgroundColor = type ? backgroundColorMap[type] : colors.info; + + return StyleSheet.create({ + container: { + position: 'absolute', + bottom: Tokens.spacer({ key: 'lg' }), + left: Tokens.spacer({ key: 'md' }), + right: Tokens.spacer({ key: 'md' }), + padding: Tokens.spacer({ key: 'md' }), + borderRadius: Tokens.radii({ key: 'lg' }), + flexDirection: 'row', + alignItems: 'center', + gap: Tokens.spacer({ key: 'sm' }), + backgroundColor, + }, + + textWrapper: { + flex: 1, + }, + + title: {}, + + description: {}, + }); +}; diff --git a/src/components/Organisms/index.ts b/src/components/Organisms/index.ts index a1e9f50..61e0858 100644 --- a/src/components/Organisms/index.ts +++ b/src/components/Organisms/index.ts @@ -1,2 +1,3 @@ export * from './BottomModal'; export * from './BottomListModal'; +export * from './Toaster'; From a5745e35cf1d455c50d952b11cd601f00ed5df57 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:09:24 -0300 Subject: [PATCH 04/17] test(toaster): add ToasterApi, ViewModel and component tests Co-Authored-By: Claude Sonnet 4.6 --- .../Toaster/__tests__/Toaster.test.tsx | 69 ++++++++++ .../ToasterApi/__tests__/ToasterApi.test.ts | 76 +++++++++++ .../__tests__/useToasterViewModel.test.ts | 127 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 src/components/Organisms/Toaster/__tests__/Toaster.test.tsx create mode 100644 src/components/Organisms/Toaster/controllers/ToasterApi/__tests__/ToasterApi.test.ts create mode 100644 src/components/Organisms/Toaster/hooks/useToasterViewModel/__tests__/useToasterViewModel.test.ts diff --git a/src/components/Organisms/Toaster/__tests__/Toaster.test.tsx b/src/components/Organisms/Toaster/__tests__/Toaster.test.tsx new file mode 100644 index 0000000..88f2d37 --- /dev/null +++ b/src/components/Organisms/Toaster/__tests__/Toaster.test.tsx @@ -0,0 +1,69 @@ +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(); + expect(toJSON()).toBeNull(); + }); + + it('Shows title after ToasterApi.show is called', () => { + render(); + + 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(); + + 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(); + + act(() => { + ToasterApi.show({ type: 'success', title: 'First' }); + }); + + act(() => { + ToasterApi.show({ type: 'error', title: 'Second' }); + }); + + expect(screen.queryByText('First')).toBeNull(); + expect(screen.getByText('Second')).toBeTruthy(); + }); + + it('ToasterApi.hide does not throw', () => { + render(); + + expect(() => { + act(() => { + ToasterApi.hide(); + }); + }).not.toThrow(); + }); +}); diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/__tests__/ToasterApi.test.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/__tests__/ToasterApi.test.ts new file mode 100644 index 0000000..6104649 --- /dev/null +++ b/src/components/Organisms/Toaster/controllers/ToasterApi/__tests__/ToasterApi.test.ts @@ -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); + }); + }); +}); diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/__tests__/useToasterViewModel.test.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/__tests__/useToasterViewModel.test.ts new file mode 100644 index 0000000..77e4bc8 --- /dev/null +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/__tests__/useToasterViewModel.test.ts @@ -0,0 +1,127 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useToasterViewModel } from '../index'; +import { ToasterApi } from '../../../controllers'; + +jest.spyOn(ToasterApi, 'setRef'); + +describe('useToasterViewModel', () => { + beforeEach(() => { + jest.useFakeTimers(); + ToasterApi.setRef({ current: null }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('Initial state', () => { + it('toasterProps starts as undefined', () => { + const { result } = renderHook(() => useToasterViewModel()); + expect(result.current.toasterProps).toBeUndefined(); + }); + + it('isVisible starts as false', () => { + const { result } = renderHook(() => useToasterViewModel()); + expect(result.current.isVisible).toBe(false); + }); + + it('Registers with ToasterApi on mount', () => { + renderHook(() => useToasterViewModel()); + expect(ToasterApi.setRef).toHaveBeenCalledTimes(1); + }); + }); + + describe('show', () => { + it('Sets toasterProps and isVisible to true', () => { + const { result } = renderHook(() => useToasterViewModel()); + + act(() => { + ToasterApi.show({ type: 'success', title: 'Hello' }); + }); + + expect(result.current.toasterProps).toEqual({ type: 'success', title: 'Hello' }); + expect(result.current.isVisible).toBe(true); + }); + + it('Auto-dismisses after default 3000ms', () => { + const { result } = renderHook(() => useToasterViewModel()); + + act(() => { + ToasterApi.show({ type: 'info', title: 'Auto-dismiss' }); + }); + + expect(result.current.isVisible).toBe(true); + + act(() => { + jest.advanceTimersByTime(3000); + }); + + expect(result.current.isVisible).toBe(false); + }); + + it('Honors custom duration', () => { + const { result } = renderHook(() => useToasterViewModel()); + + act(() => { + ToasterApi.show({ type: 'warning', title: 'Custom', duration: 1000 }); + }); + + act(() => { + jest.advanceTimersByTime(999); + }); + + expect(result.current.isVisible).toBe(true); + + act(() => { + jest.advanceTimersByTime(1); + }); + + expect(result.current.isVisible).toBe(false); + }); + + it('Calling show twice resets the timer', () => { + const { result } = renderHook(() => useToasterViewModel()); + + act(() => { + ToasterApi.show({ type: 'success', title: 'First' }); + }); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + act(() => { + ToasterApi.show({ type: 'error', title: 'Second' }); + }); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(result.current.isVisible).toBe(true); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.isVisible).toBe(false); + }); + }); + + describe('hide', () => { + it('Sets isVisible to false and clears timer', () => { + const { result } = renderHook(() => useToasterViewModel()); + + act(() => { + ToasterApi.show({ type: 'success', title: 'Hello' }); + }); + + act(() => { + ToasterApi.hide(); + }); + + expect(result.current.isVisible).toBe(false); + }); + }); +}); From 688625645d655ea6a7a547b5652f9dfc75d84052 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:10:06 -0300 Subject: [PATCH 05/17] docs(storybook): add Toaster stories Co-Authored-By: Claude Sonnet 4.6 --- .../Organisms/Toaster/Toaster.stories.tsx | 99 +++++++++++++++++++ .../Organisms/Toaster/Toaster.styles.ts | 24 +++++ 2 files changed, 123 insertions(+) create mode 100644 example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx create mode 100644 example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts diff --git a/example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx new file mode 100644 index 0000000..b15f105 --- /dev/null +++ b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx @@ -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 }) => ( + + {label} + +); + +const DefaultDemo = () => ( + + + ToasterApi.show({ + type: 'success', + title: 'Success!', + description: 'Your action was completed.', + }) + } + /> + + ToasterApi.show({ + type: 'error', + title: 'Error!', + description: 'Something went wrong.', + }) + } + /> + + ToasterApi.show({ + type: 'warning', + title: 'Warning!', + description: 'Please check your input.', + }) + } + /> + + ToasterApi.show({ + type: 'info', + title: 'Info', + description: 'Here is some information.', + }) + } + /> + + +); + +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 ( + + + + + ); +}; + +const meta = { + title: 'Organisms/Toaster', + component: View, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; + +export const Interactive: Story = { + render: () => , +}; diff --git a/example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts new file mode 100644 index 0000000..4327ee5 --- /dev/null +++ b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts @@ -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, + }, +}); From 0d18ba9f2d4df84c568b6b392ffc15adb3b4d6f9 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:14:24 -0300 Subject: [PATCH 06/17] fix(toaster): extract duration and slide offset constants Co-Authored-By: Claude Sonnet 4.6 --- .../Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts | 1 + .../Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts | 3 +++ src/components/Organisms/Toaster/constants/index.ts | 2 ++ .../Organisms/Toaster/hooks/useReanimatedStyles/index.ts | 3 ++- .../Organisms/Toaster/hooks/useToasterViewModel/index.ts | 3 ++- 5 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 src/components/Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts create mode 100644 src/components/Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts create mode 100644 src/components/Organisms/Toaster/constants/index.ts diff --git a/src/components/Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts b/src/components/Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts new file mode 100644 index 0000000..06cbaa9 --- /dev/null +++ b/src/components/Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts @@ -0,0 +1 @@ +export const DEFAULT_TOAST_DURATION_MS = 3000; diff --git a/src/components/Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts b/src/components/Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts new file mode 100644 index 0000000..044fd8c --- /dev/null +++ b/src/components/Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts @@ -0,0 +1,3 @@ +import { Tokens } from '../../../../tokens/Tokens.class'; + +export const TOAST_SLIDE_OFFSET = Tokens.spacer({ key: 'xl' }); diff --git a/src/components/Organisms/Toaster/constants/index.ts b/src/components/Organisms/Toaster/constants/index.ts new file mode 100644 index 0000000..0b5b843 --- /dev/null +++ b/src/components/Organisms/Toaster/constants/index.ts @@ -0,0 +1,2 @@ +export * from './DEFAULT_TOAST_DURATION_MS'; +export * from './TOAST_SLIDE_OFFSET'; diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts index 0170fd4..06b0fa5 100644 --- a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -5,6 +5,7 @@ import { useSharedValue, withTiming, } from 'react-native-reanimated'; +import { TOAST_SLIDE_OFFSET } from '../../constants'; export const useReanimatedStyles = ({ isVisible }: { isVisible: boolean }) => { const progress = useSharedValue(0); @@ -17,7 +18,7 @@ export const useReanimatedStyles = ({ isVisible }: { isVisible: boolean }) => { opacity: progress.value, transform: [ { - translateY: interpolate(progress.value, [0, 1], [40, 0]), + translateY: interpolate(progress.value, [0, 1], [TOAST_SLIDE_OFFSET, 0]), }, ], }), []); diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts index 315d2c1..8579d7f 100644 --- a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts @@ -2,6 +2,7 @@ import type { IToasterApi } from '../../controllers/ToasterApi/types'; import type { IToasterRefProps } from '../../types'; import { useState, useRef, useCallback, useEffect } from 'react'; import { ToasterApi } from '../../controllers'; +import { DEFAULT_TOAST_DURATION_MS } from '../../constants'; export const useToasterViewModel = () => { const [toasterProps, setToasterPropsState] = useState(); @@ -21,7 +22,7 @@ export const useToasterViewModel = () => { clearTimer(); setToasterPropsState(props); setIsVisible(true); - timeoutRef.current = setTimeout(() => setIsVisible(false), props.duration ?? 3000); + timeoutRef.current = setTimeout(() => setIsVisible(false), props.duration ?? DEFAULT_TOAST_DURATION_MS); }, []); const hide = useCallback(() => { From 88b6bddab8e109bd0f3d32f3e0952a7698beb49c Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:15:37 -0300 Subject: [PATCH 07/17] fix(toaster): resolve review issues in component and hooks Co-Authored-By: Claude Sonnet 4.6 --- .../Toaster/__tests__/Toaster.test.tsx | 3 +- .../hooks/useReanimatedStyles/index.ts | 3 +- .../hooks/useToasterViewModel/index.ts | 13 +++-- src/components/Organisms/Toaster/index.tsx | 49 ++++++++++--------- src/components/Organisms/Toaster/styles.ts | 4 -- 5 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/components/Organisms/Toaster/__tests__/Toaster.test.tsx b/src/components/Organisms/Toaster/__tests__/Toaster.test.tsx index 88f2d37..e8dc4c2 100644 --- a/src/components/Organisms/Toaster/__tests__/Toaster.test.tsx +++ b/src/components/Organisms/Toaster/__tests__/Toaster.test.tsx @@ -14,7 +14,7 @@ describe('Toaster', () => { it('Renders without crashing', () => { const { toJSON } = render(); - expect(toJSON()).toBeNull(); + expect(toJSON()).toBeTruthy(); }); it('Shows title after ToasterApi.show is called', () => { @@ -51,6 +51,7 @@ describe('Toaster', () => { act(() => { ToasterApi.show({ type: 'error', title: 'Second' }); + jest.advanceTimersByTime(100); }); expect(screen.queryByText('First')).toBeNull(); diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts index 06b0fa5..369dfa2 100644 --- a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -12,7 +12,8 @@ export const useReanimatedStyles = ({ isVisible }: { isVisible: boolean }) => { useEffect(() => { progress.value = withTiming(isVisible ? 1 : 0, { duration: 250 }); - }, [isVisible, progress]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible]); const container = useAnimatedStyle(() => ({ opacity: progress.value, diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts index 8579d7f..c6f7406 100644 --- a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts @@ -11,35 +11,38 @@ export const useToasterViewModel = () => { const timeoutRef = useRef | null>(null); const imperativeRef = useRef(null); - const clearTimer = () => { + const clearTimer = useCallback(() => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - }; + }, []); const show = useCallback((props: IToasterRefProps) => { clearTimer(); setToasterPropsState(props); setIsVisible(true); timeoutRef.current = setTimeout(() => setIsVisible(false), props.duration ?? DEFAULT_TOAST_DURATION_MS); - }, []); + }, [clearTimer]); const hide = useCallback(() => { clearTimer(); setIsVisible(false); - }, []); + }, [clearTimer]); useEffect(() => { imperativeRef.current = { show, hide }; ToasterApi.setRef(imperativeRef); + return () => { + ToasterApi.setRef({ current: null }); + }; }, [show, hide]); useEffect(() => { return () => { clearTimer(); }; - }, []); + }, [clearTimer]); return { toasterProps, diff --git a/src/components/Organisms/Toaster/index.tsx b/src/components/Organisms/Toaster/index.tsx index 28c1eac..0bffcf3 100644 --- a/src/components/Organisms/Toaster/index.tsx +++ b/src/components/Organisms/Toaster/index.tsx @@ -1,6 +1,5 @@ -import type { IToasterProps } from './types'; +import type { IToasterProps, ToasterType } from './types'; import type { IconName } from '../../Atoms/Icon/types/IconName'; -import type { ToasterType } from './types'; import React from 'react'; import { View } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -21,31 +20,33 @@ export const Toaster: React.FC = () => { const styles = useStyles({ type: toasterProps?.type }); const reanimatedStyles = useReanimatedStyles({ isVisible }); - if (!toasterProps) { - return null; - } - return ( - - - - {toasterProps.title} - - {toasterProps.description - ? - - {toasterProps.description} - - : null - } - + {toasterProps + ? + <> + + + + {toasterProps.title} + + {toasterProps.description + ? + + {toasterProps.description} + + : null + } + + + : null + } ); }; diff --git a/src/components/Organisms/Toaster/styles.ts b/src/components/Organisms/Toaster/styles.ts index c22dfb3..871c71b 100644 --- a/src/components/Organisms/Toaster/styles.ts +++ b/src/components/Organisms/Toaster/styles.ts @@ -32,9 +32,5 @@ export const useStyles = ({ type }: { type?: ToasterType }) => { textWrapper: { flex: 1, }, - - title: {}, - - description: {}, }); }; From e199a4cd5aeb3d524cddaa60b64e814235af1f79 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:23:26 -0300 Subject: [PATCH 08/17] refactor(toaster): move types to folder, map to constants, simplify vm Co-Authored-By: Claude Sonnet 4.6 --- .../Toaster/constants/TYPE_ICON_MAP.ts | 9 +++++++ .../Organisms/Toaster/constants/index.ts | 1 + .../ToasterApi/{types.ts => types/index.ts} | 2 +- .../hooks/useToasterViewModel/index.ts | 27 +++++++++---------- src/components/Organisms/Toaster/index.tsx | 20 ++++++-------- 5 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts rename src/components/Organisms/Toaster/controllers/ToasterApi/{types.ts => types/index.ts} (63%) diff --git a/src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts b/src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts new file mode 100644 index 0000000..0af9be8 --- /dev/null +++ b/src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts @@ -0,0 +1,9 @@ +import type { ToasterType } from '../types'; +import type { IconName } from '../../../Atoms/Icon/types/IconName'; + +export const TYPE_ICON_MAP: Record = { + success: 'CircleCheck', + error: 'CircleX', + warning: 'TriangleAlert', + info: 'CircleAlert', +}; diff --git a/src/components/Organisms/Toaster/constants/index.ts b/src/components/Organisms/Toaster/constants/index.ts index 0b5b843..37b5423 100644 --- a/src/components/Organisms/Toaster/constants/index.ts +++ b/src/components/Organisms/Toaster/constants/index.ts @@ -1,2 +1,3 @@ export * from './DEFAULT_TOAST_DURATION_MS'; export * from './TOAST_SLIDE_OFFSET'; +export * from './TYPE_ICON_MAP'; diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/types.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts similarity index 63% rename from src/components/Organisms/Toaster/controllers/ToasterApi/types.ts rename to src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts index dd82cb7..424c35e 100644 --- a/src/components/Organisms/Toaster/controllers/ToasterApi/types.ts +++ b/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts @@ -1,4 +1,4 @@ -import type { IToasterRefProps } from '../../types'; +import type { IToasterRefProps } from '../../../types'; export interface IToasterApi { show: (props: IToasterRefProps) => void; diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts index c6f7406..68ee9d9 100644 --- a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts @@ -1,6 +1,6 @@ import type { IToasterApi } from '../../controllers/ToasterApi/types'; import type { IToasterRefProps } from '../../types'; -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { ToasterApi } from '../../controllers'; import { DEFAULT_TOAST_DURATION_MS } from '../../constants'; @@ -11,38 +11,35 @@ export const useToasterViewModel = () => { const timeoutRef = useRef | null>(null); const imperativeRef = useRef(null); - const clearTimer = useCallback(() => { + const clearTimer = () => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - }, []); + }; - const show = useCallback((props: IToasterRefProps) => { + const show = (props: IToasterRefProps) => { clearTimer(); setToasterPropsState(props); setIsVisible(true); timeoutRef.current = setTimeout(() => setIsVisible(false), props.duration ?? DEFAULT_TOAST_DURATION_MS); - }, [clearTimer]); + }; - const hide = useCallback(() => { + const hide = () => { clearTimer(); setIsVisible(false); - }, [clearTimer]); + }; - useEffect(() => { - imperativeRef.current = { show, hide }; - ToasterApi.setRef(imperativeRef); - return () => { - ToasterApi.setRef({ current: null }); - }; - }, [show, hide]); + imperativeRef.current = { show, hide }; useEffect(() => { + ToasterApi.setRef(imperativeRef); return () => { clearTimer(); + ToasterApi.setRef({ current: null }); }; - }, [clearTimer]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return { toasterProps, diff --git a/src/components/Organisms/Toaster/index.tsx b/src/components/Organisms/Toaster/index.tsx index 0bffcf3..ad6dbfc 100644 --- a/src/components/Organisms/Toaster/index.tsx +++ b/src/components/Organisms/Toaster/index.tsx @@ -1,29 +1,23 @@ -import type { IToasterProps, ToasterType } from './types'; -import type { IconName } from '../../Atoms/Icon/types/IconName'; +import type { IToasterProps } from './types'; import React from 'react'; import { View } from 'react-native'; import Animated from 'react-native-reanimated'; import { useStyles } from './styles'; import { useToasterViewModel, useReanimatedStyles } from './hooks'; +import { TYPE_ICON_MAP } from './constants'; import { Text } from '../../Atoms/Text'; import { Icon } from '../../Atoms/Icon'; -const TYPE_ICON_MAP: Record = { - success: 'CircleCheck', - error: 'CircleX', - warning: 'TriangleAlert', - info: 'CircleAlert', -}; - export const Toaster: React.FC = () => { const { toasterProps, isVisible } = useToasterViewModel(); - const styles = useStyles({ type: toasterProps?.type }); - const reanimatedStyles = useReanimatedStyles({ isVisible }); + + const styles = useStyles(toasterProps?.type); + const reanimatedStyles = useReanimatedStyles(isVisible); return ( + style={[styles.wrapper, reanimatedStyles.wrapper]}> {toasterProps ? <> @@ -32,10 +26,12 @@ export const Toaster: React.FC = () => { size="small_16" color="white" /> + {toasterProps.title} + {toasterProps.description ? From 2672413628f72fa6894a06895599cdcf8ef42491 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:23:36 -0300 Subject: [PATCH 09/17] style(toaster): remove unnecessary eslint-disable comment Co-Authored-By: Claude Sonnet 4.6 --- .../Organisms/Toaster/hooks/useToasterViewModel/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts index 68ee9d9..d8d4f3d 100644 --- a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts @@ -38,7 +38,6 @@ export const useToasterViewModel = () => { clearTimer(); ToasterApi.setRef({ current: null }); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { From a6d068c8f879cb6158d31435c57ea49901bea64c Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:29:16 -0300 Subject: [PATCH 10/17] refactor(toaster): move IToasterApi to own file in types folder Co-Authored-By: Claude Sonnet 4.6 --- .../Toaster/controllers/ToasterApi/types/IToasterApi.ts | 6 ++++++ .../Toaster/controllers/ToasterApi/types/index.ts | 7 +------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 src/components/Organisms/Toaster/controllers/ToasterApi/types/IToasterApi.ts diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/types/IToasterApi.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/types/IToasterApi.ts new file mode 100644 index 0000000..424c35e --- /dev/null +++ b/src/components/Organisms/Toaster/controllers/ToasterApi/types/IToasterApi.ts @@ -0,0 +1,6 @@ +import type { IToasterRefProps } from '../../../types'; + +export interface IToasterApi { + show: (props: IToasterRefProps) => void; + hide: () => void; +} diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts index 424c35e..aa3236a 100644 --- a/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts +++ b/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts @@ -1,6 +1 @@ -import type { IToasterRefProps } from '../../../types'; - -export interface IToasterApi { - show: (props: IToasterRefProps) => void; - hide: () => void; -} +export * from './IToasterApi'; From ca28b89b29b1fb5781fe96e934225652973af758 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:36:46 -0300 Subject: [PATCH 11/17] feat(toaster): add drag-to-dismiss, slingshot and close button Co-Authored-By: Claude Sonnet 4.6 --- __mocks__/react-native-gesture-handler.ts | 29 ++++++++ package.json | 3 +- .../constants/TOAST_DRAG_DISMISS_THRESHOLD.ts | 1 + .../constants/TOAST_SLINGSHOT_MAX_OFFSET.ts | 3 + .../Organisms/Toaster/constants/index.ts | 2 + .../hooks/useReanimatedStyles/index.ts | 42 ++++++++++-- .../hooks/useToasterViewModel/index.ts | 1 + src/components/Organisms/Toaster/index.tsx | 67 ++++++++++--------- src/components/Organisms/Toaster/styles.ts | 8 ++- 9 files changed, 114 insertions(+), 42 deletions(-) create mode 100644 __mocks__/react-native-gesture-handler.ts create mode 100644 src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts create mode 100644 src/components/Organisms/Toaster/constants/TOAST_SLINGSHOT_MAX_OFFSET.ts diff --git a/__mocks__/react-native-gesture-handler.ts b/__mocks__/react-native-gesture-handler.ts new file mode 100644 index 0000000..a101230 --- /dev/null +++ b/__mocks__/react-native-gesture-handler.ts @@ -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, +}; diff --git a/package.json b/package.json index be0e3ec..217afe4 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,8 @@ "node_modules/(?!(react-native|@react-native|lucide-react-native)/)" ], "moduleNameMapper": { - "^react-native-reanimated$": "/__mocks__/react-native-reanimated.ts" + "^react-native-reanimated$": "/__mocks__/react-native-reanimated.ts", + "^react-native-gesture-handler$": "/__mocks__/react-native-gesture-handler.ts" }, "modulePathIgnorePatterns": [ "/example/node_modules", diff --git a/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts new file mode 100644 index 0000000..bf21f30 --- /dev/null +++ b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts @@ -0,0 +1 @@ +export const TOAST_DRAG_DISMISS_THRESHOLD = 80; diff --git a/src/components/Organisms/Toaster/constants/TOAST_SLINGSHOT_MAX_OFFSET.ts b/src/components/Organisms/Toaster/constants/TOAST_SLINGSHOT_MAX_OFFSET.ts new file mode 100644 index 0000000..5b3a1b2 --- /dev/null +++ b/src/components/Organisms/Toaster/constants/TOAST_SLINGSHOT_MAX_OFFSET.ts @@ -0,0 +1,3 @@ +import { Tokens } from '../../../../tokens/Tokens.class'; + +export const TOAST_SLINGSHOT_MAX_OFFSET = Tokens.spacer({ key: 'lg' }); diff --git a/src/components/Organisms/Toaster/constants/index.ts b/src/components/Organisms/Toaster/constants/index.ts index 37b5423..26ae8df 100644 --- a/src/components/Organisms/Toaster/constants/index.ts +++ b/src/components/Organisms/Toaster/constants/index.ts @@ -1,3 +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'; diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts index 369dfa2..d55e096 100644 --- a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -1,30 +1,58 @@ import { useEffect } from 'react'; +import { Gesture } from 'react-native-gesture-handler'; import { interpolate, + runOnJS, useAnimatedStyle, useSharedValue, + withSpring, withTiming, } from 'react-native-reanimated'; -import { TOAST_SLIDE_OFFSET } from '../../constants'; +import { + TOAST_DRAG_DISMISS_THRESHOLD, + TOAST_SLINGSHOT_MAX_OFFSET, + TOAST_SLIDE_OFFSET, +} from '../../constants'; -export const useReanimatedStyles = ({ isVisible }: { isVisible: boolean }) => { +export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) => { const progress = useSharedValue(0); + const dragY = useSharedValue(0); useEffect(() => { progress.value = withTiming(isVisible ? 1 : 0, { duration: 250 }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isVisible]); + if (!isVisible) { + dragY.value = withTiming(0, { duration: 250 }); + } + }, [isVisible, progress, dragY]); + + const gesture = Gesture.Pan() + .onUpdate((e) => { + if (e.translationY < 0) { + dragY.value = Math.max(e.translationY, -TOAST_SLINGSHOT_MAX_OFFSET); + } else { + dragY.value = e.translationY; + } + }) + .onEnd((e) => { + if (e.translationY > TOAST_DRAG_DISMISS_THRESHOLD) { + dragY.value = withTiming(TOAST_SLIDE_OFFSET, { duration: 200 }); + runOnJS(onDismiss)(); + } else { + dragY.value = withSpring(0); + } + }); - const container = useAnimatedStyle(() => ({ + const wrapper = useAnimatedStyle(() => ({ opacity: progress.value, transform: [ { - translateY: interpolate(progress.value, [0, 1], [TOAST_SLIDE_OFFSET, 0]), + translateY: interpolate(progress.value, [0, 1], [TOAST_SLIDE_OFFSET, 0]) + dragY.value, }, ], }), []); return { - container, + wrapper, + gesture, }; }; diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts index d8d4f3d..a19add1 100644 --- a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts @@ -43,5 +43,6 @@ export const useToasterViewModel = () => { return { toasterProps, isVisible, + hide, }; }; diff --git a/src/components/Organisms/Toaster/index.tsx b/src/components/Organisms/Toaster/index.tsx index ad6dbfc..14e209a 100644 --- a/src/components/Organisms/Toaster/index.tsx +++ b/src/components/Organisms/Toaster/index.tsx @@ -1,7 +1,8 @@ import type { IToasterProps } from './types'; import React from 'react'; -import { View } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import Animated from 'react-native-reanimated'; +import { GestureDetector } from 'react-native-gesture-handler'; import { useStyles } from './styles'; import { useToasterViewModel, useReanimatedStyles } from './hooks'; import { TYPE_ICON_MAP } from './constants'; @@ -9,41 +10,43 @@ import { Text } from '../../Atoms/Text'; import { Icon } from '../../Atoms/Icon'; export const Toaster: React.FC = () => { - const { toasterProps, isVisible } = useToasterViewModel(); - + const { toasterProps, isVisible, hide } = useToasterViewModel(); const styles = useStyles(toasterProps?.type); - const reanimatedStyles = useReanimatedStyles(isVisible); + const { wrapper, gesture } = useReanimatedStyles(isVisible, hide); return ( - - {toasterProps - ? - <> - - - - - {toasterProps.title} - - - {toasterProps.description - ? - - {toasterProps.description} + + + {toasterProps + ? + <> + + + + {toasterProps.title} - : null - } - - - : null - } - + {toasterProps.description + ? + + {toasterProps.description} + + : null + } + + + + + + : null + } + + ); }; diff --git a/src/components/Organisms/Toaster/styles.ts b/src/components/Organisms/Toaster/styles.ts index 871c71b..f42dd0b 100644 --- a/src/components/Organisms/Toaster/styles.ts +++ b/src/components/Organisms/Toaster/styles.ts @@ -3,7 +3,7 @@ import { StyleSheet } from 'react-native'; import { useRubberDuckStore } from '../../../store'; import { Tokens } from '../../../tokens/Tokens.class'; -export const useStyles = ({ type }: { type?: ToasterType }) => { +export const useStyles = (type: ToasterType | undefined) => { const colors = useRubberDuckStore((s) => s.colors); const backgroundColorMap: Record = { @@ -16,7 +16,7 @@ export const useStyles = ({ type }: { type?: ToasterType }) => { const backgroundColor = type ? backgroundColorMap[type] : colors.info; return StyleSheet.create({ - container: { + wrapper: { position: 'absolute', bottom: Tokens.spacer({ key: 'lg' }), left: Tokens.spacer({ key: 'md' }), @@ -32,5 +32,9 @@ export const useStyles = ({ type }: { type?: ToasterType }) => { textWrapper: { flex: 1, }, + + closeButton: { + padding: Tokens.spacer({ key: 'xxs' }), + }, }); }; From a79f4246e91f306dcba9dfde51f8dbd1aecbdfd6 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:38:44 -0300 Subject: [PATCH 12/17] feat(toaster): spring animation on show, timing on hide Co-Authored-By: Claude Sonnet 4.6 --- .../Organisms/Toaster/hooks/useReanimatedStyles/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts index d55e096..2bc0fb2 100644 --- a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -19,9 +19,11 @@ export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) = const dragY = useSharedValue(0); useEffect(() => { - progress.value = withTiming(isVisible ? 1 : 0, { duration: 250 }); - if (!isVisible) { - dragY.value = withTiming(0, { duration: 250 }); + if (isVisible) { + progress.value = withSpring(1, { damping: 18, stiffness: 180 }); + } else { + progress.value = withTiming(0, { duration: 200 }); + dragY.value = withTiming(0, { duration: 200 }); } }, [isVisible, progress, dragY]); From 9ec75b72f174b46f5d9c295cedf3c201a89fd48b Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:40:22 -0300 Subject: [PATCH 13/17] fix(toaster): reduce drag dismiss threshold from 80 to 40 Co-Authored-By: Claude Sonnet 4.6 --- .../Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts index bf21f30..ce1dad9 100644 --- a/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts +++ b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts @@ -1 +1 @@ -export const TOAST_DRAG_DISMISS_THRESHOLD = 80; +export const TOAST_DRAG_DISMISS_THRESHOLD = 40; From f1c94f337d66cdd0a3b40ce07ea9bdc832e029a5 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:41:57 -0300 Subject: [PATCH 14/17] fix(toaster): dismiss by drag animates down instead of snapping back Co-Authored-By: Claude Sonnet 4.6 --- .../Organisms/Toaster/hooks/useReanimatedStyles/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts index 2bc0fb2..13eecdd 100644 --- a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -20,10 +20,10 @@ export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) = useEffect(() => { if (isVisible) { + dragY.value = 0; progress.value = withSpring(1, { damping: 18, stiffness: 180 }); } else { progress.value = withTiming(0, { duration: 200 }); - dragY.value = withTiming(0, { duration: 200 }); } }, [isVisible, progress, dragY]); @@ -37,7 +37,6 @@ export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) = }) .onEnd((e) => { if (e.translationY > TOAST_DRAG_DISMISS_THRESHOLD) { - dragY.value = withTiming(TOAST_SLIDE_OFFSET, { duration: 200 }); runOnJS(onDismiss)(); } else { dragY.value = withSpring(0); From fd10affbcae0c4b0f3e5a9e80a1976a9958181d5 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:44:29 -0300 Subject: [PATCH 15/17] fix(toaster): separate opacity and translateY animations to avoid spring overshoot on opacity Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/useReanimatedStyles/index.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts index 13eecdd..7f5eed8 100644 --- a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import { Gesture } from 'react-native-gesture-handler'; import { - interpolate, runOnJS, useAnimatedStyle, useSharedValue, @@ -15,17 +14,20 @@ import { } from '../../constants'; export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) => { - const progress = useSharedValue(0); + const opacity = useSharedValue(0); + const translateY = useSharedValue(TOAST_SLIDE_OFFSET); const dragY = useSharedValue(0); useEffect(() => { if (isVisible) { dragY.value = 0; - progress.value = withSpring(1, { damping: 18, stiffness: 180 }); + opacity.value = withTiming(1, { duration: 200 }); + translateY.value = withSpring(0, { damping: 18, stiffness: 180 }); } else { - progress.value = withTiming(0, { duration: 200 }); + opacity.value = withTiming(0, { duration: 200 }); + translateY.value = withTiming(TOAST_SLIDE_OFFSET, { duration: 200 }); } - }, [isVisible, progress, dragY]); + }, [isVisible, opacity, translateY, dragY]); const gesture = Gesture.Pan() .onUpdate((e) => { @@ -44,12 +46,8 @@ export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) = }); const wrapper = useAnimatedStyle(() => ({ - opacity: progress.value, - transform: [ - { - translateY: interpolate(progress.value, [0, 1], [TOAST_SLIDE_OFFSET, 0]) + dragY.value, - }, - ], + opacity: opacity.value, + transform: [{ translateY: translateY.value + dragY.value }], }), []); return { From 9845ec80e642eafad7e795e41692b7dd9217c867 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sun, 3 May 2026 23:46:27 -0300 Subject: [PATCH 16/17] style(ui): increase activeOpacity from 0.7 to 0.8 Improves touch feedback consistency across Button, RadioButton, and BottomListModal item components. Co-Authored-By: Claude Haiku 4.5 --- src/components/Molecules/Button/index.tsx | 2 +- src/components/Molecules/RadioButton/index.tsx | 2 +- .../components/ModalList/library/mountRenderItem.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Molecules/Button/index.tsx b/src/components/Molecules/Button/index.tsx index 0dd861c..ecb4463 100644 --- a/src/components/Molecules/Button/index.tsx +++ b/src/components/Molecules/Button/index.tsx @@ -23,7 +23,7 @@ export const Button: React.FC = (props) => { return ( diff --git a/src/components/Molecules/RadioButton/index.tsx b/src/components/Molecules/RadioButton/index.tsx index 8e474db..ba9e4de 100644 --- a/src/components/Molecules/RadioButton/index.tsx +++ b/src/components/Molecules/RadioButton/index.tsx @@ -36,7 +36,7 @@ export const RadioButton: React.FC = (props) => { + activeOpacity={0.8}> diff --git a/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx b/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx index 26978ac..19ea35e 100644 --- a/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx +++ b/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx @@ -55,7 +55,7 @@ export const mountRenderItem = (params: IMountRenderItemParams): ListRenderItem< case 'avatar': return ( - + {item.title} @@ -67,7 +67,7 @@ export const mountRenderItem = (params: IMountRenderItemParams): ListRenderItem< case 'icon': if (!item.iconName) { return null; } return ( - + Date: Sun, 3 May 2026 23:46:30 -0300 Subject: [PATCH 17/17] style(toaster): improve close button touch feedback Add activeOpacity={0.8} and hitSlop={8} to closeButton for consistent touch feedback and larger interactive area. Co-Authored-By: Claude Haiku 4.5 --- src/components/Organisms/Toaster/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Organisms/Toaster/index.tsx b/src/components/Organisms/Toaster/index.tsx index 14e209a..bb4b599 100644 --- a/src/components/Organisms/Toaster/index.tsx +++ b/src/components/Organisms/Toaster/index.tsx @@ -39,7 +39,12 @@ export const Toaster: React.FC = () => { : null } - + +