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/__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/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, + }, +}); 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/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 ( - + { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + ToasterApi.setRef({ current: null }); + }); + + it('Renders without crashing', () => { + const { toJSON } = render(); + expect(toJSON()).toBeTruthy(); + }); + + 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' }); + jest.advanceTimersByTime(100); + }); + + 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/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_DRAG_DISMISS_THRESHOLD.ts b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts new file mode 100644 index 0000000..ce1dad9 --- /dev/null +++ b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts @@ -0,0 +1 @@ +export const TOAST_DRAG_DISMISS_THRESHOLD = 40; 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/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/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 new file mode 100644 index 0000000..26ae8df --- /dev/null +++ b/src/components/Organisms/Toaster/constants/index.ts @@ -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'; 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/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/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 new file mode 100644 index 0000000..aa3236a --- /dev/null +++ b/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts @@ -0,0 +1 @@ +export * from './IToasterApi'; 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/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..7f5eed8 --- /dev/null +++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { Gesture } from 'react-native-gesture-handler'; +import { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { + TOAST_DRAG_DISMISS_THRESHOLD, + TOAST_SLINGSHOT_MAX_OFFSET, + TOAST_SLIDE_OFFSET, +} from '../../constants'; + +export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) => { + const opacity = useSharedValue(0); + const translateY = useSharedValue(TOAST_SLIDE_OFFSET); + const dragY = useSharedValue(0); + + useEffect(() => { + if (isVisible) { + dragY.value = 0; + opacity.value = withTiming(1, { duration: 200 }); + translateY.value = withSpring(0, { damping: 18, stiffness: 180 }); + } else { + opacity.value = withTiming(0, { duration: 200 }); + translateY.value = withTiming(TOAST_SLIDE_OFFSET, { duration: 200 }); + } + }, [isVisible, opacity, translateY, 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) { + runOnJS(onDismiss)(); + } else { + dragY.value = withSpring(0); + } + }); + + const wrapper = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value + dragY.value }], + }), []); + + return { + wrapper, + gesture, + }; +}; 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); + }); + }); +}); 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..a19add1 --- /dev/null +++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts @@ -0,0 +1,48 @@ +import type { IToasterApi } from '../../controllers/ToasterApi/types'; +import type { IToasterRefProps } from '../../types'; +import { useState, useRef, useEffect } from 'react'; +import { ToasterApi } from '../../controllers'; +import { DEFAULT_TOAST_DURATION_MS } from '../../constants'; + +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 = (props: IToasterRefProps) => { + clearTimer(); + setToasterPropsState(props); + setIsVisible(true); + timeoutRef.current = setTimeout(() => setIsVisible(false), props.duration ?? DEFAULT_TOAST_DURATION_MS); + }; + + const hide = () => { + clearTimer(); + setIsVisible(false); + }; + + imperativeRef.current = { show, hide }; + + useEffect(() => { + ToasterApi.setRef(imperativeRef); + return () => { + clearTimer(); + ToasterApi.setRef({ current: null }); + }; + }, []); + + return { + toasterProps, + isVisible, + hide, + }; +}; diff --git a/src/components/Organisms/Toaster/index.tsx b/src/components/Organisms/Toaster/index.tsx new file mode 100644 index 0000000..bb4b599 --- /dev/null +++ b/src/components/Organisms/Toaster/index.tsx @@ -0,0 +1,60 @@ +import type { IToasterProps } from './types'; +import React from 'react'; +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'; +import { Text } from '../../Atoms/Text'; +import { Icon } from '../../Atoms/Icon'; + +export const Toaster: React.FC = () => { + const { toasterProps, isVisible, hide } = useToasterViewModel(); + const styles = useStyles(toasterProps?.type); + const { wrapper, gesture } = useReanimatedStyles(isVisible, hide); + + return ( + + + {toasterProps + ? + <> + + + + {toasterProps.title} + + {toasterProps.description + ? + + {toasterProps.description} + + : null + } + + + + + + + : 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..f42dd0b --- /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: ToasterType | undefined) => { + 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({ + wrapper: { + 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, + }, + + closeButton: { + padding: Tokens.spacer({ key: 'xxs' }), + }, + }); +}; 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'; 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';